feat(cv): localise section AI output
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import AiSectionAssistant from "./components/cv/AiSectionAssistant";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { ToastProvider } from "./toast";
|
||||
|
||||
jest.mock("./accountPlan", () => ({ useAccountPlan: () => ({ canUseAi: true }) }));
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_error: unknown, fallback?: string) => fallback ?? "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderAssistant(onApply = jest.fn()) {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<AiSectionAssistant
|
||||
sectionName="Erfaring"
|
||||
text="Built reliable APIs"
|
||||
documentLanguage="nb-NO"
|
||||
onApply={onApply}
|
||||
/>
|
||||
</ToastProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return onApply;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem("uiLanguage"));
|
||||
|
||||
test("section AI uses the CV document language and requires explicit apply", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { original: "Built reliable APIs", result: "Utviklet driftssikre API-er" } } as never);
|
||||
const onApply = renderAssistant();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
|
||||
expect(screen.getByLabelText("Output language")).toHaveTextContent("Norsk bokmål");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/cv/ai/assist", expect.objectContaining({
|
||||
action: "improve",
|
||||
language: "nb-NO",
|
||||
text: "Built reliable APIs",
|
||||
})));
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
expect(await screen.findByDisplayValue("Utviklet driftssikre API-er")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onApply).toHaveBeenCalledWith("Utviklet driftssikre API-er");
|
||||
});
|
||||
|
||||
test("section AI controls render in Bokmål without changing the document language", () => {
|
||||
window.localStorage.setItem("uiLanguage", "nb");
|
||||
renderAssistant();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "AI-forslag" }));
|
||||
expect(screen.getByLabelText("AI-handling")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Språk for resultat")).toHaveTextContent("Norsk bokmål");
|
||||
expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
@@ -8,23 +8,30 @@ import { useAccountPlan } from "../../accountPlan";
|
||||
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
|
||||
import { useToast } from "../../toast";
|
||||
import ProFeatureNotice from "../ProFeatureNotice";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
|
||||
type Props = {
|
||||
sectionName: string;
|
||||
text: string;
|
||||
context?: string;
|
||||
documentLanguage?: string | null;
|
||||
onApply: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
|
||||
|
||||
export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) {
|
||||
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, onApply }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [action, setAction] = useState("improve");
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const normalizedLanguage = documentLanguage === "no" || documentLanguage === "nb" ? "nb-NO" : documentLanguage ?? "en";
|
||||
const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage);
|
||||
|
||||
useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]);
|
||||
|
||||
const run = async () => {
|
||||
if (!text.trim() || !canUseAi) return;
|
||||
@@ -33,11 +40,12 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
||||
const response = await cvBuilderApi.aiAssist({
|
||||
action,
|
||||
text,
|
||||
language: targetLanguage,
|
||||
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
|
||||
});
|
||||
setSuggestion(response.result);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "AI suggestion failed."), "error");
|
||||
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -46,7 +54,7 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
||||
if (!open) {
|
||||
return (
|
||||
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
|
||||
AI suggestions
|
||||
{t("cvAiSuggestions")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -54,10 +62,10 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
||||
if (!canUseAi) {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine this section with Pro.">
|
||||
AI only proposes wording; it never changes CV content without your approval.
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title={t("cvAiProTitle")}>
|
||||
{t("cvAiProBody")}
|
||||
</ProFeatureNotice>
|
||||
<Button size="small" onClick={() => setOpen(false)}>Close</Button>
|
||||
<Button size="small" onClick={() => setOpen(false)}>{t("cvAiClose")}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -66,25 +74,39 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
|
||||
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
||||
<TextField select size="small" label="AI action" value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
||||
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
|
||||
<TextField select size="small" label={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
||||
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? "Working…" : "Suggest"}</Button>
|
||||
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>Close</Button>
|
||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
</TextField>
|
||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
|
||||
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>{t("cvAiClose")}</Button>
|
||||
</Stack>
|
||||
{suggestion && <>
|
||||
<Box>
|
||||
<Typography variant="overline" color="text.secondary">Current</Typography>
|
||||
<Typography variant="overline" color="text.secondary">{t("cvAiCurrent")}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||
</Box>
|
||||
<TextField label="Suggested — edit before applying" multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
||||
<Alert severity="info" sx={{ py: 0 }}>Check every fact before applying this suggestion.</Alert>
|
||||
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
||||
<Alert severity="info" sx={{ py: 0 }}>{t("cvAiCheckFacts")}</Alert>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(`Suggestion applied to ${sectionName}.`, "success"); }}>Apply</Button>
|
||||
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(t("cvAiApplied", { section: sectionName }), "success"); }}>{t("cvAiApply")}</Button>
|
||||
<Button size="small" onClick={() => setSuggestion("")}>{t("cvAiReject")}</Button>
|
||||
</Stack>
|
||||
</>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
function actionLabel(key: string, fallback: string, t: Translate) {
|
||||
const labels: Record<string, string> = {
|
||||
improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"),
|
||||
grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"),
|
||||
};
|
||||
return labels[key] ?? fallback;
|
||||
}
|
||||
|
||||
@@ -406,6 +406,27 @@ export const translations = {
|
||||
cvDashboardChooseTemplate: "Choose a template",
|
||||
cvDashboardCreating: "Creating…",
|
||||
cvDashboardCreateCv: "Create CV",
|
||||
cvAiFailed: "AI suggestion failed.",
|
||||
cvAiSuggestions: "AI suggestions",
|
||||
cvAiProTitle: "Refine this section with Pro.",
|
||||
cvAiProBody: "AI only proposes wording; it never changes CV content without your approval.",
|
||||
cvAiClose: "Close",
|
||||
cvAiAction: "AI action",
|
||||
cvAiLanguage: "Output language",
|
||||
cvAiWorking: "Working…",
|
||||
cvAiSuggest: "Suggest",
|
||||
cvAiCurrent: "Current",
|
||||
cvAiSuggested: "Suggested — edit before applying",
|
||||
cvAiCheckFacts: "Check every fact before applying this suggestion.",
|
||||
cvAiApplied: "Suggestion applied to {section}.",
|
||||
cvAiApply: "Apply",
|
||||
cvAiReject: "Reject",
|
||||
cvAiImprove: "Improve writing",
|
||||
cvAiProfessional: "Make more professional",
|
||||
cvAiShorten: "Make shorter",
|
||||
cvAiGrammar: "Fix grammar",
|
||||
cvAiImpact: "Add measurable impact",
|
||||
cvAiBullets: "Suggest bullet points",
|
||||
richTextBold: "Bold",
|
||||
richTextBoldPlaceholder: "bold text",
|
||||
richTextItalic: "Italic",
|
||||
@@ -2011,6 +2032,27 @@ export const translations = {
|
||||
cvDashboardChooseTemplate: "Velg en mal",
|
||||
cvDashboardCreating: "Oppretter…",
|
||||
cvDashboardCreateCv: "Opprett CV",
|
||||
cvAiFailed: "AI-forslaget mislyktes.",
|
||||
cvAiSuggestions: "AI-forslag",
|
||||
cvAiProTitle: "Forbedre denne seksjonen med Pro.",
|
||||
cvAiProBody: "AI foreslår bare formuleringer og endrer aldri CV-innhold uten godkjenning fra deg.",
|
||||
cvAiClose: "Lukk",
|
||||
cvAiAction: "AI-handling",
|
||||
cvAiLanguage: "Språk for resultat",
|
||||
cvAiWorking: "Arbeider…",
|
||||
cvAiSuggest: "Foreslå",
|
||||
cvAiCurrent: "Nåværende",
|
||||
cvAiSuggested: "Forslag — rediger før du bruker det",
|
||||
cvAiCheckFacts: "Kontroller alle fakta før du bruker dette forslaget.",
|
||||
cvAiApplied: "Forslaget er brukt i {section}.",
|
||||
cvAiApply: "Bruk",
|
||||
cvAiReject: "Avvis",
|
||||
cvAiImprove: "Forbedre teksten",
|
||||
cvAiProfessional: "Gjør mer profesjonell",
|
||||
cvAiShorten: "Gjør kortere",
|
||||
cvAiGrammar: "Rett grammatikk",
|
||||
cvAiImpact: "Legg til målbar effekt",
|
||||
cvAiBullets: "Foreslå punkter",
|
||||
richTextBold: "Fet",
|
||||
richTextBoldPlaceholder: "fet tekst",
|
||||
richTextItalic: "Kursiv",
|
||||
|
||||
@@ -780,7 +780,7 @@ function SectionRow({
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||
</Stack>
|
||||
) : outlineSection?.kind === "entries" ? (
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
@@ -788,7 +788,7 @@ function SectionRow({
|
||||
<Stack spacing={1}>
|
||||
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
@@ -859,7 +859,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
||||
value={(ov.bullets ?? entry.bullets).join("\n")}
|
||||
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||
{ov.bullets && (
|
||||
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user