feat(cv-import): add source-grounded review and explicit corrections

This commit is contained in:
cesnimda
2026-10-26 15:56:54 +01:00
parent 9e1726c61d
commit e941b3ccb7
24 changed files with 943 additions and 48 deletions
@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, Button, FormControl, InputLabel, LinearProgress, NativeSelect, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../../api";
import { useI18n } from "../../i18n/I18nProvider";
import type { TranslationKey } from "../../i18n/translations";
type ReviewField = {
id: string; label: string; currentValue: string | null; extractedValue: string;
sourceSnippet: string | null; sourcePage: number | null; method: string; reviewState: string;
};
type ReviewGroup = { id: string; category: string; label: string; matchDescription: string | null; fields: ReviewField[] };
type ImportReview = {
runId: number; status: string; revision: number; fileName: string; sourceText: string;
warnings: string[]; groups: ReviewGroup[];
};
type Choice = { action: "keep" | "use" | "correct" | "add"; value?: string; confirmed: boolean };
const labelKeys: Record<string, TranslationKey> = {
contact: "careerProfileContactSection", fullName: "profileCvContactFullName", headline: "profileCvContactHeadline",
email: "profileCvContactEmail", phone: "profileCvContactPhone", location: "profileCvContactLocation",
website: "profileCvContactWebsite", linkedIn: "profileCvContactLinkedIn", summary: "profileCvStructuredSummary",
jobs: "cvSectionExperience", education: "cvSectionEducation", skills: "cvSectionSkills", languages: "cvSectionLanguages",
interests: "cvSectionInterests", projects: "cvSectionProjects", certifications: "cvSectionCertifications",
title: "cvImportFieldTitle", company: "cvImportFieldCompany", start: "cvImportFieldStart", end: "cvImportFieldEnd",
isCurrent: "cvImportFieldCurrent", bullets: "careerImportDetails", details: "careerImportDetails",
awards: "cvSectionAwards", publications: "cvSectionPublications", organisations: "cvSectionOrganisations", references: "cvSectionReferences",
qualification: "profileCvEducationQualification", institution: "profileCvEducationInstitution", level: "profileCvLanguageLevel", notes: "profileCvLanguageNotes",
name: "cvImportFieldName", issuer: "careerImportIssuer", date: "careerImportDates", role: "cvImportFieldRole", otherSections: "cvEditorCustom",
Contact: "careerProfileContactSection", "Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline",
Email: "profileCvContactEmail", Phone: "profileCvContactPhone", Location: "profileCvContactLocation",
Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Summary: "profileCvStructuredSummary",
Experience: "cvSectionExperience", Education: "cvSectionEducation", Skills: "cvSectionSkills",
Languages: "cvSectionLanguages", Interests: "cvSectionInterests", Projects: "cvSectionProjects",
Certifications: "cvSectionCertifications", Level: "profileCvLanguageLevel", Details: "careerImportDetails",
Bullets: "careerImportDetails", Title: "cvImportFieldTitle", Company: "cvImportFieldCompany",
Start: "cvImportFieldStart", End: "cvImportFieldEnd", "Is current": "cvImportFieldCurrent",
};
export default function CvImportReview({ runId, disabled, onApplyingChange, onApplied, onDiscard }: {
runId: number; disabled: boolean; onApplyingChange: (applying: boolean) => void;
onApplied: () => Promise<void>; onDiscard: () => void;
}) {
const { t } = useI18n();
const translate = useRef(t);
translate.current = t;
const [review, setReview] = useState<ImportReview | null>(null);
const [choices, setChoices] = useState<Record<string, Choice>>({});
const [loading, setLoading] = useState(true);
const [comparisonCurrent, setComparisonCurrent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stale, setStale] = useState(false);
const [applying, setApplying] = useState(false);
const applyPending = useRef(false);
const [applied, setApplied] = useState(false);
const [mobilePanel, setMobilePanel] = useState("review");
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [sourceIsPdf, setSourceIsPdf] = useState(false);
const [sourceError, setSourceError] = useState(false);
const [sourceAttempt, setSourceAttempt] = useState(0);
const generation = useRef(0);
const reviewStatus = review?.status;
const loadReview = useCallback(async () => {
const requestGeneration = ++generation.current;
setLoading(true);
setComparisonCurrent(false);
setError(null);
try {
const response = await api.get<ImportReview>(`/profile-cv/runs/${runId}/review`);
if (requestGeneration !== generation.current) return;
setReview(response.data);
setApplied(response.data.status === "applied");
setComparisonCurrent(true);
} catch (failure) {
if (requestGeneration === generation.current) setError(getApiErrorMessage(failure, translate.current("careerImportComparisonFailed")));
} finally {
if (requestGeneration === generation.current) setLoading(false);
}
}, [runId]);
useEffect(() => {
const requestState = generation;
setChoices({});
setStale(false);
void loadReview();
return () => { ++requestState.current; };
}, [loadReview]);
useEffect(() => {
if (reviewStatus !== "pending_review" || applied) return;
const controller = new AbortController();
let disposed = false;
let ownedUrl: string | null = null;
setSourceUrl(null);
setSourceError(false);
void api.get<Blob>(`/profile-cv/runs/${runId}/source`, { responseType: "blob", signal: controller.signal }).then((response) => {
if (disposed) return;
ownedUrl = URL.createObjectURL(response.data);
setSourceIsPdf(response.data.type === "application/pdf");
setSourceUrl(ownedUrl);
}).catch(() => { if (!disposed) setSourceError(true); });
return () => {
disposed = true;
controller.abort();
if (ownedUrl) URL.revokeObjectURL(ownedUrl);
};
}, [runId, sourceAttempt, reviewStatus, applied]);
const label = (value: string) => Object.hasOwn(labelKeys, value) ? t(labelKeys[value]) : value;
const warningKeys: Record<string, TranslationKey> = { pdf_native_order_no_layout_or_tables: "cvImportWarningNative", docx_page_numbers_unavailable: "cvImportWarningPages", ocr_disabled: "cvImportWarningOcr" };
const fields = review?.groups.flatMap((group) => group.fields) ?? [];
const reviewedCount = fields.filter((field) => choices[field.id]?.confirmed).length;
const allReviewed = fields.length > 0 && reviewedCount === fields.length;
async function apply() {
if (disabled || applyPending.current || !comparisonCurrent || !review || !allReviewed) return;
applyPending.current = true;
setApplying(true);
onApplyingChange(true);
setError(null);
try {
await api.post(`/profile-cv/runs/${runId}/apply-reviewed`, {
expectedVersion: review.revision,
decisions: fields.map((field) => {
const choice = choices[field.id];
return { id: field.id, action: choice.action, ...(choice.action === "correct" ? { value: choice.value ?? field.extractedValue } : {}) };
}),
});
setApplied(true);
await onApplied();
} catch (failure) {
if ((failure as { response?: { status?: number } }).response?.status === 409) {
setStale(true);
setChoices((current) => Object.fromEntries(Object.entries(current).map(([id, choice]) => [id, { ...choice, confirmed: false }])));
await loadReview();
} else setError(getApiErrorMessage(failure, translate.current("profileCvApplyChangesFailed")));
} finally {
applyPending.current = false;
setApplying(false);
onApplyingChange(false);
}
}
if (applied) return <Alert severity="success" sx={{ mt: 1.5 }}>
<Typography component="p">{t("cvImportSaved")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Button component="a" href="/career?section=profile">{t("cvImportOpenProfile")}</Button>
<Button component="a" href="/career/builder">{t("cvImportOpenEditor")}</Button>
</Box>
</Alert>;
return <Box sx={{ mt: 1.5, minWidth: 0 }} aria-busy={loading || applying}>
{loading ? <LinearProgress aria-label={t("careerImportComparisonLoading")} /> : null}
{error ? <Alert severity="error" sx={{ mb: 1 }} action={!comparisonCurrent ? <Button color="inherit" onClick={() => void loadReview()} disabled={loading}>{t("retry")}</Button> : undefined}>{error}</Alert> : null}
{stale ? <Alert severity="warning" sx={{ mb: 1 }}>{t("cvImportStaleReview")}</Alert> : null}
{!review ? null : <>
<Typography component="p" variant="body2" sx={{ mb: 1 }}>{t("cvImportReviewHelp")}</Typography>
{review.warnings.map((warning, index) => <Alert key={index} severity="warning" sx={{ mb: 1 }}>{Object.hasOwn(warningKeys, warning) ? t(warningKeys[warning]) : warning}</Alert>)}
<Tabs value={mobilePanel} onChange={(_, value: string) => setMobilePanel(value)} aria-label={t("cvImportPanels")} sx={{ display: { xs: "flex", md: "none" }, mb: 1 }}>
<Tab value="review" label={t("cvImportReviewPanel")} id={`cv-import-${runId}-review-tab`} aria-controls={`cv-import-${runId}-review-panel`} />
<Tab value="source" label={t("cvImportSourcePanel")} id={`cv-import-${runId}-source-tab`} aria-controls={`cv-import-${runId}-source-panel`} />
</Tabs>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1.2fr)" }, gap: 2, minWidth: 0 }}>
<Paper component="section" variant="outlined" id={`cv-import-${runId}-source-panel`} aria-label={t("cvImportSourcePanel")} sx={{ p: 1.5, minWidth: 0, alignSelf: "start", display: { xs: mobilePanel === "source" ? "block" : "none", md: "block" } }}>
<Typography component="h4" variant="subtitle1">{t("cvImportSourcePanel")}</Typography>
<Typography variant="body2" sx={{ overflowWrap: "anywhere", mb: 1 }}>{review.fileName}</Typography>
{sourceUrl ? <Button component="a" href={sourceUrl} download={review.fileName} size="small">{t("cvImportDownload")}</Button> : null}
{sourceError ? <Alert severity="warning" action={<Button color="inherit" onClick={() => setSourceAttempt((current) => current + 1)}>{t("retry")}</Button>}>{t("cvImportSourceFailed")}</Alert>
: !sourceUrl ? <LinearProgress aria-label={t("cvImportSourceLoading")} /> : null}
{sourceUrl && sourceIsPdf ? <Box component="iframe" src={sourceUrl} title={t("cvImportSourceTitle")} sx={{ width: "100%", height: { xs: 420, md: 680 }, border: 0, mt: 1 }} />
: <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", mt: 1 }}>{review.sourceText || t("cvImportSourceEmpty")}</Typography>}
</Paper>
<Box component="section" id={`cv-import-${runId}-review-panel`} aria-label={t("cvImportReviewPanel")} sx={{ minWidth: 0, display: { xs: mobilePanel === "review" ? "block" : "none", md: "block" } }}>
<Typography component="h4" variant="subtitle1">{t("cvImportReviewPanel")}</Typography>
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{t("cvImportReviewedCount", { reviewed: reviewedCount, total: fields.length })}</Typography>
{review.groups.map((group) => <Box component="fieldset" key={group.id} disabled={disabled || applying || loading} sx={{ m: 0, mb: 1.5, p: 1.5, minWidth: 0, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography component="legend" variant="subtitle2">{label(group.label)}</Typography>
{group.matchDescription ? <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere", mb: 1 }}>{group.matchDescription === "Matched existing record" ? t("cvImportMatchedRecord") : group.matchDescription === "New record" ? t("cvImportNewRecord") : group.matchDescription}</Typography> : null}
<Button size="small" onClick={() => setChoices((current) => ({ ...current, ...Object.fromEntries(group.fields.map((field) => [field.id, { action: field.currentValue === null ? "add" : "use", confirmed: true }])) }))}>{t("cvImportUseGroup")}</Button>
{group.fields.map((field) => {
const choice = choices[field.id];
const fieldKey = field.id.split(".").filter((part) => !/^\d+$/.test(part)).at(-1) ?? field.label;
const fieldLabel = label(Object.hasOwn(labelKeys, fieldKey) ? fieldKey : field.label);
const inputId = `cv-import-${runId}-${field.id}`;
return <Box key={field.id} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider", overflowWrap: "anywhere" }}>
<Typography component="h5" variant="subtitle2">{fieldLabel}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportCurrentValue", { value: field.currentValue || t("careerImportEmptyValue") })}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{t("careerImportIncomingValue", { value: field.extractedValue || t("careerImportEmptyValue") })}</Typography>
<Box sx={{ my: 1, pl: 1, borderLeft: "2px solid", borderColor: "divider" }}>
<Typography variant="caption" color="text.secondary">{t("cvImportEvidence")}{field.sourcePage ? ` · ${t("cvImportPage", { page: field.sourcePage })}` : ""}</Typography>
{field.sourcePage ? <Typography variant="caption" sx={{ display: "block" }}>{t("cvImportPage", { page: field.sourcePage })}</Typography> : null}
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{field.sourceSnippet || t("cvImportNoEvidence")}</Typography>
<Typography variant="caption" color="text.secondary">{t(field.method === "docling" ? "cvImportMethodDocument" : "cvImportMethodDeterministic")}</Typography>
</Box>
<FormControl variant="standard" fullWidth sx={{ mt: 1 }}>
<InputLabel htmlFor={inputId} shrink>{t("cvImportDecision", { field: fieldLabel })}</InputLabel>
<NativeSelect value={choice?.confirmed ? choice.action : ""} inputProps={{ id: inputId }} onChange={(event) => {
const action = event.target.value as Choice["action"];
setChoices((current) => ({ ...current, [field.id]: { action, value: current[field.id]?.value ?? field.extractedValue, confirmed: Boolean(action) } }));
}}>
<option value="">{t("cvImportChoose")}</option>
<option value="keep">{t("cvImportKeep")}</option>
<option value={field.currentValue === null ? "add" : "use"}>{t("cvImportUse")}</option>
<option value="correct">{t("cvImportCorrect")}</option>
</NativeSelect>
</FormControl>
{choice?.action === "correct" ? <TextField fullWidth multiline minRows={1} maxRows={10} label={t("cvImportCorrection", { field: fieldLabel })} value={choice.value ?? field.extractedValue} onChange={(event) => setChoices((current) => ({ ...current, [field.id]: { ...current[field.id], value: event.target.value } }))} sx={{ mt: 2 }} /> : null}
</Box>;
})}
</Box>)}
{fields.length === 0 ? <Alert severity="info" sx={{ mb: 1 }}>{t("cvImportNoFields")}</Alert> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" disabled={disabled || applying || loading || !comparisonCurrent || !allReviewed} onClick={() => void apply()}>{t(applying ? "careerImportApplying" : "cvImportApplyReviewed")}</Button>
<Button color="inherit" disabled={disabled || applying} onClick={onDiscard}>{t("profileCvDiscard")}</Button>
</Box>
</Box>
</Box>
</>}
</Box>;
}