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