Add CV extraction review surfaces

This commit is contained in:
2026-03-28 23:51:03 +01:00
parent 107c181506
commit 2392b135c2
7 changed files with 390 additions and 34 deletions
+18
View File
@@ -211,6 +211,15 @@ export const translations = {
profileCvStructureEmpty: "No parsed sections yet.",
profileCvStructuredEditor: "Structured CV editor",
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
profileCvExtractionHistory: "Extraction history",
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
profileCvProfileVersion: "Profile v{count}",
profileCvCurrentRun: "Current run",
profileCvNoStoredArtifact: "No stored artifact",
profileCvReprocess: "Reprocess CV",
profileCvReprocessing: "Reprocessing CV...",
profileCvReprocessed: "CV reprocessed from the stored artifact.",
profileCvReprocessFailed: "Failed to reprocess the stored CV artifact.",
profileCvContactFullName: "Full name",
profileCvContactHeadline: "Professional headline",
profileCvContactEmail: "Contact email",
@@ -1102,6 +1111,15 @@ export const translations = {
profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.",
profileCvStructuredEditor: "Strukturert CV-redigering",
profileCvStructuredEditorHelp: "Rediger gjenbrukbare CV-data direkte slik at generatorer og matching kan jobbe fra stabile felt i stedet for bare råtekst.",
profileCvExtractionHistory: "Ekstraksjonshistorikk",
profileCvExtractionHistoryHelp: "Se hvilken parserkjøring som produserte den nåværende strukturerte profilen, og kjør på nytt fra det lagrede kildeartefaktet ved behov.",
profileCvProfileVersion: "Profil v{count}",
profileCvCurrentRun: "Gjeldende kjøring",
profileCvNoStoredArtifact: "Ingen lagret kildefil",
profileCvReprocess: "Kjør CV på nytt",
profileCvReprocessing: "Kjører CV på nytt...",
profileCvReprocessed: "CV-en ble kjørt på nytt fra det lagrede artefaktet.",
profileCvReprocessFailed: "Kunne ikke kjøre den lagrede CV-filen på nytt.",
profileCvContactFullName: "Fullt navn",
profileCvContactHeadline: "Profesjonell overskrift",
profileCvContactEmail: "Kontakt-e-post",
+160 -33
View File
@@ -12,10 +12,12 @@ import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import {
emptyStructuredCv,
getStructuredCvFieldMetadata,
joinLines,
normalizeStructuredCv,
parseStructuredCvJson,
splitLines,
StructuredCvFieldMetadata,
StructuredCvProfile,
} from "../profileCv";
@@ -23,6 +25,20 @@ import {
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
type ExtractionRun = {
id: number;
trigger: string;
status: string;
artifactFileName?: string;
startedAtUtc: string;
completedAtUtc?: string;
appliedAtUtc?: string;
parserVersion: string;
normalizerVersion: string;
llmPromptVersion: string;
errorMessage?: string;
};
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -78,6 +94,30 @@ function replaceCvSection(source: string, sectionName: string, sectionDraft: str
return [before, `${sectionName}\n${trimmedDraft}`, after].filter(Boolean).join("\n\n").trim();
}
function confidenceTone(confidence?: number) {
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
}
function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
if (!metadata) return null;
const tone = confidenceTone(metadata.confidence);
return (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
{metadata.sourceSnippet ? (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{metadata.sourceSnippet}
</Typography>
) : null}
</Box>
);
}
export default function ProfilePage() {
const { toast } = useToast();
const { t } = useI18n();
@@ -105,13 +145,19 @@ export default function ProfilePage() {
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
const [cvSectionDraft, setCvSectionDraft] = useState("");
const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const loadProfile = useCallback(async () => {
try {
const r = await api.get<MeResponse>("/auth/me");
const [profileResponse, runsResponse] = await Promise.all([
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
]);
const r = profileResponse;
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
@@ -120,9 +166,11 @@ export default function ProfilePage() {
setDisplayName(r.data?.displayName ?? "");
setProfileCvText(r.data?.profileCvText ?? "");
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
setExtractionRuns(runsResponse.data ?? []);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
} catch {
setMe(null);
setExtractionRuns([]);
}
}, []);
@@ -142,6 +190,7 @@ export default function ProfilePage() {
: t("profileGoogleLinked")
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const latestRun = extractionRuns[0];
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
@@ -332,6 +381,24 @@ export default function ProfilePage() {
>
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
onClick={async () => {
setReprocessingCv(true);
try {
await api.post("/profile-cv/reprocess");
await loadProfile();
toast(t("profileCvReprocessed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
} finally {
setReprocessingCv(false);
}
}}
>
{reprocessingCv ? t("profileCvReprocessing") : t("profileCvReprocess")}
</Button>
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
{t("profileCopyCvText")}
</Button>
@@ -348,6 +415,41 @@ export default function ProfilePage() {
disabled={!isLocal}
fullWidth
/>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvExtractionHistory")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryHelp")}</Typography>
</Box>
{structuredCv.metadata.profileVersion ? <Chip label={t("profileCvProfileVersion", { count: structuredCv.metadata.profileVersion })} size="small" /> : null}
</Box>
{latestRun ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
{extractionRuns.map((run) => (
<Box key={run.id} sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: run.id === structuredCv.metadata.appliedExtractionRunId ? "primary.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 0.75 }}>
<Typography variant="overline">{run.trigger}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
<Chip size="small" label={run.status} color={run.status === "applied" ? "success" : run.status === "failed" ? "error" : "default"} variant={run.status === "applied" ? "filled" : "outlined"} />
{run.id === structuredCv.metadata.appliedExtractionRunId ? <Chip size="small" color="primary" label={t("profileCvCurrentRun")} /> : null}
</Box>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()}
</Typography>
{run.errorMessage ? (
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
{run.errorMessage}
</Typography>
) : null}
</Box>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryEmpty")}</Typography>
)}
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
@@ -400,43 +502,67 @@ export default function ProfilePage() {
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label={t("profileCvContactFullName")} value={structuredCv.contact.fullName ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactHeadline")} value={structuredCv.contact.headline ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactEmail")} value={structuredCv.contact.email ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactPhone")} value={structuredCv.contact.phone ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactLocation")} value={structuredCv.contact.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth />
<Box>
<TextField label={t("profileCvContactFullName")} value={structuredCv.contact.fullName ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.fullName")} />
</Box>
<Box>
<TextField label={t("profileCvContactHeadline")} value={structuredCv.contact.headline ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.headline")} />
</Box>
<Box>
<TextField label={t("profileCvContactEmail")} value={structuredCv.contact.email ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.email")} />
</Box>
<Box>
<TextField label={t("profileCvContactPhone")} value={structuredCv.contact.phone ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.phone")} />
</Box>
<Box>
<TextField label={t("profileCvContactLocation")} value={structuredCv.contact.location ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth />
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "contact.location")} />
</Box>
<TextField label={t("profileCvContactWebsite")} value={structuredCv.contact.website ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth />
<TextField label={t("profileCvContactLinkedIn")} value={structuredCv.contact.linkedIn ?? ""} onChange={(e) => setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField
label={t("profileCvStructuredSummary")}
value={joinLines(structuredCv.summary)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<TextField
label={t("profileCvStructuredSkills")}
value={joinLines(structuredCv.skills)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<TextField
label={t("profileCvStructuredInterests")}
value={joinLines(structuredCv.interests)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={4}
fullWidth
/>
<Box>
<TextField
label={t("profileCvStructuredSummary")}
value={joinLines(structuredCv.summary)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "summary")} />
</Box>
<Box>
<TextField
label={t("profileCvStructuredSkills")}
value={joinLines(structuredCv.skills)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={5}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "skills")} />
</Box>
<Box>
<TextField
label={t("profileCvStructuredInterests")}
value={joinLines(structuredCv.interests)}
onChange={(e) => setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={4}
fullWidth
/>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "interests")} />
</Box>
</Box>
<Box sx={{ mt: 2 }}>
@@ -444,6 +570,7 @@ export default function ProfilePage() {
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: [...prev.languages, { name: "", level: "", notes: "" }] }))}>{t("profileCvStructuredAddLanguage")}</Button>
</Box>
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "languages")} />
{structuredCv.languages.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{structuredCv.languages.map((language, index) => (
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
+47
View File
@@ -24,6 +24,16 @@ const mockedApi = api as jest.Mocked<typeof api>;
const structuredCv = {
version: '1',
metadata: {
profileVersion: 3,
appliedExtractionRunId: 12,
updatedAtUtc: '2026-03-28T12:00:00Z',
fields: {
'contact.fullName': { confidence: 0.92, method: 'llm', reviewState: 'suggested', sourceSnippet: 'Demo User' },
summary: { confidence: 0.71, method: 'deterministic', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
skills: { confidence: 0.68, method: 'deterministic', reviewState: 'suggested', sourceSnippet: '.NET' },
},
},
contact: {
fullName: 'Demo User',
headline: 'Backend Developer',
@@ -80,6 +90,24 @@ beforeEach(() => {
},
} as any);
}
if (url === '/profile-cv/runs') {
return Promise.resolve({
data: [
{
id: 12,
trigger: 'upload',
status: 'applied',
artifactFileName: 'resume.pdf',
startedAtUtc: '2026-03-28T12:00:00Z',
completedAtUtc: '2026-03-28T12:00:05Z',
appliedAtUtc: '2026-03-28T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
},
],
} as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.post.mockImplementation((url: string) => {
@@ -97,6 +125,9 @@ beforeEach(() => {
},
} as any);
}
if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
@@ -113,8 +144,12 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument();
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
expect(screen.getByText(/current run/i)).toBeInTheDocument();
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
await waitFor(() => expect(analyzeButton).toBeEnabled());
@@ -127,6 +162,18 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
});
test('profile page can reprocess from stored artifact history', async () => {
renderPage();
expect(await screen.findByText(/extraction history/i)).toBeInTheDocument();
const reprocessButton = screen.getByRole('button', { name: /reprocess cv/i });
fireEvent.click(reprocessButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/reprocess');
});
});
test('saving profile persists structured cv json', async () => {
renderPage();
+40
View File
@@ -4,6 +4,23 @@ export type ParsedCvSection = {
wordCount: number;
};
export type StructuredCvFieldMetadata = {
confidence?: number;
method?: string;
sourceSnippet?: string;
sourcePage?: number;
sourceBlockId?: string;
reviewState?: string;
lastUpdatedAtUtc?: string;
};
export type StructuredCvMetadata = {
profileVersion?: number;
appliedExtractionRunId?: number;
updatedAtUtc?: string;
fields: Record<string, StructuredCvFieldMetadata>;
};
export type StructuredCvContact = {
fullName?: string;
headline?: string;
@@ -47,6 +64,7 @@ export type StructuredCvOtherSection = {
export type StructuredCvProfile = {
version: string;
metadata: StructuredCvMetadata;
contact: StructuredCvContact;
summary: string[];
jobs: StructuredCvJob[];
@@ -72,6 +90,7 @@ export function joinLines(values: string[]) {
export function emptyStructuredCv(): StructuredCvProfile {
return {
version: "1",
metadata: { fields: {} },
contact: {},
summary: [],
jobs: [],
@@ -135,6 +154,7 @@ function buildLegacyStructuredCv(sections: ParsedCvSection[]): StructuredCvProfi
return {
...emptyStructuredCv(),
metadata: { fields: {} },
contact,
summary,
skills,
@@ -153,6 +173,22 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
const sections = normalizeParsedSections(source.sections);
const normalized: StructuredCvProfile = {
version: normalizeString(source.version) ?? "1",
metadata: {
profileVersion: Number.isFinite(Number(source.metadata?.profileVersion)) ? Number(source.metadata.profileVersion) : undefined,
appliedExtractionRunId: Number.isFinite(Number(source.metadata?.appliedExtractionRunId)) ? Number(source.metadata.appliedExtractionRunId) : undefined,
updatedAtUtc: normalizeString(source.metadata?.updatedAtUtc),
fields: source.metadata?.fields && typeof source.metadata.fields === "object"
? Object.fromEntries(Object.entries(source.metadata.fields as Record<string, any>).map(([key, value]) => [key, {
confidence: Number.isFinite(Number(value?.confidence)) ? Number(value.confidence) : undefined,
method: normalizeString(value?.method),
sourceSnippet: normalizeString(value?.sourceSnippet),
sourcePage: Number.isFinite(Number(value?.sourcePage)) ? Number(value.sourcePage) : undefined,
sourceBlockId: normalizeString(value?.sourceBlockId),
reviewState: normalizeString(value?.reviewState),
lastUpdatedAtUtc: normalizeString(value?.lastUpdatedAtUtc),
}]))
: {},
},
contact: {
fullName: normalizeString(source.contact?.fullName),
headline: normalizeString(source.contact?.headline),
@@ -221,3 +257,7 @@ export function parseStructuredCvJson(value?: string): StructuredCvProfile {
return emptyStructuredCv();
}
}
export function getStructuredCvFieldMetadata(profile: StructuredCvProfile, key: string): StructuredCvFieldMetadata | undefined {
return profile.metadata?.fields?.[key];
}