Add CV extraction review surfaces
This commit is contained in:
@@ -81,6 +81,7 @@ public sealed class ProfileCvControllerTests
|
|||||||
Assert.IsType<OkObjectResult>(result);
|
Assert.IsType<OkObjectResult>(result);
|
||||||
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
||||||
var run = await db.CvExtractionRuns.SingleAsync();
|
var run = await db.CvExtractionRuns.SingleAsync();
|
||||||
|
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||||
Assert.Equal("user-1", artifact.OwnerUserId);
|
Assert.Equal("user-1", artifact.OwnerUserId);
|
||||||
Assert.Equal("resume.md", artifact.OriginalFileName);
|
Assert.Equal("resume.md", artifact.OriginalFileName);
|
||||||
Assert.True(System.IO.File.Exists(artifact.StoragePath));
|
Assert.True(System.IO.File.Exists(artifact.StoragePath));
|
||||||
@@ -90,6 +91,42 @@ public sealed class ProfileCvControllerTests
|
|||||||
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
|
||||||
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
|
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
|
||||||
Assert.Equal(1, user.CurrentCvProfileVersion);
|
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<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
var aiService = new Mock<ISummarizerService>();
|
||||||
|
|
||||||
|
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<OkObjectResult>(result.Result);
|
||||||
|
var runs = Assert.IsAssignableFrom<IEnumerable<ProfileCvController.CvExtractionRunListItem>>(ok.Value);
|
||||||
|
var single = Assert.Single(runs);
|
||||||
|
Assert.Equal("upload", single.Trigger);
|
||||||
|
Assert.Equal("applied", single.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
public sealed record ParseCvRequest(string? Text);
|
public sealed record ParseCvRequest(string? Text);
|
||||||
|
|
||||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
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")]
|
[HttpPost("upload")]
|
||||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||||
@@ -163,6 +175,34 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("runs")]
|
||||||
|
public async Task<ActionResult<IEnumerable<CvExtractionRunListItem>>> 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")]
|
[HttpPost("reprocess")]
|
||||||
public async Task<IActionResult> Reprocess()
|
public async Task<IActionResult> Reprocess()
|
||||||
{
|
{
|
||||||
@@ -308,7 +348,9 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections);
|
var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections);
|
||||||
|
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
|
||||||
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
|
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
|
||||||
|
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
|
||||||
heuristicFallback.Sections = new List<StructuredCvSection>();
|
heuristicFallback.Sections = new List<StructuredCvSection>();
|
||||||
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
|
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
|
||||||
fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email);
|
fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email);
|
||||||
@@ -433,6 +475,40 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
await _db.SaveChangesAsync(cancellationToken);
|
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<string, StructuredCvFieldMetadata>();
|
||||||
|
|
||||||
|
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<StructuredCvProfile?> TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken)
|
private async Task<StructuredCvProfile?> TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var structuredJson = await _aiService.SummarizeSectionAsync(
|
var structuredJson = await _aiService.SummarizeSectionAsync(
|
||||||
@@ -446,7 +522,10 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(extracted)) return null;
|
if (string.IsNullOrWhiteSpace(extracted)) return null;
|
||||||
|
|
||||||
var parsed = StructuredCvProfileJson.Deserialize(extracted);
|
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)
|
private static bool IsMeaningfullyStructured(StructuredCvProfile profile)
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ public static class StructuredCvProfileJson
|
|||||||
if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections;
|
if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections;
|
||||||
if (primary.Sections.Count == 0) primary.Sections = secondary.Sections;
|
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);
|
return Normalize(primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ export const translations = {
|
|||||||
profileCvStructureEmpty: "No parsed sections yet.",
|
profileCvStructureEmpty: "No parsed sections yet.",
|
||||||
profileCvStructuredEditor: "Structured CV editor",
|
profileCvStructuredEditor: "Structured CV editor",
|
||||||
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
|
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",
|
profileCvContactFullName: "Full name",
|
||||||
profileCvContactHeadline: "Professional headline",
|
profileCvContactHeadline: "Professional headline",
|
||||||
profileCvContactEmail: "Contact email",
|
profileCvContactEmail: "Contact email",
|
||||||
@@ -1102,6 +1111,15 @@ export const translations = {
|
|||||||
profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.",
|
profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.",
|
||||||
profileCvStructuredEditor: "Strukturert CV-redigering",
|
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.",
|
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",
|
profileCvContactFullName: "Fullt navn",
|
||||||
profileCvContactHeadline: "Profesjonell overskrift",
|
profileCvContactHeadline: "Profesjonell overskrift",
|
||||||
profileCvContactEmail: "Kontakt-e-post",
|
profileCvContactEmail: "Kontakt-e-post",
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { useToast } from "../toast";
|
|||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import {
|
import {
|
||||||
emptyStructuredCv,
|
emptyStructuredCv,
|
||||||
|
getStructuredCvFieldMetadata,
|
||||||
joinLines,
|
joinLines,
|
||||||
normalizeStructuredCv,
|
normalizeStructuredCv,
|
||||||
parseStructuredCvJson,
|
parseStructuredCvJson,
|
||||||
splitLines,
|
splitLines,
|
||||||
|
StructuredCvFieldMetadata,
|
||||||
StructuredCvProfile,
|
StructuredCvProfile,
|
||||||
} from "../profileCv";
|
} from "../profileCv";
|
||||||
|
|
||||||
@@ -23,6 +25,20 @@ import {
|
|||||||
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||||
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
|
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 = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
id?: string;
|
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();
|
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() {
|
export default function ProfilePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -105,13 +145,19 @@ export default function ProfilePage() {
|
|||||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||||
|
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||||
|
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
const loadProfile = useCallback(async () => {
|
||||||
try {
|
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);
|
setMe(r.data);
|
||||||
setEmail(r.data?.email ?? "");
|
setEmail(r.data?.email ?? "");
|
||||||
setUserName(r.data?.userName ?? "");
|
setUserName(r.data?.userName ?? "");
|
||||||
@@ -120,9 +166,11 @@ export default function ProfilePage() {
|
|||||||
setDisplayName(r.data?.displayName ?? "");
|
setDisplayName(r.data?.displayName ?? "");
|
||||||
setProfileCvText(r.data?.profileCvText ?? "");
|
setProfileCvText(r.data?.profileCvText ?? "");
|
||||||
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
|
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
|
||||||
|
setExtractionRuns(runsResponse.data ?? []);
|
||||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||||
} catch {
|
} catch {
|
||||||
setMe(null);
|
setMe(null);
|
||||||
|
setExtractionRuns([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -142,6 +190,7 @@ export default function ProfilePage() {
|
|||||||
: t("profileGoogleLinked")
|
: t("profileGoogleLinked")
|
||||||
: t("profileGoogleNotLinked");
|
: t("profileGoogleNotLinked");
|
||||||
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
||||||
|
const latestRun = extractionRuns[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||||
@@ -332,6 +381,24 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
|
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
|
||||||
</Button>
|
</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)}>
|
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||||
{t("profileCopyCvText")}
|
{t("profileCopyCvText")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -348,6 +415,41 @@ export default function ProfilePage() {
|
|||||||
disabled={!isLocal}
|
disabled={!isLocal}
|
||||||
fullWidth
|
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={{ 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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -400,16 +502,32 @@ export default function ProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
<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("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 />
|
<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 />
|
<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 />
|
<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 />
|
<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("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" } }} />
|
<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>
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
<TextField
|
<TextField
|
||||||
label={t("profileCvStructuredSummary")}
|
label={t("profileCvStructuredSummary")}
|
||||||
value={joinLines(structuredCv.summary)}
|
value={joinLines(structuredCv.summary)}
|
||||||
@@ -419,6 +537,9 @@ export default function ProfilePage() {
|
|||||||
minRows={5}
|
minRows={5}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "summary")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
<TextField
|
<TextField
|
||||||
label={t("profileCvStructuredSkills")}
|
label={t("profileCvStructuredSkills")}
|
||||||
value={joinLines(structuredCv.skills)}
|
value={joinLines(structuredCv.skills)}
|
||||||
@@ -428,6 +549,9 @@ export default function ProfilePage() {
|
|||||||
minRows={5}
|
minRows={5}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "skills")} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
<TextField
|
<TextField
|
||||||
label={t("profileCvStructuredInterests")}
|
label={t("profileCvStructuredInterests")}
|
||||||
value={joinLines(structuredCv.interests)}
|
value={joinLines(structuredCv.interests)}
|
||||||
@@ -437,6 +561,8 @@ export default function ProfilePage() {
|
|||||||
minRows={4}
|
minRows={4}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "interests")} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -444,6 +570,7 @@ export default function ProfilePage() {
|
|||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
<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>
|
<Button variant="outlined" size="small" onClick={() => setStructuredCv((prev) => ({ ...prev, languages: [...prev.languages, { name: "", level: "", notes: "" }] }))}>{t("profileCvStructuredAddLanguage")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
<FieldReviewNote metadata={getStructuredCvFieldMetadata(structuredCv, "languages")} />
|
||||||
{structuredCv.languages.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
{structuredCv.languages.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||||
{structuredCv.languages.map((language, index) => (
|
{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" }}>
|
<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 = {
|
const structuredCv = {
|
||||||
version: '1',
|
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: {
|
contact: {
|
||||||
fullName: 'Demo User',
|
fullName: 'Demo User',
|
||||||
headline: 'Backend Developer',
|
headline: 'Backend Developer',
|
||||||
@@ -80,6 +90,24 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} 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);
|
return Promise.resolve({ data: {} } as any);
|
||||||
});
|
});
|
||||||
mockedApi.post.mockImplementation((url: string) => {
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
@@ -97,6 +125,9 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
if (url === '/profile-cv/reprocess') {
|
||||||
|
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||||
|
}
|
||||||
return Promise.resolve({ data: {} } as any);
|
return Promise.resolve({ data: {} } as any);
|
||||||
});
|
});
|
||||||
mockedApi.put.mockResolvedValue({ 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(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
|
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/structured cv editor/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.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
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 });
|
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
|
||||||
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
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);
|
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 () => {
|
test('saving profile persists structured cv json', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ export type ParsedCvSection = {
|
|||||||
wordCount: number;
|
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 = {
|
export type StructuredCvContact = {
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
headline?: string;
|
headline?: string;
|
||||||
@@ -47,6 +64,7 @@ export type StructuredCvOtherSection = {
|
|||||||
|
|
||||||
export type StructuredCvProfile = {
|
export type StructuredCvProfile = {
|
||||||
version: string;
|
version: string;
|
||||||
|
metadata: StructuredCvMetadata;
|
||||||
contact: StructuredCvContact;
|
contact: StructuredCvContact;
|
||||||
summary: string[];
|
summary: string[];
|
||||||
jobs: StructuredCvJob[];
|
jobs: StructuredCvJob[];
|
||||||
@@ -72,6 +90,7 @@ export function joinLines(values: string[]) {
|
|||||||
export function emptyStructuredCv(): StructuredCvProfile {
|
export function emptyStructuredCv(): StructuredCvProfile {
|
||||||
return {
|
return {
|
||||||
version: "1",
|
version: "1",
|
||||||
|
metadata: { fields: {} },
|
||||||
contact: {},
|
contact: {},
|
||||||
summary: [],
|
summary: [],
|
||||||
jobs: [],
|
jobs: [],
|
||||||
@@ -135,6 +154,7 @@ function buildLegacyStructuredCv(sections: ParsedCvSection[]): StructuredCvProfi
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...emptyStructuredCv(),
|
...emptyStructuredCv(),
|
||||||
|
metadata: { fields: {} },
|
||||||
contact,
|
contact,
|
||||||
summary,
|
summary,
|
||||||
skills,
|
skills,
|
||||||
@@ -153,6 +173,22 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
|||||||
const sections = normalizeParsedSections(source.sections);
|
const sections = normalizeParsedSections(source.sections);
|
||||||
const normalized: StructuredCvProfile = {
|
const normalized: StructuredCvProfile = {
|
||||||
version: normalizeString(source.version) ?? "1",
|
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: {
|
contact: {
|
||||||
fullName: normalizeString(source.contact?.fullName),
|
fullName: normalizeString(source.contact?.fullName),
|
||||||
headline: normalizeString(source.contact?.headline),
|
headline: normalizeString(source.contact?.headline),
|
||||||
@@ -221,3 +257,7 @@ export function parseStructuredCvJson(value?: string): StructuredCvProfile {
|
|||||||
return emptyStructuredCv();
|
return emptyStructuredCv();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStructuredCvFieldMetadata(profile: StructuredCvProfile, key: string): StructuredCvFieldMetadata | undefined {
|
||||||
|
return profile.metadata?.fields?.[key];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user