fix(cv-qa): preserve ambiguous structures and measure extraction accuracy

This commit is contained in:
cesnimda
2026-10-27 00:26:40 +01:00
parent 5e68848cf7
commit 48b899c8de
13 changed files with 8698 additions and 53 deletions
+282 -50
View File
@@ -23,28 +23,215 @@ public static class CvDocumentInterpreter
{
var profile = new StructuredCvProfile();
var section = "contact";
var sectionTitle = "Contact";
var unassigned = new List<string>();
void Unknown(string line)
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)
{
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);
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);
}
foreach (var block in blocks)
foreach (var source in block.Text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
void Flush(bool acceptLegacyParagraph = false)
{
var line = source.Trim().TrimStart('#', '•').Trim();
if (line.StartsWith('|')) line = line.Trim('|').Trim();
if (Regex.IsMatch(line, @"^[|:\s-]+$")) continue;
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 (Headings.TryGetValue(heading, out var named)) { section = named; sectionTitle = heading; continue; }
if (block.Kind == "section_header") { section = "custom"; sectionTitle = heading; continue; }
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();
switch (contact.Groups[1].Value.ToLowerInvariant())
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;
@@ -56,55 +243,100 @@ public static class CvDocumentInterpreter
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;
}
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);
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": profile.Summary.Add(line); break;
case "skills": profile.Skills.AddRange(Regex.Split(line, @"\s*[,;•|]\s*").Where(s => s.Length > 0)); 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);
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);
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 "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;
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;
}
}
}
if (unassigned.Count > 0) profile.OtherSections.Add(new() { Title = "Unclassified source", Items = unassigned });
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)
+3 -2
View File
@@ -104,7 +104,8 @@ public static class CvDocumentReview
if (!scalar.TryGetValue<string>(out var text) || string.IsNullOrWhiteSpace(text)) return;
var block = Evidence(path, text);
if (block is null) { if (value.Parent is JsonObject parent) parent[path.Split('.').Last()] = null; return; }
metadata[path] = new() { Method = "deterministic-source", SourceBlockId = block.Id, SourcePage = block.Page, SourceSnippet = text.Length > 500 ? text[..500] : text, ReviewState = "suggested" };
var groupingNeedsReview = selected.TryGetValue(path, out var selectedEvidence) && selectedEvidence.Method == "legacy-paragraph-continuation";
metadata[path] = new() { Method = groupingNeedsReview ? "legacy-paragraph-continuation" : "deterministic-source", SourceBlockId = block.Id, SourcePage = block.Page, SourceSnippet = text.Length > 500 ? text[..500] : text, ReviewState = groupingNeedsReview ? "needs_review" : "suggested" };
}
}
Walk(node, "");
@@ -165,7 +166,7 @@ public static class CvDocumentReview
var label = Regex.Replace(path.Split('.').Last(), "([a-z])([A-Z])", "$1 $2");
if (int.TryParse(label, out _)) label = "Item";
label = char.ToUpperInvariant(label[0]) + label[1..];
fields.Add(new(path, label, previous?.ToString(), text, source?.SourceSnippet, source?.SourcePage, source?.Method ?? "deterministic-source", "suggested"));
fields.Add(new(path, label, previous?.ToString(), text, source?.SourceSnippet, source?.SourcePage, source?.Method ?? "deterministic-source", source?.ReviewState ?? "suggested"));
}
}
Walk(item, existing, id);