349 lines
23 KiB
C#
349 lines
23 KiB
C#
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);
|
||
}
|
||
}
|