feat: add cv benchmark workflow and admin visibility

This commit is contained in:
2026-04-01 12:25:45 +02:00
parent 0551a525a8
commit 0d65835857
16 changed files with 832 additions and 95 deletions
@@ -43,6 +43,14 @@ describe('AdminSystemPage', () => {
gpuName: null,
ocrAvailable: true,
ocrLanguages: 'eng',
ollamaConfigured: true,
ollamaReachable: true,
ollamaModel: 'qwen2.5:7b',
ollamaModelAvailable: true,
ollamaVersion: '0.7.0',
ollamaInstalledModels: ['qwen2.5:7b', 'nomic-embed-text'],
ollamaLoadedModels: ['qwen2.5:7b'],
ollamaLoadedCount: 1,
healthLatencyMs: 12.4,
probeLatencyMs: 25.8,
lastProbeAt: '2026-03-23T10:00:00Z',
@@ -82,6 +90,15 @@ describe('AdminSystemPage', () => {
},
} as any);
}
if (url === '/admin/system/cv-benchmark') {
return Promise.resolve({
data: {
rootPath: '/data/CvBenchmarks/latest',
lastUpdatedAtUtc: '2026-03-23T10:10:00Z',
reportMarkdown: '# CV benchmark report\n\n- Files: 4',
},
} as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({
@@ -118,6 +135,9 @@ describe('AdminSystemPage', () => {
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
expect(screen.getByText('OCR eng')).toBeTruthy();
expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0);
expect(screen.getByText(/ollama version/i)).toBeTruthy();
expect(screen.getByText(/model · qwen2.5:7b/i)).toBeTruthy();
expect(screen.getByText(/cv benchmark review/i)).toBeTruthy();
expect(screen.getByText('OCR avg latency')).toBeTruthy();
expect(screen.getByText('88.4 ms')).toBeTruthy();
});
+41 -1
View File
@@ -26,6 +26,14 @@ type AiServiceMetrics = {
gpuName?: string | null;
ocrAvailable?: boolean | null;
ocrLanguages?: string | null;
ollamaConfigured?: boolean | null;
ollamaReachable?: boolean | null;
ollamaModel?: string | null;
ollamaModelAvailable?: boolean | null;
ollamaVersion?: string | null;
ollamaInstalledModels?: string[] | null;
ollamaLoadedModels?: string[] | null;
ollamaLoadedCount?: number | null;
healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
@@ -60,6 +68,13 @@ type EditableEmailSettings = {
hasPassword: boolean;
};
type CvBenchmarkStatus = {
indexJson?: string | null;
reportMarkdown?: string | null;
rootPath: string;
lastUpdatedAtUtc?: string | null;
};
type SystemStatus = {
environment: string;
contentRoot: string;
@@ -141,6 +156,7 @@ export default function AdminSystemPage() {
const [tab, setTab] = useState(0);
const [status, setStatus] = useState<SystemStatus | null>(null);
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
const [smtpPassword, setSmtpPassword] = useState("");
const [clearPassword, setClearPassword] = useState(false);
const [loading, setLoading] = useState(false);
@@ -156,18 +172,21 @@ export default function AdminSystemPage() {
setLoading(true);
setError(null);
try {
const [statusRes, emailRes] = await Promise.all([
const [statusRes, emailRes, benchmarkRes] = await Promise.all([
api.get<SystemStatus>("/admin/system"),
api.get<EditableEmailSettings>("/admin/system/email-settings"),
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
]);
setStatus(statusRes.data);
setEmailSettings(emailRes.data);
setBenchmarkStatus(benchmarkRes.data ?? null);
setSmtpPassword("");
setClearPassword(false);
} catch (e: any) {
setError(getApiErrorMessage(e, "Failed to load system status."));
setStatus(null);
setEmailSettings(null);
setBenchmarkStatus(null);
} finally {
setLoading(false);
}
@@ -367,6 +386,8 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
<DetailRow label="Ollama version" value={status?.ai.ollamaVersion || "-"} />
<DetailRow label="Loaded models" value={status?.ai.ollamaLoadedCount ?? 0} />
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
@@ -395,6 +416,25 @@ export default function AdminSystemPage() {
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : t("adminSystemOcrUnavailable")} variant="outlined" size="small" />
{(status?.ai.ollamaInstalledModels ?? []).slice(0, 4).map((model) => (
<Chip key={model} label={`Model · ${model}`} variant="outlined" size="small" />
))}
{(status?.ai.ollamaLoadedModels ?? []).slice(0, 3).map((model) => (
<Chip key={`loaded-${model}`} label={`Loaded · ${model}`} color="primary" variant="outlined" size="small" />
))}
</Box>
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>CV benchmark review</Typography>
<Stack spacing={0.75}>
<DetailRow label="Benchmark root" value={benchmarkStatus?.rootPath || "-"} />
<DetailRow label="Last benchmark update" value={formatDate(benchmarkStatus?.lastUpdatedAtUtc)} />
</Stack>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", maxHeight: 260, overflow: "auto" }}>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
{benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."}
</Typography>
</Box>
</Paper>
</>
+178 -60
View File
@@ -26,7 +26,7 @@ import { JobApplication } from "../types";
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh";
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
type ExtractionRun = {
id: number;
@@ -60,6 +60,18 @@ type RewriteTemplateOption = {
sampleBullets: string[];
};
type CvBuilderPreview = {
templateId: CvSectionStyle;
html: string;
suggestedFileName: string;
fullText: string;
rewrittenText: string;
structuredCv: StructuredCvProfile;
sectionName?: string | null;
targetRole?: string | null;
jobApplicationId?: number | null;
};
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -122,6 +134,26 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
sampleMeta: "Premium spacing · stronger visual voice",
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
},
{
id: "monarch",
title: "Monarch",
eyebrow: "Executive",
accent: "#7c2d12",
blurb: "High-contrast premium presentation for leadership-heavy applications.",
sampleHeading: "Executive Profile",
sampleMeta: "Leadership clarity · premium hierarchy",
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
},
{
id: "fjord",
title: "Fjord",
eyebrow: "Technical",
accent: "#0f4c5c",
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
sampleHeading: "Projects & Systems",
sampleMeta: "Technical depth · practical readability",
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
},
];
function initialsFrom(values: Array<string | undefined>) {
@@ -208,7 +240,7 @@ export default function ProfilePage() {
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [cvSectionDraft, setCvSectionDraft] = useState("");
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
const [parsingCvSections, setParsingCvSections] = useState(false);
@@ -263,6 +295,7 @@ export default function ProfilePage() {
const latestRun = extractionRuns[0];
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
@@ -742,39 +775,22 @@ export default function ProfilePage() {
))}
</Box>
</Box>
<Box sx={{ mt: 2, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.03) 0%, rgba(15,23,42,0) 100%)" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>CV style rewrite studio</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
</Box>
<Button
variant="contained"
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
sectionName: cvSection || null,
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
});
setCvSectionDraft(res.data?.text ?? "");
toast(t("profileCvSectionRewritten"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
} finally {
setRewritingSection(false);
}
}}
>
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
</Button>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
{REWRITE_TEMPLATES.map((option) => {
const selected = option.id === cvSectionStyle;
return (
@@ -791,25 +807,27 @@ export default function ProfilePage() {
}}
sx={{
p: 1.5,
borderRadius: 3,
borderRadius: 3.5,
cursor: "pointer",
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.15)" : "none",
backgroundColor: selected ? "rgba(25,118,210,0.06)" : "background.paper",
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
'&:hover': { transform: 'translateY(-2px)' },
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
<Box>
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 800 }}>{option.eyebrow}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{option.title}</Typography>
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
</Box>
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
<ZoomInOutlinedIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 148 }}>
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 700, mb: 0.5 }}>{option.sampleHeading}</Typography>
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
{option.sampleBullets.map((bullet) => (
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}> {bullet}</Typography>
@@ -821,7 +839,7 @@ export default function ProfilePage() {
})}
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
@@ -833,7 +851,13 @@ export default function ProfilePage() {
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
</Select>
</FormControl>
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} />
<TextField
label={t("profileCvSectionTargetRole")}
value={cvSectionTargetRole}
onChange={(e) => setCvSectionTargetRole(e.target.value)}
fullWidth
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
/>
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<InputLabel>Saved job context</InputLabel>
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
@@ -845,27 +869,121 @@ export default function ProfilePage() {
</FormControl>
</Box>
<Paper sx={{ 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 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Rewrite preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · {cvSection || "Whole CV"}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
variant="contained"
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
try {
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
sectionName: cvSection || null,
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
});
setRewritePreview(res.data);
toast(t("profileCvSectionRewritten"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
} finally {
setRewritingSection(false);
}
}}
>
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
</Button>
<Button
variant="outlined"
disabled={!rewriteReady}
onClick={async () => {
try {
const response = await api.post("/profile-cv/export-pdf", {
sectionName: cvSection || null,
style: cvSectionStyle,
templateId: cvSectionStyle,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
}, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast("CV PDF downloaded.", "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
}
}}
>
Download PDF
</Button>
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
</Box>
{cvSectionDraft.trim() ? <Chip size="small" color="success" label="Draft ready" /> : null}
</Box>
<Box sx={{ minHeight: 180, borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{cvSectionDraft.trim() ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{cvSectionDraft}</Typography>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here.</Typography>
)}
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim() : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionAppend") : "Use as full CV"}</Button>
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? replaceCvSection(prev, cvSection, cvSectionDraft) : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionReplace") : "Replace full CV"}</Button>
</Box>
</Paper>
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{rewriteReady ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
)}
</Box>
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
<Button
variant="contained"
disabled={!rewriteReady}
onClick={() => {
setProfileCvText(rewritePreview?.fullText ?? "");
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
}}
>
Replace master CV
</Button>
</Box>
</Paper>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
</Box>
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
</Box>
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
{rewriteReady ? (
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
) : (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it.
</Typography>
</Box>
)}
</Box>
</Paper>
</Box>
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
+6 -6
View File
@@ -146,8 +146,8 @@ beforeEach(() => {
},
} as any);
}
if (url === '/profile-cv/rewrite-section') {
return Promise.resolve({ data: { text: 'Professional Summary\nClearer, sharper positioning for backend platform roles.' } } as any);
if (url === '/profile-cv/rewrite-preview') {
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
}
if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any);
@@ -228,16 +228,16 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
test('profile page rewrite tools use selected template and saved job context', async () => {
renderPage();
expect(await screen.findByText(/cv style rewrite studio/i)).toBeInTheDocument();
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/harvard/i));
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
const rewriteButton = screen.getByRole('button', { name: /rewrite section/i });
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
fireEvent.click(rewriteButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-section', expect.objectContaining({
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
sectionName: null,
style: 'harvard',
templateId: 'harvard',
@@ -245,7 +245,7 @@ test('profile page rewrite tools use selected template and saved job context', a
}));
});
expect(await screen.findByText(/draft ready/i)).toBeInTheDocument();
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
});
+44
View File
@@ -44,6 +44,7 @@ export type StructuredCvJob = {
export type StructuredCvEducation = {
qualification?: string;
qualificationLevel?: string;
institution?: string;
location?: string;
start?: string;
@@ -51,6 +52,24 @@ export type StructuredCvEducation = {
details: string[];
};
export type StructuredCvCertification = {
name?: string;
issuer?: string;
location?: string;
date?: string;
details: string[];
};
export type StructuredCvProject = {
name?: string;
role?: string;
location?: string;
start?: string;
end?: string;
bullets: string[];
skills: string[];
};
export type StructuredCvLanguage = {
name?: string;
level?: string;
@@ -69,6 +88,8 @@ export type StructuredCvProfile = {
summary: string[];
jobs: StructuredCvJob[];
education: StructuredCvEducation[];
certifications: StructuredCvCertification[];
projects: StructuredCvProject[];
skills: string[];
languages: StructuredCvLanguage[];
interests: string[];
@@ -95,6 +116,8 @@ export function emptyStructuredCv(): StructuredCvProfile {
summary: [],
jobs: [],
education: [],
certifications: [],
projects: [],
skills: [],
languages: [],
interests: [],
@@ -214,6 +237,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
education: Array.isArray(source.education)
? source.education.map((education: any) => ({
qualification: normalizeString(education?.qualification),
qualificationLevel: normalizeString(education?.qualificationLevel),
institution: normalizeString(education?.institution),
location: normalizeString(education?.location),
start: normalizeString(education?.start),
@@ -221,6 +245,26 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
details: normalizeList(education?.details),
}))
: [],
certifications: Array.isArray(source.certifications)
? source.certifications.map((certification: any) => ({
name: normalizeString(certification?.name),
issuer: normalizeString(certification?.issuer),
location: normalizeString(certification?.location),
date: normalizeString(certification?.date),
details: normalizeList(certification?.details),
}))
: [],
projects: Array.isArray(source.projects)
? source.projects.map((project: any) => ({
name: normalizeString(project?.name),
role: normalizeString(project?.role),
location: normalizeString(project?.location),
start: normalizeString(project?.start),
end: normalizeString(project?.end),
bullets: normalizeList(project?.bullets),
skills: normalizeList(project?.skills),
}))
: [],
skills: normalizeList(source.skills),
languages: Array.isArray(source.languages)
? source.languages.map((language: any) => ({
+1
View File
@@ -40,6 +40,7 @@ export interface TailoredCvExperienceItem {
export interface TailoredCvEducationItem {
qualification?: string | null;
qualificationLevel?: string | null;
institution?: string | null;
location?: string | null;
start?: string | null;