Files
jobtrackingapp/JobTrackerApi/Services/CvDocumentInterpreter.cs
T

349 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> 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<CvImportBlock> 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<string> 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<string> 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<string, SourceValue> Fields { get; } = new();
public Dictionary<string, List<SourceValue>> Lists { get; } = new();
public List<SourceLine> 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);
}
}