Add CV extraction review surfaces
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user