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
+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" }}>