diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 40722c6..8b5c185 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -81,6 +81,7 @@ public sealed class ProfileCvControllerTests Assert.IsType(result); var artifact = await db.CvUploadArtifacts.SingleAsync(); var run = await db.CvExtractionRuns.SingleAsync(); + var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("user-1", artifact.OwnerUserId); Assert.Equal("resume.md", artifact.OriginalFileName); Assert.True(System.IO.File.Exists(artifact.StoragePath)); @@ -90,6 +91,42 @@ public sealed class ProfileCvControllerTests Assert.Equal(run.Id, user.CurrentCvExtractionRunId); Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId); Assert.Equal(1, user.CurrentCvProfileVersion); + Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId); + Assert.True(parsed.Metadata.ProfileVersion >= 1); + Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary"); + } + + [Fact] + public async Task GetRuns_returns_latest_extraction_runs() + { + var user = new ApplicationUser { Id = "user-1" }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var aiService = new Mock(); + + await using var db = CreateDb(); + db.CvExtractionRuns.Add(new CvExtractionRun + { + OwnerUserId = "user-1", + Trigger = "upload", + ParserVersion = "m005-s01", + NormalizerVersion = "m005-s01", + LlmPromptVersion = "m005-s01", + Status = "applied", + StartedAtUtc = DateTimeOffset.UtcNow, + AppliedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var paths = CreatePaths(); + var controller = CreateController(userManager.Object, aiService.Object, db, paths); + var result = await controller.GetRuns(); + + var ok = Assert.IsType(result.Result); + var runs = Assert.IsAssignableFrom>(ok.Value); + var single = Assert.Single(runs); + Assert.Equal("upload", single.Trigger); + Assert.Equal("applied", single.Status); } [Fact] diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 1335f26..0a74b51 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -76,6 +76,18 @@ public sealed class ProfileCvController : ControllerBase public sealed record ParseCvRequest(string? Text); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); + public sealed record CvExtractionRunListItem( + int Id, + string Trigger, + string Status, + string? ArtifactFileName, + DateTimeOffset StartedAtUtc, + DateTimeOffset? CompletedAtUtc, + DateTimeOffset? AppliedAtUtc, + string ParserVersion, + string NormalizerVersion, + string LlmPromptVersion, + string? ErrorMessage); [HttpPost("upload")] [RequestSizeLimit(MaxFileSizeBytes)] @@ -163,6 +175,34 @@ public sealed class ProfileCvController : ControllerBase } } + [HttpGet("runs")] + public async Task>> GetRuns() + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var runs = await _db.CvExtractionRuns + .AsNoTracking() + .Where(x => x.OwnerUserId == user.Id) + .OrderByDescending(x => x.StartedAtUtc) + .Take(10) + .Select(x => new CvExtractionRunListItem( + x.Id, + x.Trigger, + x.Status, + x.Artifact != null ? x.Artifact.OriginalFileName : null, + x.StartedAtUtc, + x.CompletedAtUtc, + x.AppliedAtUtc, + x.ParserVersion, + x.NormalizerVersion, + x.LlmPromptVersion, + x.ErrorMessage)) + .ToListAsync(HttpContext.RequestAborted); + + return Ok(runs); + } + [HttpPost("reprocess")] public async Task Reprocess() { @@ -308,7 +348,9 @@ public sealed class ProfileCvController : ControllerBase .ToList(); var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections); + AnnotateStructuredCv(sectionFallback, "repair", 0.56); var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text); + AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68); heuristicFallback.Sections = new List(); var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback); fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email); @@ -433,6 +475,40 @@ public sealed class ProfileCvController : ControllerBase await _db.SaveChangesAsync(cancellationToken); } + private static void AnnotateStructuredCv(StructuredCvProfile profile, string method, double confidence) + { + var now = DateTimeOffset.UtcNow; + profile.Metadata ??= new StructuredCvMetadata(); + profile.Metadata.Fields ??= new Dictionary(); + + void SetIf(string key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) return; + profile.Metadata.Fields[key] = new StructuredCvFieldMetadata + { + Confidence = confidence, + Method = method, + SourceSnippet = value.Length > 180 ? value[..180] : value, + ReviewState = "suggested", + LastUpdatedAtUtc = now, + }; + } + + SetIf("contact.fullName", profile.Contact.FullName); + SetIf("contact.headline", profile.Contact.Headline); + SetIf("contact.email", profile.Contact.Email); + SetIf("contact.phone", profile.Contact.Phone); + SetIf("contact.location", profile.Contact.Location); + SetIf("contact.website", profile.Contact.Website); + SetIf("contact.linkedIn", profile.Contact.LinkedIn); + SetIf("summary", profile.Summary.FirstOrDefault()); + SetIf("skills", profile.Skills.FirstOrDefault()); + SetIf("languages", profile.Languages.FirstOrDefault()?.Name); + SetIf("interests", profile.Interests.FirstOrDefault()); + SetIf("jobs", profile.Jobs.FirstOrDefault()?.Title ?? profile.Jobs.FirstOrDefault()?.Company); + SetIf("education", profile.Education.FirstOrDefault()?.Qualification ?? profile.Education.FirstOrDefault()?.Institution); + } + private async Task TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken) { var structuredJson = await _aiService.SummarizeSectionAsync( @@ -446,7 +522,10 @@ public sealed class ProfileCvController : ControllerBase if (string.IsNullOrWhiteSpace(extracted)) return null; var parsed = StructuredCvProfileJson.Deserialize(extracted); - return IsMeaningfullyStructured(parsed) ? parsed : null; + if (!IsMeaningfullyStructured(parsed)) return null; + + AnnotateStructuredCv(parsed, "llm", 0.82); + return parsed; } private static bool IsMeaningfullyStructured(StructuredCvProfile profile) diff --git a/Models/StructuredCvProfileJson.cs b/Models/StructuredCvProfileJson.cs index 5ae9d60..ff43d3d 100644 --- a/Models/StructuredCvProfileJson.cs +++ b/Models/StructuredCvProfileJson.cs @@ -76,6 +76,14 @@ public static class StructuredCvProfileJson if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections; if (primary.Sections.Count == 0) primary.Sections = secondary.Sections; + foreach (var entry in secondary.Metadata.Fields) + { + if (!primary.Metadata.Fields.ContainsKey(entry.Key)) + { + primary.Metadata.Fields[entry.Key] = entry.Value; + } + } + return Normalize(primary); } diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index ec28aeb..8b5ffaf 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 2e86a97..0d755c2 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -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 ( + + + {metadata.method ? : null} + {metadata.reviewState ? : null} + {metadata.sourceSnippet ? ( + + {metadata.sourceSnippet} + + ) : null} + + ); +} + 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(emptyStructuredCv()); + const [extractionRuns, setExtractionRuns] = useState([]); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const loadProfile = useCallback(async () => { try { - const r = await api.get("/auth/me"); + const [profileResponse, runsResponse] = await Promise.all([ + api.get("/auth/me"), + api.get("/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 ( @@ -332,6 +381,24 @@ export default function ProfilePage() { > {improvingCv ? t("profileCvImproving") : t("profileCvImprove")} + @@ -348,6 +415,41 @@ export default function ProfilePage() { disabled={!isLocal} fullWidth /> + + + + {t("profileCvExtractionHistory")} + {t("profileCvExtractionHistoryHelp")} + + {structuredCv.metadata.profileVersion ? : null} + + {latestRun ? ( + + {extractionRuns.map((run) => ( + + + {run.trigger} + + + {run.id === structuredCv.metadata.appliedExtractionRunId ? : null} + + + {run.artifactFileName || t("profileCvNoStoredArtifact")} + + {run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()} + + {run.errorMessage ? ( + + {run.errorMessage} + + ) : null} + + ))} + + ) : ( + {t("profileCvExtractionHistoryEmpty")} + )} + @@ -400,43 +502,67 @@ export default function ProfilePage() { - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth /> - setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth /> setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> - setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={5} - fullWidth - /> - setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={5} - fullWidth - /> - setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))} - helperText={t("profileCvStructuredListHelp")} - multiline - minRows={4} - fullWidth - /> + + setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + + + + setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + + + + setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={4} + fullWidth + /> + + @@ -444,6 +570,7 @@ export default function ProfilePage() { {t("profileCvStructuredLanguages")} + {structuredCv.languages.length === 0 ? {t("profileCvStructuredEmpty")} : null} {structuredCv.languages.map((language, index) => ( diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index c4cfd56..94f322c 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -24,6 +24,16 @@ const mockedApi = api as jest.Mocked; 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(); diff --git a/job-tracker-ui/src/profileCv.ts b/job-tracker-ui/src/profileCv.ts index 7121c99..804e639 100644 --- a/job-tracker-ui/src/profileCv.ts +++ b/job-tracker-ui/src/profileCv.ts @@ -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; +}; + 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).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]; +}