fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user