using System.Text.RegularExpressions; using JobTrackerApi.Models; namespace JobTrackerApi.Services; // Structural evidence is interpreted before any profile is proposed; ambiguous source stays reviewable. public static class CvDocumentInterpreter { private static readonly Dictionary Headings = new(StringComparer.OrdinalIgnoreCase) { ["contact"] = "contact", ["kontakt"] = "contact", ["contact details"] = "contact", ["professional summary"] = "summary", ["summary"] = "summary", ["profile"] = "summary", ["profil"] = "summary", ["sammendrag"] = "summary", ["work experience"] = "jobs", ["experience"] = "jobs", ["employment history"] = "jobs", ["professional experience"] = "jobs", ["arbeidserfaring"] = "jobs", ["yrkeserfaring"] = "jobs", ["erfaring"] = "jobs", ["education"] = "education", ["utdanning"] = "education", ["qualifications"] = "education", ["skills"] = "skills", ["technical skills"] = "skills", ["core skills"] = "skills", ["ferdigheter"] = "skills", ["kompetanse"] = "skills", ["tekniske ferdigheter"] = "skills", ["languages"] = "languages", ["språk"] = "languages", ["certifications"] = "certifications", ["sertifiseringer"] = "certifications", ["projects"] = "projects", ["prosjekter"] = "projects", ["interests"] = "interests", ["interesser"] = "interests", ["awards"] = "awards", ["publications"] = "publications", ["organisations"] = "organisations", ["organizations"] = "organisations", ["references"] = "references", ["referanser"] = "references", ["courses"] = "Courses", ["kurs"] = "Kurs", ["volunteer experience"] = "Volunteer Experience", ["notes"] = "Notes", }; public static StructuredCvProfile Parse(IReadOnlyList blocks) { var profile = new StructuredCvProfile(); var section = "contact"; string? sectionTitle = null; SourceLine? sectionHeading = null; StructuredCvOtherSection? unknown = null; PendingRecord? pending = null; var projectNamed = false; void Evidence(string path, string value, SourceLine source, string method = "deterministic-source", string reviewState = "suggested") => profile.Metadata.Fields[path] = new() { SourceBlockId = source.Block.Id, SourcePage = source.Block.Page, SourceSnippet = value, Method = method, ReviewState = reviewState }; void Unknown(SourceLine source) { if (unknown is null) { unknown = new() { Title = sectionTitle }; profile.OtherSections.Add(unknown); if (sectionHeading is not null) Evidence($"otherSections.{profile.OtherSections.Count - 1}.title", sectionTitle!, sectionHeading); } if (unknown.Items.Contains(source.Text, StringComparer.OrdinalIgnoreCase)) return; Evidence($"otherSections.{profile.OtherSections.Count - 1}.items.{unknown.Items.Count}", source.Text, source); unknown.Items.Add(source.Text); } void Flush(bool acceptLegacyParagraph = false) { if (pending is null) return; var paragraph = pending.Paragraph; pending.Paragraph = null; if (acceptLegacyParagraph && paragraph is not null && pending.Resolved) { Set(pending.Category == "education" ? "details" : "bullets", paragraph.Text, paragraph, legacyParagraph: true); paragraph = null; } if (!pending.Resolved) { foreach (var source in pending.Source) Unknown(source); pending = null; return; } string? Value(string key) => pending.Fields.TryGetValue(key, out var field) ? field.Value : null; List List(string key) => pending.Lists.TryGetValue(key, out var list) ? list.Select(f => f.Value).ToList() : new(); var index = pending.Category switch { "jobs" => profile.Jobs.Count, "education" => profile.Education.Count, "projects" => profile.Projects.Count, _ => profile.Certifications.Count }; var prefix = $"{pending.Category}.{index}"; switch (pending.Category) { case "jobs": profile.Jobs.Add(new() { Title = Value("title"), Company = Value("company"), Location = Value("location"), Start = Value("start"), End = Value("end"), IsCurrent = IsCurrent(Value("end")), Bullets = List("bullets"), Skills = List("skills") }); break; case "education": profile.Education.Add(new() { Qualification = Value("qualification"), Institution = Value("institution"), QualificationLevel = Value("qualificationLevel"), Location = Value("location"), Start = Value("start"), End = Value("end"), Details = List("details") }); break; case "projects": profile.Projects.Add(new() { Name = Value("name"), Role = Value("role"), Location = Value("location"), Start = Value("start"), End = Value("end"), Bullets = List("bullets"), Skills = List("skills") }); break; case "certifications": profile.Certifications.Add(new() { Name = Value("name"), Issuer = Value("issuer"), Location = Value("location"), Date = Value("date"), Details = List("details") }); break; } foreach (var field in pending.Fields) Evidence(prefix + "." + field.Key, field.Value.Value, field.Value.Source); foreach (var list in pending.Lists) for (var i = 0; i < list.Value.Count; i++) { var value = list.Value[i]; Evidence($"{prefix}.{list.Key}.{i}", value.Value, value.Source, value.LegacyParagraph ? "legacy-paragraph-continuation" : "deterministic-source", value.LegacyParagraph ? "needs_review" : "suggested"); } if (pending.Category == "jobs" && IsCurrent(Value("end"))) Evidence(prefix + ".isCurrent", Value("end")!, pending.Fields["end"].Source, "normalized-current-marker"); pending = null; unknown = null; if (paragraph is not null) Unknown(paragraph); } void Set(string field, string value, SourceLine source, bool legacyParagraph = false) { pending ??= new(section); if (section == "projects" && field == "name") projectNamed = true; if (!pending.Source.Contains(source)) pending.Source.Add(source); if (field is "bullets" or "details" or "skills") { if (!pending.Lists.TryGetValue(field, out var list)) pending.Lists[field] = list = new(); var values = field == "skills" ? Regex.Split(value, @"\s*[,;•|]\s*") : new[] { value }; foreach (var item in values.Where(v => v.Length > 0)) if (!list.Any(v => string.Equals(v.Value, item, StringComparison.OrdinalIgnoreCase))) list.Add(new(item, source, legacyParagraph)); } else if (field == "dates") { var (start, end) = Dates(value); if (start is not null) pending.Fields["start"] = new(start, source); if (end is not null) pending.Fields["end"] = new(end, source); } else pending.Fields[field] = new(value, source); } void BeginSection(string category, string? title, SourceLine? heading) { Flush(acceptLegacyParagraph: true); section = category; sectionTitle = title; sectionHeading = heading; unknown = null; projectNamed = false; } foreach (var block in blocks) { var lines = block.Text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); string[]? columns = null; var tableHasHeader = block.Kind == "table" && lines.Length > 1 && IsTableSeparator(lines[1]); var unknownTableHeader = false; for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) { var source = new SourceLine(block, lineIndex, lines[lineIndex].Trim()); var line = source.Text.TrimStart('#').Trim(); var explicitBullet = Regex.IsMatch(line, @"^(?:[-*•]\s+)"); if (explicitBullet) line = Regex.Replace(line, @"^[-*•]\s+", ""); if (IsTableSeparator(line)) continue; var parts = line.Trim('|').Split('|', StringSplitOptions.TrimEntries); if (tableHasHeader && lineIndex == 0 && IsRecordSection(section)) { columns = parts.Select(p => Field(section, p)).ToArray(); var identity = Identity(section); unknownTableHeader = !identity.All(columns.Contains) || columns.Any(c => c.Length == 0) || columns.Distinct().Count() != columns.Length; if (unknownTableHeader) { Flush(); Unknown(source); } continue; } if (unknownTableHeader) { Unknown(source); continue; } if (columns is not null) { Flush(acceptLegacyParagraph: parts.Length == columns.Length); if (parts.Length != columns.Length) { Unknown(source); continue; } for (var i = 0; i < parts.Length; i++) if (parts[i].Length > 0) Set(columns[i], parts[i], source); Flush(); continue; } var heading = line.TrimEnd(':'); if (!explicitBullet && Headings.TryGetValue(heading, out var named)) { BeginSection(named, heading, source); continue; } var labelled = Regex.Match(line, @"^([^:]{1,40}):\s*(.+)$"); var label = labelled.Success ? labelled.Groups[1].Value.Trim().ToLowerInvariant() : ""; if (section == "contact" && label is "employer" or "company" or "arbeidsgiver" or "job title" or "stilling") BeginSection("jobs", null, null); if (section == "contact" && label is "institution" or "institusjon" or "qualification") BeginSection("education", null, null); if (IsRecordSection(section) && labelled.Success && Field(section, label) is { Length: > 0 } field) { if (pending?.Paragraph is not null || pending is { Category: "certifications", CertificateInput: not CertificateShape.Explicit }) Flush(); // Repeated identity labels begin another occurrence, even when the words are identical. if (pending is not null && ((Identity(section).Contains(field) && (pending.Resolved || pending.Fields.ContainsKey(field))) || (field == "dates" && (pending.Fields.ContainsKey("start") || pending.Fields.ContainsKey("end"))) || pending.Fields.ContainsKey(field))) Flush(); Set(field, labelled.Groups[2].Value.Trim(), source); continue; } if (block.Kind == "section_header" && !explicitBullet) { BeginSection("custom", heading, source); continue; } if (IsRecordSection(section)) { if (section == "certifications" && (block.Kind == "list_item" || explicitBullet) && (pending is null || pending.CertificateInput != CertificateShape.Explicit)) { Flush(); Set("name", line, source); pending!.CertificateInput = CertificateShape.ListItem; continue; } if (pending?.Paragraph is not null && (labelled.Success || IsDateOnly(line) || block.Kind == "list_item" || explicitBullet)) Flush(); if ((block.Kind == "list_item" || explicitBullet) && pending?.Resolved == true) { Set(section is "education" or "certifications" ? "details" : "bullets", line, source); continue; } if (!explicitBullet && block.Kind != "list_item" && parts.Length >= 2 && parts.All(p => p.Length > 0)) { var keys = section switch { "jobs" => new[] { "title", "company", "dates" }, "education" => new[] { "qualification", "institution", "dates" }, "projects" => new[] { "name", "role", "dates" }, _ => new[] { "name", "issuer", "date" } }; Flush(acceptLegacyParagraph: parts.Length <= keys.Length); if (parts.Length > keys.Length) { Unknown(source); continue; } for (var i = 0; i < parts.Length; i++) Set(keys[i], parts[i], source); pending!.LegacyParagraphContext = section is "jobs" or "education" or "projects"; continue; } if (IsDateOnly(line) && pending?.Resolved == true && section != "certifications" && !pending.Fields.ContainsKey("start") && !pending.Fields.ContainsKey("end")) { Set("dates", line, source); continue; } // Legacy pipe records allow one deferred paragraph; competing lines remain unresolved. if (pending?.Resolved == true && pending.LegacyParagraphContext && block.Kind == "paragraph" && !explicitBullet && !labelled.Success && !IsDateOnly(line)) { if (pending.Paragraph is null) pending.Paragraph = source; else { Flush(); Unknown(source); } continue; } if (section == "certifications" && !explicitBullet && block.Kind == "paragraph" && !labelled.Success && pending is { CertificateInput: CertificateShape.SingleParagraph or CertificateShape.AmbiguousParagraphs }) { // Native PDF table cells can look like names; retain the whole unlabelled cluster. pending.Source.Add(source); pending.CertificateInput = CertificateShape.AmbiguousParagraphs; continue; } Flush(); if (section == "projects" && (block.Kind == "title" || !projectNamed)) { Set("name", line, source); pending!.LegacyParagraphContext = true; } else if (section == "certifications" && !explicitBullet && block.Kind != "list_item" && !labelled.Success && !IsDateOnly(line)) { Set("name", line, source); pending!.CertificateInput = CertificateShape.SingleParagraph; } else Unknown(source); continue; } var contact = Regex.Match(line, @"^(name|navn|full name|email|e-post|phone|telefon|location|sted|headline|title|website|nettside|linkedin|github|portfolio)\s*:\s*(.+)$", RegexOptions.IgnoreCase); if (section == "contact" && contact.Success) { var value = contact.Groups[2].Value.Trim(); var contactField = contact.Groups[1].Value.ToLowerInvariant(); switch (contactField) { case "name": case "navn": case "full name": profile.Contact.FullName = value; break; case "email": case "e-post": profile.Contact.Email = value; break; case "phone": case "telefon": profile.Contact.Phone = value; break; case "location": case "sted": profile.Contact.Location = value; break; case "headline": case "title": profile.Contact.Headline = value; break; case "website": case "nettside": profile.Contact.Website = value; break; case "linkedin": profile.Contact.LinkedIn = value; break; case "github": profile.Contact.GitHub = value; break; case "portfolio": profile.Contact.Links.Add(new() { Label = contact.Groups[1].Value, Url = value }); break; } var key = contactField switch { "name" or "navn" or "full name" => "fullName", "e-post" => "email", "telefon" => "phone", "sted" => "location", "title" => "headline", "nettside" => "website", "linkedin" => "linkedIn", "github" => "gitHub", _ => contactField }; if (key != "portfolio") Evidence("contact." + key, value, source); else { Evidence($"contact.links.{profile.Contact.Links.Count - 1}.label", contact.Groups[1].Value, source); Evidence($"contact.links.{profile.Contact.Links.Count - 1}.url", value, source); } continue; } switch (section) { case "contact": var email = Regex.Match(line, @"[\w.+-]+@[\w.-]+\.[\p{L}]{2,}"); if (email.Success && profile.Contact.Email is null) { profile.Contact.Email = email.Value; Evidence("contact.email", email.Value, source); } else if (block.Kind == "title" && profile.Contact.FullName is null) { profile.Contact.FullName = line; Evidence("contact.fullName", line, source); } else Unknown(source); break; case "summary": Add(profile.Summary, "summary", line, source); break; case "skills": foreach (var skill in Regex.Split(line, @"\s*[,;•|]\s*").Where(s => s.Length > 0)) Add(profile.Skills, "skills", skill, source); break; case "languages": var language = line.Split(':', 2, StringSplitOptions.TrimEntries); var languageIndex = profile.Languages.Count; profile.Languages.Add(new() { Name = language[0], Level = language.Length == 2 ? language[1] : null }); Evidence($"languages.{languageIndex}.name", language[0], source); if (language.Length == 2) Evidence($"languages.{languageIndex}.level", language[1], source); break; case "interests": Add(profile.Interests, "interests", line, source); break; case "awards": Add(profile.Awards, "awards", line, source); break; case "publications": Add(profile.Publications, "publications", line, source); break; case "organisations": Add(profile.Organisations, "organisations", line, source); break; case "references": Add(profile.References, "references", line, source); break; default: Unknown(source); break; } } } Flush(acceptLegacyParagraph: true); return profile; void Add(List values, string path, string value, SourceLine source) { if (values.Contains(value, StringComparer.OrdinalIgnoreCase)) return; Evidence($"{path}.{values.Count}", value, source); values.Add(value); } } private sealed record SourceLine(CvImportBlock Block, int Line, string Text); private sealed record SourceValue(string Value, SourceLine Source, bool LegacyParagraph = false); private enum CertificateShape { Explicit, SingleParagraph, AmbiguousParagraphs, ListItem } private sealed class PendingRecord(string category) { public string Category { get; } = category; public Dictionary Fields { get; } = new(); public Dictionary> Lists { get; } = new(); public List Source { get; } = new(); public bool LegacyParagraphContext { get; set; } public SourceLine? Paragraph { get; set; } public CertificateShape CertificateInput { get; set; } public bool Resolved => CertificateInput != CertificateShape.AmbiguousParagraphs && Identity(Category).All(Fields.ContainsKey); } private static bool IsRecordSection(string section) => section is "jobs" or "education" or "projects" or "certifications"; private static string[] Identity(string section) => section switch { "jobs" => new[] { "title", "company" }, "education" => new[] { "qualification", "institution" }, _ => new[] { "name" } }; private static bool IsTableSeparator(string text) => Regex.IsMatch(text, @"^[|:\s-]+$"); private static bool IsCurrent(string? text) => text is not null && Regex.IsMatch(text, @"^(present|current|now|nå|dags dato|pågående)$", RegexOptions.IgnoreCase); private static string Field(string section, string label) { label = label.Trim().ToLowerInvariant(); if (label is "location" or "sted") return "location"; if (label is "dates" or "date range" or "period" or "datoer" or "periode") return section == "certifications" ? "date" : "dates"; if (label is "date" or "dato") return section == "certifications" ? "date" : "dates"; if (label is "start" or "start date" or "fra") return section == "certifications" ? "" : "start"; if (label is "end" or "end date" or "til") return section == "certifications" ? "" : "end"; if (label is "description" or "details" or "beskrivelse" or "specialism" or "specialization" or "spesialisering") return section is "education" or "certifications" ? "details" : "bullets"; if (section is "jobs" or "projects" && label is "technologies" or "skills" or "teknologier" or "ferdigheter") return "skills"; return (section, label) switch { ("jobs", "title" or "job title" or "role" or "stilling") => "title", ("jobs", "company" or "employer" or "arbeidsgiver") => "company", ("education", "qualification" or "degree" or "kvalifikasjon" or "grad") => "qualification", ("education", "institution" or "school" or "institusjon" or "lærested") => "institution", ("education", "qualification level" or "kvalifikasjonsnivå") => "qualificationLevel", ("projects", "project" or "project name" or "name" or "prosjekt") => "name", ("projects", "role" or "rolle") => "role", ("certifications", "certificate" or "certification" or "name" or "sertifisering") => "name", ("certifications", "issuer" or "utsteder") => "issuer", _ => "" }; } private static bool IsDateOnly(string text) { const string month = @"(?:jan(?:uary|uar)?|feb(?:ruary|ruar)?|mar(?:ch|s)?|apr(?:il)?|may|mai|jun(?:e|i)?|jul(?:y|i)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|okt(?:ober)?|nov(?:ember)?|dec(?:ember)?|des(?:ember)?)"; var token = @"(?:\d{4}(?:-\d{2}(?:-\d{2})?)?|\d{1,2}[./]\d{1,2}[./]\d{4}|(?:\d{1,2}\s+)?" + month + @"\.?\s+\d{4})"; var (start, end) = Dates(text); return start is not null && Regex.IsMatch(start, "^" + token + "$", RegexOptions.IgnoreCase) && (end is null || IsCurrent(end) || Regex.IsMatch(end, "^" + token + "$", RegexOptions.IgnoreCase)); } private static (string? Start, string? End) Dates(string? text) { if (string.IsNullOrWhiteSpace(text)) return (null, null); var dates = Regex.Split(text, @"\s+(?:-|–|—|to|til)\s+|(?<=\d{4})[–—](?=\d{4})", RegexOptions.IgnoreCase); return dates.Length == 2 ? (dates[0].Trim(), dates[1].Trim()) : (text.Trim(), null); } }