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