feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user