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"; var sectionTitle = "Contact"; var unassigned = new List(); void Unknown(string line) { var other = profile.OtherSections.FirstOrDefault(s => s.Title == sectionTitle); if (other is null) { other = new() { Title = sectionTitle }; profile.OtherSections.Add(other); } other.Items.Add(line); } foreach (var block in blocks) foreach (var source in block.Text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { var line = source.Trim().TrimStart('#', '•').Trim(); if (line.StartsWith('|')) line = line.Trim('|').Trim(); if (Regex.IsMatch(line, @"^[|:\s-]+$")) continue; var heading = line.TrimEnd(':'); if (Headings.TryGetValue(heading, out var named)) { section = named; sectionTitle = heading; continue; } if (block.Kind == "section_header") { section = "custom"; sectionTitle = heading; 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(); switch (contact.Groups[1].Value.ToLowerInvariant()) { 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; } continue; } var parts = line.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); switch (section) { case "contact": var email = Regex.Match(line, @"[\w.+-]+@[\w.-]+\.[\p{L}]{2,}"); if (email.Success) profile.Contact.Email ??= email.Value; else if (block.Kind == "title" && profile.Contact.FullName is null) profile.Contact.FullName = line; else unassigned.Add(line); break; case "summary": profile.Summary.Add(line); break; case "skills": profile.Skills.AddRange(Regex.Split(line, @"\s*[,;•|]\s*").Where(s => s.Length > 0)); break; case "languages": var language = line.Split(':', 2, StringSplitOptions.TrimEntries); profile.Languages.Add(new() { Name = language[0], Level = language.Length == 2 ? language[1] : null }); break; case "jobs": if (parts.Length >= 2) { var (start, end) = Dates(parts.ElementAtOrDefault(2)); profile.Jobs.Add(new() { Title = parts[0], Company = parts[1], Start = start, End = end, IsCurrent = end is not null && Regex.IsMatch(end, @"^(present|current|now|nå|dags dato|pågående)$", RegexOptions.IgnoreCase) }); } else if (profile.Jobs.Count > 0) profile.Jobs[^1].Bullets.Add(line.TrimStart('-', ' ')); else Unknown(line); break; case "education": if (parts.Length >= 2) { var (start, end) = Dates(parts.ElementAtOrDefault(2)); profile.Education.Add(new() { Qualification = parts[0], Institution = parts[1], Start = start, End = end }); } else if (profile.Education.Count > 0) profile.Education[^1].Details.Add(line); else Unknown(line); break; case "certifications": profile.Certifications.Add(new() { Name = parts[0], Issuer = parts.ElementAtOrDefault(1), Date = parts.ElementAtOrDefault(2) }); break; case "projects": if (profile.Projects.Count == 0 || parts.Length > 1 || block.Kind == "title") profile.Projects.Add(new() { Name = parts[0], Role = parts.ElementAtOrDefault(1) }); else profile.Projects[^1].Bullets.Add(line); break; case "interests": profile.Interests.Add(line); break; case "awards": profile.Awards.Add(line); break; case "publications": profile.Publications.Add(line); break; case "organisations": profile.Organisations.Add(line); break; case "references": profile.References.Add(line); break; default: Unknown(line); break; } } if (unassigned.Count > 0) profile.OtherSections.Add(new() { Title = "Unclassified source", Items = unassigned }); return profile; } 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); } }