fix(cv): gate AI proposals on grounding and source freshness

This commit is contained in:
cesnimda
2026-10-08 14:28:06 +02:00
parent d8d262e4d1
commit 2398ff354e
27 changed files with 2553 additions and 157 deletions
+171 -41
View File
@@ -1,8 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, 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";
@@ -13,25 +12,46 @@ jest.mock("./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.",
getApiErrorMessage: (error: unknown, fallback?: string) => error instanceof Error ? error.message : fallback ?? "Request failed.",
}));
const mockedApi = api as jest.Mocked<typeof api>;
type AssistantProps = React.ComponentProps<typeof AiSectionAssistant>;
type Proposal = Awaited<ReturnType<AssistantProps["onGenerate"]>>;
function renderAssistant(onApply = jest.fn()) {
render(
function proposal(result: string, proposalToken = "proposal-1"): Proposal {
return { original: "Built reliable APIs", result, proposalToken };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => { resolve = complete; });
return { promise, resolve };
}
function renderAssistant(overrides: Partial<AssistantProps> = {}) {
const props: AssistantProps = {
sectionName: "Erfaring",
text: "Built reliable APIs",
targetId: "experience:job-1",
context: "Career facts",
documentLanguage: "nb-NO",
onGenerate: jest.fn().mockResolvedValue(proposal("Proposed wording")),
onAccept: jest.fn().mockResolvedValue(undefined),
...overrides,
};
const view = (nextProps: AssistantProps) => (
<I18nProvider>
<ToastProvider>
<AiSectionAssistant
sectionName="Erfaring"
text="Built reliable APIs"
documentLanguage="nb-NO"
onApply={onApply}
/>
<AiSectionAssistant {...nextProps} />
</ToastProvider>
</I18nProvider>,
</I18nProvider>
);
return onApply;
const rendered = render(view(props));
return {
onGenerate: props.onGenerate as jest.Mock,
onAccept: props.onAccept as jest.Mock,
rerenderAssistant: (updates: Partial<AssistantProps>) => rendered.rerender(view({ ...props, ...updates })),
};
}
beforeEach(() => {
@@ -41,23 +61,26 @@ beforeEach(() => {
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();
test("section AI uses the CV document language and requires explicit server acceptance", async () => {
const generated = proposal("Utviklet driftssikre API-er");
const { onGenerate, onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated) });
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({
await waitFor(() => expect(onGenerate).toHaveBeenCalledWith(expect.objectContaining({
action: "improve",
language: "nb-NO",
text: "Built reliable APIs",
targetId: "experience:job-1",
context: expect.stringContaining("Career facts"),
})));
expect(onApply).not.toHaveBeenCalled();
expect(await screen.findByDisplayValue("Utviklet driftssikre API-er")).toBeInTheDocument();
expect(onAccept).not.toHaveBeenCalled();
expect(await screen.findByDisplayValue(generated.result)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onApply).toHaveBeenCalledWith("Utviklet driftssikre API-er");
expect(onAccept).toHaveBeenCalledWith(generated, generated.result);
await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument());
});
test("section AI controls render in Bokmål without changing the document language", () => {
@@ -70,43 +93,150 @@ test("section AI controls render in Bokmål without changing the document langua
expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument();
});
test.each(["Reject", "Close"])("[PRES-C11] %s leaves the source unchanged and removes the suggestion", async (action) => {
mockedApi.post.mockResolvedValue({ data: { result: "Unaccepted draft" } } as never);
const onApply = renderAssistant();
test.each(["Reject", "Close"])("[PRES-C11] %s removes the suggestion without accepting content", async (action) => {
const { onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(proposal("Unaccepted draft")) });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByDisplayValue("Unaccepted draft")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: action }));
expect(screen.queryByDisplayValue("Unaccepted draft")).not.toBeInTheDocument();
expect(onApply).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
if (action === "Close") fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument();
});
test("[PRES-C11] applies the user's edited suggestion only after acceptance", async () => {
mockedApi.post.mockResolvedValue({ data: { result: "Proposed wording" } } as never);
const onApply = renderAssistant();
test("[PRES-C11] accepts the user's edited suggestion with the original proposal token", async () => {
const generated = proposal("Proposed wording");
const { onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated) });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
fireEvent.change(await screen.findByDisplayValue("Proposed wording"), { target: { value: "My reviewed wording" } });
expect(onApply).not.toHaveBeenCalled();
fireEvent.change(await screen.findByDisplayValue(generated.result), { target: { value: "My reviewed wording" } });
expect(onAccept).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onApply).toHaveBeenCalledTimes(1);
expect(onApply).toHaveBeenCalledWith("My reviewed wording");
expect(onAccept).toHaveBeenCalledTimes(1);
expect(onAccept).toHaveBeenCalledWith(generated, "My reviewed wording");
await waitFor(() => expect(screen.queryByDisplayValue("My reviewed wording")).not.toBeInTheDocument());
});
test("[PRES-C11] a failed suggestion can be retried without applying or saving content", async () => {
mockedApi.post.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ data: { result: "Recovered suggestion" } } as never);
const onApply = renderAssistant();
test("[PRES-C11] a failed suggestion can be retried without accepting content", async () => {
const onGenerate = jest.fn().mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(proposal("Recovered suggestion"));
const { onAccept } = renderAssistant({ onGenerate });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(await screen.findByText("offline")).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: "Suggest" })).toBeEnabled());
expect(onApply).not.toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByDisplayValue("Recovered suggestion")).toBeInTheDocument();
expect(onApply).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(onAccept).not.toHaveBeenCalled();
});
test("closing and reopening ignores an old request even after a newer suggestion completes", async () => {
const oldRequest = deferred<Proposal>();
const newRequest = deferred<Proposal>();
const freshProposal = proposal("Fresh suggestion", "proposal-2");
const onGenerate = jest.fn().mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise);
const { onAccept } = renderAssistant({ onGenerate });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(onGenerate).toHaveBeenCalledTimes(2);
await act(async () => { newRequest.resolve(freshProposal); });
expect(screen.getByDisplayValue(freshProposal.result)).toBeInTheDocument();
await act(async () => { oldRequest.resolve(proposal("Late stale suggestion")); });
expect(screen.queryByDisplayValue("Late stale suggestion")).not.toBeInTheDocument();
expect(screen.getByDisplayValue(freshProposal.result)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onAccept).toHaveBeenCalledWith(freshProposal, freshProposal.result);
await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument());
});
test.each([
["text", { text: "Changed career facts" }],
["target", { targetId: "experience:job-2" }],
["document language", { documentLanguage: "en" }],
] as const)("a changed %s invalidates a pending suggestion", async (_source, updates) => {
const request = deferred<Proposal>();
const { onAccept, rerenderAssistant } = renderAssistant({ onGenerate: jest.fn().mockReturnValue(request.promise) });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
rerenderAssistant(updates);
await act(async () => { request.resolve(proposal("Stale source suggestion")); });
expect(screen.queryByDisplayValue("Stale source suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument();
expect(onAccept).not.toHaveBeenCalled();
});
test("source changes remove an existing review before it can be accepted", async () => {
const { onAccept, rerenderAssistant } = renderAssistant();
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByDisplayValue("Proposed wording")).toBeInTheDocument();
rerenderAssistant({ text: "Changed career facts" });
expect(screen.queryByDisplayValue("Proposed wording")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument();
expect(onAccept).not.toHaveBeenCalled();
});
test("regeneration replaces the proposal and its token before acceptance", async () => {
const first = proposal("First suggestion");
const regenerated = proposal("Regenerated suggestion", "proposal-2");
const onGenerate = jest.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(regenerated);
const { onAccept } = renderAssistant({ onGenerate });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByDisplayValue(first.result)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(await screen.findByDisplayValue(regenerated.result)).toBeInTheDocument();
expect(screen.queryByDisplayValue(first.result)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onAccept).toHaveBeenCalledWith(regenerated, regenerated.result);
await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument());
});
test("Reject invalidates a pending regeneration and clears the previous review", async () => {
const regenerated = deferred<Proposal>();
const onGenerate = jest.fn().mockResolvedValueOnce(proposal("First suggestion")).mockReturnValueOnce(regenerated.promise);
const { onAccept } = renderAssistant({ onGenerate });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
const suggestion = await screen.findByDisplayValue("First suggestion");
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
expect(suggestion).toBeDisabled();
expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await act(async () => { regenerated.resolve(proposal("Rejected late suggestion", "proposal-2")); });
expect(screen.queryByDisplayValue("First suggestion")).not.toBeInTheDocument();
expect(screen.queryByDisplayValue("Rejected late suggestion")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument();
expect(onAccept).not.toHaveBeenCalled();
});
test("failed server acceptance keeps the edited review and allows retry without duplicate submits", async () => {
const accepting = deferred<void>();
const generated = proposal("Proposed wording");
const onAccept = jest.fn().mockRejectedValueOnce(new Error("Source changed")).mockReturnValueOnce(accepting.promise);
renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated), onAccept });
fireEvent.click(screen.getByRole("button", { name: "AI suggestions" }));
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
fireEvent.change(await screen.findByDisplayValue(generated.result), { target: { value: "My reviewed wording" } });
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(await screen.findByText("Source changed")).toBeInTheDocument();
expect(screen.getByDisplayValue("My reviewed wording")).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Apply" });
await waitFor(() => expect(retry).toBeEnabled());
fireEvent.click(retry);
expect(retry).toBeDisabled();
expect(screen.getByDisplayValue("My reviewed wording")).toBeDisabled();
fireEvent.click(retry);
expect(onAccept).toHaveBeenCalledTimes(2);
expect(onAccept).toHaveBeenNthCalledWith(1, generated, "My reviewed wording");
expect(onAccept).toHaveBeenNthCalledWith(2, generated, "My reviewed wording");
await act(async () => { accepting.resolve(undefined); });
expect(screen.queryByDisplayValue("My reviewed wording")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "AI suggestions" })).toBeInTheDocument();
});
@@ -1,53 +1,94 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
import { getApiErrorMessage } from "../../api";
import { useAccountPlan } from "../../accountPlan";
import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder";
import { AI_ACTIONS, CvAiCallbacks, CvAiProposal, normalizeCvLanguage } from "../../cvBuilder";
import { useToast } from "../../toast";
import ProFeatureNotice from "../ProFeatureNotice";
import { useI18n } from "../../i18n/I18nProvider";
type Props = {
type Props = CvAiCallbacks & {
sectionName: string;
text: string;
context?: string;
documentLanguage?: string | null;
onApply: (suggestion: string) => void;
targetId: string;
};
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, onApply }: Props) {
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, targetId, onGenerate, onAccept }: 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 [proposal, setProposal] = useState<CvAiProposal | null>(null);
const [busy, setBusy] = useState(false);
const normalizedLanguage = documentLanguage === "no" || documentLanguage === "nb" ? "nb-NO" : documentLanguage ?? "en";
const [applying, setApplying] = useState(false);
const requestId = useRef(0);
const normalizedLanguage = normalizeCvLanguage(documentLanguage);
const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage);
useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]);
useEffect(() => {
requestId.current += 1;
setProposal(null);
setSuggestion("");
setBusy(false);
setApplying(false);
return () => { requestId.current += 1; };
}, [text, targetId, normalizedLanguage, canUseAi]);
const discard = () => {
requestId.current += 1;
setProposal(null);
setSuggestion("");
setBusy(false);
setApplying(false);
};
const run = async () => {
if (!text.trim() || !canUseAi) return;
if (!text.trim() || !canUseAi || busy) return;
const request = ++requestId.current;
setBusy(true);
try {
const response = await cvBuilderApi.aiAssist({
const response = await onGenerate({
action,
text,
targetId,
language: targetLanguage,
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
});
if (request !== requestId.current) return;
setProposal(response);
setSuggestion(response.result);
} catch (error) {
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
setBusy(false);
if (request === requestId.current) { setBusy(false); setApplying(false); }
}
};
const accept = async () => {
if (!proposal || busy) return;
const request = ++requestId.current;
setBusy(true);
setApplying(true);
try {
await onAccept(proposal, suggestion);
if (request !== requestId.current) return;
discard();
setOpen(false);
toast(t("cvAiApplied", { section: sectionName }), "success");
} catch (error) {
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
if (request === requestId.current) { setBusy(false); setApplying(false); }
}
};
@@ -74,26 +115,26 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
<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={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
<TextField select size="small" disabled={busy} 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>
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
<TextField select size="small" disabled={busy} label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</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>
<Button size="small" disabled={applying} onClick={() => { discard(); setOpen(false); }}>{t("cvAiClose")}</Button>
</Stack>
{suggestion && <>
<Box>
<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" }}>{proposal?.original ?? text}</Typography>
</Box>
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" disabled={busy} 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(t("cvAiApplied", { section: sectionName }), "success"); }}>{t("cvAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("cvAiReject")}</Button>
<Button size="small" variant="contained" disabled={busy || !proposal || !suggestion.trim()} onClick={() => void accept()}>{t("cvAiApply")}</Button>
<Button size="small" disabled={applying} onClick={discard}>{t("cvAiReject")}</Button>
</Stack>
</>}
</Stack>
@@ -755,15 +755,21 @@ test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing edito
async function reviewDocumentSuggestion(result: string) {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { result } : { html: '<p>Preview</p>' } } as never));
? { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result, proposalToken: 'bound-proposal' }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 5, settings: { ...variant.settings, headline: 'Reviewed headline' } }
: { html: '<p>Preview</p>' } } as never));
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object) } } as never));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({
action: 'document-translate', text: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]),
action: 'document-translate', variantId: 3, expectedVersion: 4,
})));
const request = mockedApi.post.mock.calls.find(([url]) => url === '/cv/ai/assist')![1] as any;
expect(request).not.toHaveProperty('text');
expect(request).not.toHaveProperty('targetId');
}
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
@@ -773,15 +779,13 @@ test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes
expect(screen.getByText('Reviewed headline')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: action }));
expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(await screen.findByLabelText('Headline override')).toHaveValue(action === 'Apply' ? 'Reviewed headline' : '');
if (action === 'Apply') {
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({ headline: 'Reviewed headline' }),
})));
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
} else {
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
@@ -800,3 +804,124 @@ test.each([
expect(await screen.findByLabelText('Headline override')).toHaveValue('');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('bound document generation saves the pending draft, reviews server Original, and accepted AI remains undoable', async () => {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Outline headline', sections: [] });
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { original: JSON.stringify([{ id: 'headline', items: ['Server source headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Accepted headline'] }]), proposalToken: 'saved-source-token' }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 6, settings: { ...variant.settings, headline: 'Accepted headline' } }
: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before generation' } });
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
expect(await screen.findByText('Server source headline')).toBeInTheDocument();
expect(screen.queryByText('Outline headline')).not.toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 5 }));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument());
expect(mockedApi.put).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Accepted headline');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ expectedVersion: 6, settings: expect.objectContaining({ headline: 'Draft before generation' }) })));
await screen.findByText('Saved');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
});
test('a newer draft is flushed before stale proposal acceptance fails without rebasing or changing content', async () => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
? Promise.reject({ response: { status: 409, data: { code: 'cv_ai_source_changed' } } })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Newer retained name' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(await screen.findByText('AI suggestion failed.')).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({ name: 'Newer retained name', expectedVersion: 4 }));
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(1);
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
});
test('late document proposals cannot return after tab unmount or a source undo', async () => {
let resolveProposal: (response: any) => void = () => {};
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => url === '/cv/ai/assist'
? new Promise(resolve => { resolveProposal = resolve; })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Generation source' } });
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.any(Object)));
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Generation source'] }]), result: JSON.stringify([{ id: 'headline', items: ['Late proposal'] }]), proposalToken: 'late-token' } }));
expect(screen.queryByText('Late proposal')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(2));
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Unmounted proposal'] }]), proposalToken: 'unmounted-token' } }));
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
expect(screen.queryByText('Unmounted proposal')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
test.each([
{ label: 'Skills', targetId: 'section:skills', outline: { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['Original item'], bullets: [], entries: [] }] }, customSections: [] },
{ label: 'Custom evidence', targetId: 'custom:sample', outline: { sections: [] }, customSections: [{ key: 'sample', title: 'Custom evidence', contentType: 'paragraphs', items: ['Original item'] }] },
{ label: 'Experience', targetId: 'entry:experience:job-1', outline: { sections: [{ key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Original role', subtitle: 'Original employer', bullets: ['Original item'], tags: [] }] }] }, customSections: [] },
])('bound $targetId assistant sends explicit scope and edited wording to server acceptance', async fixture => {
const originalSettings = { ...variant.settings, customSections: fixture.customSections };
routeGet(() => Promise.resolve({ data: { ...variant, settings: originalSettings } }), fixture.outline);
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { original: 'Original item', result: 'Suggested item', proposalToken: `token:${fixture.targetId}` }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 5, settings: { ...originalSettings, headline: 'Server accepted readback' } }
: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: `Expand ${fixture.label}` }));
fireEvent.click(await screen.findByRole('button', { name: 'AI suggestions' }));
fireEvent.click(screen.getByRole('button', { name: 'Suggest' }));
const suggestion = await screen.findByLabelText('Suggested — edit before applying');
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 4, targetId: fixture.targetId, text: 'Original item' }));
fireEvent.change(suggestion, { target: { value: 'Edited approved item' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(screen.getByLabelText('Headline override')).toHaveValue('Server accepted readback'));
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: `token:${fixture.targetId}`, result: 'Edited approved item' });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
});
test('user edits made during server acceptance are retained instead of overwritten by its late saved response', async () => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
let resolveApply: (response: any) => void = () => {};
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
? new Promise(resolve => { resolveApply = resolve; })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', expect.any(Object)));
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Retained during acceptance' } });
await act(async () => resolveApply({ data: { ...variant, version: 5, settings: { ...variant.settings, headline: 'Late accepted headline' } } }));
expect(await screen.findByText('CV changed elsewhere')).toBeInTheDocument();
expect(screen.getByDisplayValue('Retained during acceptance')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
expect(mockedApi.put).not.toHaveBeenCalled();
});
+10 -2
View File
@@ -105,6 +105,12 @@ export type CvVariant = {
export type CvVariantVersionInfo = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
export type CvRender = { themeId: string; html: string; suggestedFileName: string };
export type CvAiProposal = { original: string; result: string; proposalToken: string };
export type CvAiInput = { action: string; text?: string; targetId?: string; role?: string; language?: string; context?: string };
export type CvAiCallbacks = {
onGenerate: (input: CvAiInput) => Promise<CvAiProposal>;
onAccept: (proposal: CvAiProposal, result?: string) => Promise<void>;
};
export const AI_ACTIONS: { key: string; label: string }[] = [
{ key: "improve", label: "Improve writing" },
@@ -277,7 +283,9 @@ export const cvBuilderApi = {
versions: (id: number) => api.get<CvVariantVersionInfo[]>(`/cv/variants/${id}/versions`).then((r) => r.data),
restore: (id: number, version: number, expectedVersion: number) => api.post<CvVariant>(`/cv/variants/${id}/versions/${version}/restore`, { expectedVersion }).then((r) => r.data),
previewSettings: (settings: CvVariantSettings) => api.post<CvRender>("/cv/preview", { settings }).then((r) => r.data),
aiAssist: (body: { action: string; text: string; role?: string; language?: string; context?: string }) =>
api.post<{ original: string; result: string }>("/cv/ai/assist", body).then((r) => r.data),
aiAssist: (body: CvAiInput & { variantId: number; expectedVersion: number }) =>
api.post<CvAiProposal>("/cv/ai/assist", body).then((r) => r.data),
applyAi: (id: number, body: { proposalToken: string; result?: string }) =>
api.post<CvVariant>(`/cv/variants/${id}/ai/apply`, body).then((r) => r.data),
exportPdfUrl: (id: number) => `/cv/variants/${id}/export-pdf`,
};
+134 -51
View File
@@ -34,7 +34,7 @@ import CvCustomizeTab from "../components/cv/CvCustomizeTab";
import ProFeatureNotice from "../components/ProFeatureNotice";
import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
@@ -81,6 +81,7 @@ export default function CvBuilderEditor() {
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveRevision = useRef(0);
const persistedRevision = useRef(0);
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
const serverVersion = useRef(0);
const editorEpoch = useRef(0);
@@ -119,6 +120,7 @@ export default function CvBuilderEditor() {
})();
return () => {
alive = false;
editorEpoch.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variantId]);
@@ -126,6 +128,7 @@ export default function CvBuilderEditor() {
const applyVariant = (variant: CvVariant) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveRevision.current += 1;
persistedRevision.current = saveRevision.current;
editorEpoch.current += 1;
serverVersion.current = variant.version;
revisionConflict.current = false;
@@ -175,6 +178,7 @@ export default function CvBuilderEditor() {
const saved = await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave", expectedVersion: serverVersion.current });
if (epoch !== editorEpoch.current) return false;
serverVersion.current = saved.version;
persistedRevision.current = revision;
if (saveRevision.current === revision) setSaveState("saved");
return true;
} catch (error) {
@@ -201,6 +205,7 @@ export default function CvBuilderEditor() {
if (saveTimer.current) clearTimeout(saveTimer.current);
if (!nextName.trim() || revisionConflict.current) return;
saveTimer.current = setTimeout(async () => {
saveTimer.current = null;
await performSave(next, nextName, revision);
}, 800);
},
@@ -214,6 +219,70 @@ export default function CvBuilderEditor() {
return performSave(latestSettings.current, latestName.current, revision);
}, [performSave]);
const flushAiSource = async () => {
const epoch = editorEpoch.current;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = null;
await saveQueue.current;
if (epoch !== editorEpoch.current || revisionConflict.current || !latestSettings.current || !latestName.current.trim()) {
throw new Error(t("cvAiFailed"));
}
const revision = saveRevision.current;
if (persistedRevision.current !== revision && !(await performSave(latestSettings.current, latestName.current, revision))) {
throw new Error(t("cvAiFailed"));
}
if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed"));
return { epoch, revision };
};
const generateAi = async (input: CvAiInput) => {
const { epoch, revision } = await flushAiSource();
const proposal = await cvBuilderApi.aiAssist({ ...input, variantId, expectedVersion: serverVersion.current });
if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed"));
return proposal;
};
const acceptAi = async (proposal: CvAiProposal, result?: string) => {
const { epoch, revision } = await flushAiSource();
const previous = latestSettings.current!;
const apply = async () => {
if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed"));
try {
const saved = await cvBuilderApi.applyAi(variantId, { proposalToken: proposal.proposalToken, ...(result !== undefined ? { result } : {}) });
if (epoch !== editorEpoch.current) throw new Error(t("cvAiFailed"));
serverVersion.current = saved.version;
if (revision !== saveRevision.current) {
revisionConflict.current = true;
setSaveState("conflict");
throw new Error(t("cvAiFailed"));
}
undoStack.current = [...undoStack.current.slice(-59), previous];
redoStack.current = [];
saveRevision.current += 1;
persistedRevision.current = saveRevision.current;
latestSettings.current = saved.settings;
latestName.current = saved.name;
setSettings(saved.settings);
setName(saved.name);
setIsPublic(saved.isPublic);
setPublicSlug(saved.publicSlug);
setJobApplicationId(saved.jobApplicationId ?? null);
setSaveState("saved");
setHistoryRevision((value) => value + 1);
} catch (error) {
const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code;
if (epoch === editorEpoch.current && (code === "cv_revision_conflict" || code === "cv_revision_required")) {
revisionConflict.current = true;
setSaveState("conflict");
}
throw error;
}
};
const queued = saveQueue.current.then(apply, apply);
saveQueue.current = queued.then(() => true, () => false);
await queued;
};
const update = (patch: Partial<CvVariantSettings>) => {
setSettings((prev) => {
if (!prev) return prev;
@@ -514,7 +583,7 @@ export default function CvBuilderEditor() {
</Tabs>
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
<ContentTab settings={settings} update={update} outline={outline} showPreviewEntry={!compactEditor} onFocusPreview={() => {
<ContentTab settings={settings} update={update} outline={outline} onGenerate={generateAi} onAccept={acceptAi} showPreviewEntry={!compactEditor} onFocusPreview={() => {
scrollRef.current?.focus({ preventScroll: true });
scrollRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
}} />
@@ -523,7 +592,7 @@ export default function CvBuilderEditor() {
{tab === value && <>
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
{value === 3 && <DocumentAiTab settings={settings} outline={outline} onGenerate={generateAi} onAccept={acceptAi} />}
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</>}
</Box>)}
@@ -660,7 +729,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
}
}
for (const section of settings.customSections ?? []) {
if (!section.hidden) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
}
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
}
@@ -688,36 +757,9 @@ function parseDocumentAiBlocks(value: string, originals: DocumentAiBlock[]): Doc
}
}
function applyDocumentAiBlocks(settings: CvVariantSettings, blocks: DocumentAiBlock[]): CvVariantSettings {
const byId = new Map(blocks.map((block) => [block.id, block]));
const headline = byId.get("headline")?.items?.[0];
const sections = settings.sections.map((section) => {
const block = byId.get(`section:${section.key}`);
return block ? { ...section, title: block.title, items: block.items } : section;
});
const configuredKeys = new Set(sections.map((section) => section.key));
for (const block of blocks) {
if (!block.id.startsWith("section:")) continue;
const key = block.id.slice("section:".length);
if (!configuredKeys.has(key)) sections.push({ key, title: block.title, items: block.items });
}
const overrides = { ...settings.overrides };
for (const block of blocks) {
if (!block.id.startsWith("entry:")) continue;
const key = block.id.split(":").slice(2).join(":");
overrides[key] = { ...overrides[key], title: block.title, subtitle: block.subtitle, bullets: block.items };
}
const customSections = settings.customSections.map((section) => {
const block = byId.get(`custom:${section.key}`);
return block ? { ...section, title: block.title, items: block.items ?? [] } : section;
});
return { ...settings, ...(headline !== undefined ? { headline } : {}), sections, overrides, customSections };
}
function DocumentAiTab({ settings, outline, update }: {
function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallbacks & {
settings: CvVariantSettings;
outline: CvOutline | null;
update: (patch: Partial<CvVariantSettings>) => void;
}) {
const { t } = useI18n();
const { toast } = useToast();
@@ -726,27 +768,66 @@ function DocumentAiTab({ settings, outline, update }: {
const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language));
const [busy, setBusy] = useState<string | null>(null);
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
const [originals, setOriginals] = useState<DocumentAiBlock[]>([]);
const [proposal, setProposal] = useState<CvAiProposal | null>(null);
const requestId = useRef(0);
const sourceKey = JSON.stringify(blocks);
useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]);
useEffect(() => {
requestId.current += 1;
setSuggestion(null);
setProposal(null);
setBusy(null);
return () => { requestId.current += 1; };
}, [sourceKey, settings.language, canUseAi]);
const discard = () => {
requestId.current += 1;
setSuggestion(null);
setProposal(null);
setBusy(null);
};
const run = async (action: string) => {
if (!blocks.length) return;
if (!blocks.length || !canUseAi || busy) return;
const request = ++requestId.current;
setBusy(action);
setSuggestion(null);
try {
const response = await cvBuilderApi.aiAssist({
const response = await onGenerate({
action,
text: JSON.stringify(blocks),
language: targetLanguage,
context: "This is a complete CV represented as JSON blocks. Return only a JSON array with every original id exactly once and the same title, subtitle and items fields. Preserve all facts, employers, dates, qualifications and technologies. Never add unsupported claims.",
});
const parsed = parseDocumentAiBlocks(response.result, blocks);
if (request !== requestId.current) return;
const source: DocumentAiBlock[] = JSON.parse(response.original);
if (!Array.isArray(source) || !source.length || source.some((block) => !block || typeof block.id !== "string") || new Set(source.map((block) => block.id)).size !== source.length) throw new Error(t("cvDocumentAiInvalid"));
const parsed = parseDocumentAiBlocks(response.result, source);
if (!parsed) throw new Error(t("cvDocumentAiInvalid"));
setOriginals(source);
setProposal(response);
setSuggestion(parsed);
} catch (error) {
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
setBusy(null);
if (request === requestId.current) setBusy(null);
}
};
const accept = async () => {
if (!proposal || busy) return;
const request = ++requestId.current;
setBusy("apply");
try {
await onAccept(proposal);
if (request !== requestId.current) return;
discard();
toast(t("cvDocumentAiApplied"), "success");
} catch (error) {
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
} finally {
if (request === requestId.current) setBusy(null);
}
};
@@ -755,7 +836,7 @@ function DocumentAiTab({ settings, outline, update }: {
{ key: "document-improve", title: t("cvDocumentAiImprove"), description: t("cvDocumentAiImproveHelp"), icon: <AutoFixHighIcon /> },
{ key: "document-grammar", title: t("cvDocumentAiGrammar"), description: t("cvDocumentAiGrammarHelp"), icon: <SpellcheckIcon /> },
];
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(blocks.find((original) => original.id === block.id))) ?? [];
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(originals.find((original) => original.id === block.id))) ?? [];
return (
<Stack spacing={2}>
@@ -764,7 +845,7 @@ function DocumentAiTab({ settings, outline, update }: {
<Typography variant="body2" color="text.secondary">{t("cvDocumentAiHelp")}</Typography>
</Box>
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
<TextField select size="small" disabled={!!busy} label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</TextField>
@@ -791,15 +872,15 @@ function DocumentAiTab({ settings, outline, update }: {
{changed.length === 0 ? <Alert severity="info">{t("cvDocumentAiNoChanges")}</Alert> : (
<Stack spacing={1} sx={{ maxHeight: 360, overflowY: "auto" }}>
{changed.map((block) => {
const original = blocks.find((item) => item.id === block.id);
const original = originals.find((item) => item.id === block.id);
return <Paper key={block.id} variant="outlined" sx={{ p: 1 }}><Typography variant="caption" sx={{ fontWeight: 800 }}>{block.title ?? original?.title ?? block.id}</Typography><Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1, mt: 0.5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{[original?.subtitle, ...(original?.items ?? [])].filter(Boolean).join("\n")}</Typography><Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{[block.subtitle, ...(block.items ?? [])].filter(Boolean).join("\n")}</Typography></Box></Paper>;
})}
</Stack>
)}
<Alert severity="info" sx={{ mt: 1.5 }}>{t("cvAiCheckFacts")}</Alert>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button variant="contained" disabled={!changed.length} onClick={() => { const next = applyDocumentAiBlocks(settings, suggestion); update(next); setSuggestion(null); toast(t("cvDocumentAiApplied"), "success"); }}>{t("cvAiApply")}</Button>
<Button onClick={() => setSuggestion(null)}>{t("cvAiReject")}</Button>
<Button variant="contained" disabled={!!busy || !changed.length} onClick={() => void accept()}>{t("cvAiApply")}</Button>
<Button disabled={busy === "apply"} onClick={discard}>{t("cvAiReject")}</Button>
</Stack>
</Paper>
)}
@@ -829,7 +910,7 @@ function presetLabel(key: string, fallback: string, t: Translate) {
return labels[key] ?? fallback;
}
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview }: {
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview, onGenerate, onAccept }: CvAiCallbacks & {
settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void;
outline: CvOutline | null;
@@ -982,6 +1063,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
if (custom) void removeCustom(custom);
}}
onUpdateSettings={update}
onGenerate={onGenerate}
onAccept={onAccept}
/>
))}
</Stack>
@@ -1027,8 +1110,8 @@ function AddContentCard({ title, description, added, onAdd }: { title: string; d
}
function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
}: {
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings, onGenerate, onAccept,
}: CvAiCallbacks & {
row: CvSectionSetting;
index: number;
total: number;
@@ -1136,10 +1219,10 @@ function SectionRow({
<Button ref={addEntryButton} size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => { onUpdateCustom({ items: [...customSection.items, ""] }); focusCustomEntry(customSection.items.length); }}>{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} 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") })} />
<AiSectionAssistant sectionName={sectionLabel} targetId={`custom:${customSection.key}`} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onGenerate={onGenerate} onAccept={onAccept} />
</Stack>
) : 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} onGenerate={onGenerate} onAccept={onAccept} />
) : (
<Stack spacing={1}>
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} helperText={t(row.items != null ? "cvEditorCustomized" : "cvEditorUsingSource")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
@@ -1150,7 +1233,7 @@ function SectionRow({
onPatch({ items: undefined }); requestAnimationFrame(() => field?.focus());
}}>{t("cvEditorResetMasterProfile")}</Button>
</Stack>}
<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") })} />
<AiSectionAssistant sectionName={sectionLabel} targetId={`section:${row.key}`} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onGenerate={onGenerate} onAccept={onAccept} />
</Stack>
)}
</Box>
@@ -1159,7 +1242,7 @@ function SectionRow({
);
}
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings, onGenerate, onAccept }: CvAiCallbacks & {
section: CvOutlineSection;
row: CvSectionSetting;
settings: CvVariantSettings;
@@ -1244,7 +1327,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
</Box>
{ov.bullets != null && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resetField(key, "bullets")}>{t("cvEditorResetField", { field: t("cvEditorBulletsHelp") })}</Button>}
{(ov.title != null || ov.subtitle != null || ov.bullets != null) && <Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>}
<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") })} />
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} targetId={`entry:${section.key}:${key}`} 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.`} onGenerate={onGenerate} onAccept={onAccept} />
</Stack>
</Box>
);