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
+47 -8
View File
@@ -12,6 +12,7 @@ import CropImageDialog from "../components/CropImageDialog";
import ProfileCompleteness from "./career/ProfileCompleteness";
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
import CareerEvidenceBank from "./career/CareerEvidenceBank";
import CvImportReview from "./career/CvImportReview";
import {
EducationSection,
InterestsSection,
@@ -106,7 +107,7 @@ const importLabelKeys: Record<string, TranslationKey> = {
"waiting to retry": "careerImportWaitingRetry", "waiting for approved fallback": "careerImportWaitingFallback",
"cancellation requested": "operationsCancellationRequested", pending_review: "careerImportPendingReview", applied: "careerImportApplied",
discarded: "careerImportDiscarded", failed: "careerImportFailed", cancelled: "careerImportCancelled",
upload: "careerImportUpload", rebuild: "careerImportRebuild", improve: "careerImportImprove", reprocess: "careerImportReprocess",
upload: "careerImportUpload", document_import: "careerImportUpload", expired: "cvImportExpired", rebuild: "careerImportRebuild", improve: "careerImportImprove", reprocess: "careerImportReprocess",
};
type MeResponse = {
@@ -178,6 +179,7 @@ export default function CareerProfilePage() {
const [profileRevision, setProfileRevision] = useState<number | null>(null);
const profileReadId = useRef(0);
const [uploadingCv, setUploadingCv] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
@@ -323,19 +325,32 @@ export default function CareerProfilePage() {
}, []);
const queueCvUpload = useCallback(async (file: File) => {
if (uploadingCv || !canUseAi || me?.provider !== "local") return;
if (file.size > 5 * 1024 * 1024) {
setUploadError(t("cvImportSizeError"));
return;
}
if (!/\.(pdf|docx|txt|md|png|jpe?g|webp)$/i.test(file.name)) {
setUploadError(t("cvImportFormatError"));
return;
}
const formData = new FormData();
formData.append("file", file);
setUploadError(null);
setUploadingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
const route = /\.(pdf|docx)$/i.test(file.name) ? "/profile-cv/import" : "/profile-cv/upload";
const res = await api.post<QueuedCvRunResponse>(route, formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadExtractionRuns();
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (error) {
toast(getApiErrorMessage(error, t("profileCvUploadFailed")), "error");
const message = getApiErrorMessage(error, t("profileCvUploadFailed"));
setUploadError(message);
toast(message, "error");
} finally {
setUploadingCv(false);
}
}, [loadExtractionRuns, t, toast]);
}, [canUseAi, loadExtractionRuns, me?.provider, t, toast, uploadingCv]);
const loadProfile = useCallback(async () => {
const readId = ++profileReadId.current;
@@ -389,7 +404,7 @@ export default function CareerProfilePage() {
useEffect(() => {
for (const run of extractionRuns) {
if (run.status === "pending_review" && !runDiffs[run.id] && !runDiffLoading[run.id] && runDiffErrors[run.id] === undefined) void loadRunDiff(run.id);
if (run.parserVersion !== "docling-v1" && run.status === "pending_review" && !runDiffs[run.id] && !runDiffLoading[run.id] && runDiffErrors[run.id] === undefined) void loadRunDiff(run.id);
}
}, [extractionRuns, loadRunDiff, runDiffErrors, runDiffLoading, runDiffs]);
@@ -486,6 +501,8 @@ export default function CareerProfilePage() {
: run.status === "queued" || run.status === "running");
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
const extractionProgress = activeCvRun?.operation?.progressPercent;
const stageKeys: Record<string, TranslationKey> = { cv_validating_file: "cvImportStageValidating", cv_extracting: "cvImportStageExtracting", cv_identifying_sections: "cvImportStageSections", cv_analysing: "cvImportStageAnalysing", cv_validating_details: "cvImportStageDetails", cv_preparing_review: "cvImportStagePreparing", cv_interpreting: "cvImportStageInterpreting", cv_review_ready: "cvImportStageReady" };
const importStage = (stage?: string | null) => stage && Object.hasOwn(stageKeys, stage) ? t(stageKeys[stage]) : t("careerProfileImportProcessing");
const importLabel = (value: string) => Object.hasOwn(importLabelKeys, value) ? t(importLabelKeys[value]) : value;
const importChangeLabel = (change: CvImportChange, category: string) => category === "Professional summary" ? importLabel(category)
: change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label;
@@ -620,7 +637,7 @@ export default function CareerProfilePage() {
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.75 }}>
{typeof extractionProgress === "number"
? t("careerProfileImportPercent", { percent: extractionProgress })
: t("careerProfileImportProcessing")}
: importStage(activeCvRun?.operation?.progressStage)}
</Typography>
</Box> : null}
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
@@ -695,7 +712,12 @@ export default function CareerProfilePage() {
<Box sx={{ gridColumn: "1 / -1", minWidth: 0, display: careerOnly ? "block" : "none" }}>
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault();
if (editorDisabled || uploadingCv || activeCvRun || !canUseAi) return;
const file = event.dataTransfer.files[0];
if (file) void queueCvUpload(file);
}}>
{!canUseAi && (
<Box sx={{ mb: 2 }}>
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
@@ -772,6 +794,8 @@ export default function CareerProfilePage() {
</Box>
</Box>
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("cvImportUploadHelp")}</Typography>
{uploadError ? <Alert severity="error" sx={{ mb: 1.5 }}>{uploadError}</Alert> : null}
<Alert severity="info" sx={{ mb: 2, borderRadius: 2.5 }}>
{t("profileCvStructuredDefaultHint")}
</Alert>
@@ -819,6 +843,7 @@ export default function CareerProfilePage() {
</Box>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
{run.operation?.progressStage && activeOperationStatuses.has(run.operation.status) ? <Typography variant="body2" role="status" aria-live="polite" sx={{ mt: 1 }}>{importStage(run.operation.progressStage)}</Typography> : null}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
@@ -837,7 +862,21 @@ export default function CareerProfilePage() {
<Typography variant="body2" component="p" sx={{ fontWeight: 600 }}>{t(runActionErrors[run.id].key)}</Typography>
{runActionErrors[run.id].message ? <Typography variant="body2" component="p">{runActionErrors[run.id].message}</Typography> : null}
</Alert> : null}
{run.status === "pending_review" ? (
{run.parserVersion === "docling-v1" && (run.status === "pending_review" || run.status === "applied") ? <CvImportReview
runId={run.id}
disabled={editorDisabled || profileDirty || reviewingRunId !== null}
onApplyingChange={(applying) => {
profileMutationPending.current = applying;
setReviewingRunId(applying ? run.id : null);
setReviewAction(applying ? "accept" : null);
}}
onApplied={async () => {
setExtractionRuns((current) => current.map((item) => item.id === run.id ? { ...item, status: "applied" } : item));
invalidateImportComparisons();
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
}}
onDiscard={() => void reviewRun(run.id, "discard")}
/> : run.status === "pending_review" ? (
<Box sx={{ mt: 1.25 }}>
{runDiffs[run.id] ? (
<>