feat(cv-qa): map missing records from reviewed source evidence

This commit is contained in:
cesnimda
2026-10-26 19:43:30 +01:00
parent 249d06848a
commit 5e68848cf7
13 changed files with 778 additions and 14 deletions
@@ -0,0 +1,99 @@
import React, { useState } from "react";
import { Alert, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, NativeSelect, TextField, Typography } from "@mui/material";
import { useI18n } from "../../i18n/I18nProvider";
export type MappingCategory = { category: string; fields: { name: string; multiple: boolean; currentValue?: string | null }[] };
export type MappingBlock = { id: string; text: string; kind: string; page: number | null };
export type MappingTarget = { category: string; index: number; label: string; values?: Record<string, string | null> };
export type MappedField = { name: string; value: string; sourceBlockId: string; corrected: boolean };
export type MappingDraft = { clientId: string; category: string; targetIndex: number | null; fields: MappedField[]; confirmed: boolean };
const primary: Record<string, string[]> = { jobs: ["title", "company"], education: ["qualification", "institution"], certifications: ["name"], projects: ["name"], contact: ["fullName"], languages: ["name"], otherSections: ["title", "items"] };
const clean = (value: string) => value.replace(/\s+/g, " ").trim().toLocaleLowerCase();
const supported = (source: string, value: string) => new RegExp(`(?<![\\p{L}\\p{N}\\p{M}_])${clean(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}\\p{M}_])`, "iu").test(clean(source));
const empty = (name: string): MappedField => ({ name, value: "", sourceBlockId: "", corrected: false });
export default function CvImportMappingEditor({ categories, blocks, targets, drafts, onChange, disabled, label }: {
categories: MappingCategory[]; blocks: MappingBlock[]; targets: MappingTarget[]; drafts: MappingDraft[];
onChange: (drafts: MappingDraft[]) => void; disabled: boolean; label: (value: string) => string;
}) {
const { t } = useI18n();
const [errors, setErrors] = useState<Record<string, boolean>>({});
function update(index: number, value: Partial<MappingDraft>) {
onChange(drafts.map((draft, i) => i === index ? { ...draft, ...value, confirmed: false } : draft));
}
function defaultFields(category: MappingCategory) {
return (primary[category.category] ?? [category.fields[0]?.name ?? "value"]).filter(name => category.fields.some(field => field.name === name)).map(empty);
}
function approve(index: number) {
const draft = drafts[index];
const catalog = categories.find(category => category.category === draft.category);
const valid = draft.fields.length > 0 && draft.fields.every(field => {
const block = blocks.find(source => source.id === field.sourceBlockId);
return catalog?.fields.some(item => item.name === field.name) && field.value.trim().length > 0 && field.value.length <= 8000 && block && (field.corrected || supported(block.text, field.value));
});
setErrors(current => ({ ...current, [draft.clientId]: !valid }));
if (valid) onChange(drafts.map((item, i) => i === index ? { ...item, confirmed: true } : item));
}
if (categories.length === 0) return null;
return <Box sx={{ my: 2, minWidth: 0 }}>
<Typography variant="body2" sx={{ mb: 1 }}>{t("cvImportMappingNotice")}</Typography>
{drafts.map((draft, index) => {
const catalog = categories.find(category => category.category === draft.category);
const id = `cv-mapping-${draft.clientId}`;
return <Box component="fieldset" disabled={disabled} key={draft.clientId} sx={{ m: 0, mb: 2, p: 1.5, minWidth: 0, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography component="legend" variant="subtitle2">{t("cvImportMappingRecord", { number: index + 1 })}</Typography>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-category`} shrink>{t("cvImportMappingCategory")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-category` }} value={draft.category} onChange={event => {
const category = categories.find(item => item.category === event.target.value)!;
update(index, { category: category.category, targetIndex: null, fields: defaultFields(category) });
}}>{categories.map(category => <option key={category.category} value={category.category}>{label(category.category)}</option>)}</NativeSelect>
</FormControl>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-target`} shrink>{t("cvImportMappingTarget")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-target` }} value={draft.targetIndex ?? ""} onChange={event => update(index, { targetIndex: event.target.value === "" ? null : Number(event.target.value) })}>
<option value="">{t(draft.category === "contact" ? "cvImportMappingContactDestination" : catalog?.fields.every(field => field.name === "value") ? "cvImportMappingAppend" : "cvImportMappingNew")}</option>
{targets.filter(target => target.category === draft.category).map(target => <option key={target.index} value={target.index}>{target.label}</option>)}
</NativeSelect>
</FormControl>
{draft.fields.map((field, fieldIndex) => {
const number = fieldIndex + 1;
const currentValue = draft.targetIndex === null ? catalog?.fields.find(item => item.name === field.name)?.currentValue : targets.find(target => target.category === draft.category && target.index === draft.targetIndex)?.values?.[field.name];
function change(value: Partial<MappedField>) { update(index, { fields: draft.fields.map((item, i) => i === fieldIndex ? { ...item, ...value } : item) }); }
return <Box key={fieldIndex} sx={{ mb: 2, pt: 1, borderTop: "1px solid", borderColor: "divider" }}>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-field`} shrink>{t("cvImportMappingField", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-field` }} value={field.name} onChange={event => change({ name: event.target.value })}>
{catalog?.fields.map(item => <option key={item.name} value={item.name}>{label(item.name)}</option>)}
</NativeSelect>
</FormControl>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", mb: 1 }}>{t("careerImportCurrentValue", { value: currentValue || t("careerImportEmptyValue") })}</Typography>
<TextField label={t("cvImportMappingValue", { number })} value={field.value} onChange={event => change({ value: event.target.value })} multiline fullWidth minRows={1} maxRows={8} inputProps={{ maxLength: 8000 }} />
<FormControl variant="standard" fullWidth sx={{ mt: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-source`} shrink>{t("cvImportMappingSource", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-source` }} value={field.sourceBlockId} onChange={event => change({ sourceBlockId: event.target.value, value: field.value || blocks.find(block => block.id === event.target.value)?.text || "" })}>
<option value="">{t("cvImportMappingSelect")}</option>
{blocks.map(block => <option key={block.id} value={block.id}>{block.page ? `${t("cvImportPage", { page: block.page })}: ` : ""}{block.text.slice(0, 160)}</option>)}
</NativeSelect>
</FormControl>
{field.sourceBlockId ? <Typography variant="body2" sx={{ mt: 1, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{blocks.find(block => block.id === field.sourceBlockId)?.text}</Typography> : null}
<FormControlLabel control={<Checkbox checked={field.corrected} onChange={event => change({ corrected: event.target.checked })} />} label={t("cvImportMappingCorrection")} />
<Button size="small" color="inherit" onClick={() => update(index, { fields: draft.fields.filter((_, i) => i !== fieldIndex) })}>{t("cvImportMappingRemoveField", { number })}</Button>
</Box>;
})}
{errors[draft.clientId] ? <Alert severity="error" sx={{ mb: 1 }}>{t("cvImportMappingEvidenceError")}</Alert> : null}
{draft.confirmed ? <Alert severity="success" sx={{ mb: 1 }}>{t("cvImportMappingConfirmed")}</Alert> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button disabled={draft.fields.length >= 30} onClick={() => update(index, { fields: [...draft.fields, empty(catalog?.fields[0]?.name ?? "value")] })}>{t("cvImportMappingAddField")}</Button>
<Button variant="outlined" onClick={() => approve(index)}>{t("cvImportMappingConfirm")}</Button>
<Button color="inherit" onClick={() => onChange(drafts.filter((_, i) => i !== index))}>{t("cvImportMappingRemove")}</Button>
</Box>
</Box>;
})}
<Button disabled={disabled || blocks.length === 0 || drafts.length >= 25} onClick={() => {
const category = categories.find(item => item.category === "jobs") ?? categories[0];
onChange([...drafts, { clientId: crypto.randomUUID(), category: category.category, targetIndex: null, fields: defaultFields(category), confirmed: false }]);
}}>{t("cvImportMapMissing")}</Button>
</Box>;
}
@@ -3,6 +3,7 @@ import { Alert, Box, Button, FormControl, InputLabel, LinearProgress, NativeSele
import { api, getApiErrorMessage } from "../../api";
import { useI18n } from "../../i18n/I18nProvider";
import type { TranslationKey } from "../../i18n/translations";
import CvImportMappingEditor, { type MappingDraft, type MappingBlock, type MappingCategory, type MappingTarget } from "./CvImportMappingEditor";
type ReviewField = {
id: string; label: string; currentValue: string | null; extractedValue: string;
@@ -12,6 +13,7 @@ type ReviewGroup = { id: string; category: string; label: string; matchDescripti
type ImportReview = {
runId: number; status: string; revision: number; fileName: string; sourceText: string;
warnings: string[]; groups: ReviewGroup[];
sourceBlocks?: MappingBlock[]; mappingCategories?: MappingCategory[]; targets?: MappingTarget[];
};
type Choice = { action: "keep" | "use" | "correct" | "add"; value?: string; confirmed: boolean };
@@ -26,6 +28,7 @@ const labelKeys: Record<string, TranslationKey> = {
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",
value: "cvImportMappingValueLabel", linkLabel: "cvImportLinkLabel", linkUrl: "profileCvContactWebsite", qualificationLevel: "cvImportQualificationLevel",
Contact: "careerProfileContactSection", "Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline",
Email: "profileCvContactEmail", Phone: "profileCvContactPhone", Location: "profileCvContactLocation",
Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Summary: "profileCvStructuredSummary",
@@ -45,6 +48,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
translate.current = t;
const [review, setReview] = useState<ImportReview | null>(null);
const [choices, setChoices] = useState<Record<string, Choice>>({});
const [mappings, setMappings] = useState<MappingDraft[]>([]);
const [loading, setLoading] = useState(true);
const [comparisonCurrent, setComparisonCurrent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,6 +85,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
useEffect(() => {
const requestState = generation;
setChoices({});
setMappings([]);
setStale(false);
void loadReview();
return () => { ++requestState.current; };
@@ -110,7 +115,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
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;
const allReviewed = (fields.length > 0 || mappings.length > 0) && reviewedCount === fields.length && mappings.every(mapping => mapping.confirmed);
async function apply() {
if (disabled || applyPending.current || !comparisonCurrent || !review || !allReviewed) return;
@@ -125,12 +130,14 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
const choice = choices[field.id];
return { id: field.id, action: choice.action, ...(choice.action === "correct" ? { value: choice.value ?? field.extractedValue } : {}) };
}),
...(mappings.length > 0 ? { mappings: mappings.map(({ clientId, category, targetIndex, fields: mappedFields }) => ({ clientId, category, targetIndex, fields: mappedFields })) } : {}),
});
setApplied(true);
await onApplied();
} catch (failure) {
if ((failure as { response?: { status?: number } }).response?.status === 409) {
setStale(true);
setMappings(current => current.map(mapping => ({ ...mapping, targetIndex: null, confirmed: false })));
setChoices((current) => Object.fromEntries(Object.entries(current).map(([id, choice]) => [id, { ...choice, confirmed: false }])));
await loadReview();
} else setError(getApiErrorMessage(failure, translate.current("profileCvApplyChangesFailed")));
@@ -209,6 +216,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
})}
</Box>)}
{fields.length === 0 ? <Alert severity="info" sx={{ mb: 1 }}>{t("cvImportNoFields")}</Alert> : null}
<CvImportMappingEditor categories={review.mappingCategories ?? []} blocks={review.sourceBlocks ?? []} targets={review.targets ?? []} drafts={mappings} onChange={setMappings} label={label} disabled={disabled || applying || loading || !comparisonCurrent} />
<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>