feat(cv): localise section AI output

This commit is contained in:
cesnimda
2026-08-28 20:30:42 +02:00
parent 557121ad31
commit 245b39f6fa
4 changed files with 154 additions and 19 deletions
@@ -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 AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material"; 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 { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
import { useToast } from "../../toast"; import { useToast } from "../../toast";
import ProFeatureNotice from "../ProFeatureNotice"; import ProFeatureNotice from "../ProFeatureNotice";
import { useI18n } from "../../i18n/I18nProvider";
type Props = { type Props = {
sectionName: string; sectionName: string;
text: string; text: string;
context?: string; context?: string;
documentLanguage?: string | null;
onApply: (suggestion: string) => void; onApply: (suggestion: string) => void;
}; };
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]); 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 { toast } = useToast();
const { canUseAi } = useAccountPlan(); const { canUseAi } = useAccountPlan();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [action, setAction] = useState("improve"); const [action, setAction] = useState("improve");
const [suggestion, setSuggestion] = useState(""); const [suggestion, setSuggestion] = useState("");
const [busy, setBusy] = useState(false); 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 () => { const run = async () => {
if (!text.trim() || !canUseAi) return; if (!text.trim() || !canUseAi) return;
@@ -33,11 +40,12 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
const response = await cvBuilderApi.aiAssist({ const response = await cvBuilderApi.aiAssist({
action, action,
text, text,
language: targetLanguage,
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"), context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
}); });
setSuggestion(response.result); setSuggestion(response.result);
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "AI suggestion failed."), "error"); toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -46,7 +54,7 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
if (!open) { if (!open) {
return ( return (
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}> <Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
AI suggestions {t("cvAiSuggestions")}
</Button> </Button>
); );
} }
@@ -54,10 +62,10 @@ export default function AiSectionAssistant({ sectionName, text, context, onApply
if (!canUseAi) { if (!canUseAi) {
return ( return (
<Stack spacing={1}> <Stack spacing={1}>
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine this section with Pro."> <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvAiProTitle")}>
AI only proposes wording; it never changes CV content without your approval. {t("cvAiProBody")}
</ProFeatureNotice> </ProFeatureNotice>
<Button size="small" onClick={() => setOpen(false)}>Close</Button> <Button size="small" onClick={() => setOpen(false)}>{t("cvAiClose")}</Button>
</Stack> </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" }}> <Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
<Stack spacing={1}> <Stack spacing={1}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}> <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 }}> <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}>{item.label}</MenuItem>)} {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> </TextField>
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? "Working…" : "Suggest"}</Button> <TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>Close</Button> <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> </Stack>
{suggestion && <> {suggestion && <>
<Box> <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> <Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
</Box> </Box>
<TextField label="Suggested — edit before applying" multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} /> <TextField label={t("cvAiSuggested")} 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> <Alert severity="info" sx={{ py: 0 }}>{t("cvAiCheckFacts")}</Alert>
<Stack direction="row" spacing={1}> <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" variant="contained" onClick={() => { onApply(suggestion); setSuggestion(""); setOpen(false); toast(t("cvAiApplied", { section: sectionName }), "success"); }}>{t("cvAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button> <Button size="small" onClick={() => setSuggestion("")}>{t("cvAiReject")}</Button>
</Stack> </Stack>
</>} </>}
</Stack> </Stack>
</Paper> </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;
}
+42
View File
@@ -406,6 +406,27 @@ export const translations = {
cvDashboardChooseTemplate: "Choose a template", cvDashboardChooseTemplate: "Choose a template",
cvDashboardCreating: "Creating…", cvDashboardCreating: "Creating…",
cvDashboardCreateCv: "Create CV", 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", richTextBold: "Bold",
richTextBoldPlaceholder: "bold text", richTextBoldPlaceholder: "bold text",
richTextItalic: "Italic", richTextItalic: "Italic",
@@ -2011,6 +2032,27 @@ export const translations = {
cvDashboardChooseTemplate: "Velg en mal", cvDashboardChooseTemplate: "Velg en mal",
cvDashboardCreating: "Oppretter…", cvDashboardCreating: "Oppretter…",
cvDashboardCreateCv: "Opprett CV", 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", richTextBold: "Fet",
richTextBoldPlaceholder: "fet tekst", richTextBoldPlaceholder: "fet tekst",
richTextItalic: "Kursiv", richTextItalic: "Kursiv",
+3 -3
View File
@@ -780,7 +780,7 @@ function SectionRow({
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button> <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")} </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") })} />} 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> </Stack>
) : outlineSection?.kind === "entries" ? ( ) : outlineSection?.kind === "entries" ? (
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} /> <EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
@@ -788,7 +788,7 @@ function SectionRow({
<Stack spacing={1}> <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") })} /> <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>} {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> </Stack>
)} )}
</Box> </Box>
@@ -859,7 +859,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2} <RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
value={(ov.bullets ?? entry.bullets).join("\n")} value={(ov.bullets ?? entry.bullets).join("\n")}
onChange={(v) => setOverride(key, { bullets: v.split("\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 && ( {ov.bullets && (
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button> <Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
)} )}