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
+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>
);