feat(cv): extend templates and extraction

This commit is contained in:
cesnimda
2026-08-27 15:27:48 +02:00
parent cc76f86482
commit ccd0af908c
15 changed files with 586 additions and 54 deletions
@@ -59,6 +59,7 @@ public sealed class CvVariantController : ControllerBase
requiresPro = t.Premium,
available = proThemes || !t.Premium,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
supportedSettings = t.SupportedSettings,
});
return Ok(themes);
}
@@ -1,7 +1,9 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using JobTrackerApi.Models;
@@ -256,11 +258,28 @@ public sealed partial class ProfileCvController : ControllerBase
}
var skills = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var skill in ExtractSkillsHeuristically(rawSource))
var skillSections = sections.Where(section => section.Name == "Skills").ToList();
var skillsSource = string.Join("\n", skillSections.Select(section => section.Content));
if (string.IsNullOrWhiteSpace(skillsSource)) skillsSource = rawSource;
foreach (var skill in ExtractSkillsHeuristically(skillsSource))
{
skills.Add(skill);
}
profile.Skills = skills.ToList();
// Once a heading has positively bounded a Skills section, its list items are stronger
// evidence than the conservative whole-document vocabulary. Preserve domain-specific items
// without mining arbitrary prose as skills.
if (skillSections.Count > 0)
{
foreach (var item in SplitListLike(skillsSource))
{
var cleaned = CleanSkillGroupPrefix(item);
if (cleaned.Length is >= 2 and <= 80 && cleaned.Any(char.IsLetter)) skills.Add(cleaned);
}
}
profile.Skills = skills.Select(CleanSkillGroupPrefix)
.Where(skill => skill.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var educationSection = sections.FirstOrDefault(section => section.Name == "Education");
if (!string.IsNullOrWhiteSpace(educationSection.Content))
@@ -290,6 +309,20 @@ public sealed partial class ProfileCvController : ControllerBase
profile.Jobs = ParseJobsHeuristically(normalized);
}
var handledSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"General", "Contact", "Professional Summary", "Skills", "Work Experience", "Education",
"Projects", "Certifications", "Languages", "Interests", "Awards", "Publications",
"Organisations", "References", "Selected Achievements",
};
foreach (var section in sections.Where(section => !handledSections.Contains(section.Name)))
{
var items = SplitListLike(section.Content);
if (items.Count == 0 && !string.IsNullOrWhiteSpace(section.Content)) items.Add(section.Content.Trim());
if (items.Count > 0 && !profile.OtherSections.Any(existing => string.Equals(existing.Title, section.Name, StringComparison.OrdinalIgnoreCase)))
profile.OtherSections.Add(new StructuredCvOtherSection { Title = section.Name, Items = items });
}
if (profile.OtherSections.Count == 0 && sections.Any(section => section.Name == "General"))
{
var general = sections.First(section => section.Name == "General");
@@ -402,6 +435,8 @@ public sealed partial class ProfileCvController : ControllerBase
{
foreach (Match match in Regex.Matches(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase))
{
if ((match.Index > 0 && rawSource[match.Index - 1] == '@')
|| (match.Index + match.Length < rawSource.Length && rawSource[match.Index + match.Length] == '@')) continue;
var candidate = NormalizeDetectedWebsite(match.Value, email);
if (candidate is null) continue;
if (candidate.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) continue;
@@ -427,9 +462,12 @@ public sealed partial class ProfileCvController : ControllerBase
var lines = source.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var rawLine in lines.Take(10))
{
var line = Regex.Replace(rawLine, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ',');
if (!IsPlausibleLocationValue(line, fullName)) continue;
return line;
foreach (var segment in Regex.Split(rawLine, @"\s*(?:[•·|]|\s{2,})\s*").Where(value => !string.IsNullOrWhiteSpace(value)))
{
var line = Regex.Replace(segment, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ',');
if (!IsPlausibleLocationValue(line, fullName)) continue;
return line;
}
}
return IsPlausibleLocationValue(normalizedFallback, fullName) ? normalizedFallback : null;
@@ -439,6 +477,7 @@ public sealed partial class ProfileCvController : ControllerBase
{
var candidate = NullIfWhitespace(value);
if (candidate is null) return false;
if (CanonicalizeSectionHeading(candidate) is not null) return false;
if (LooksLikeRoleOrHeadline(candidate)) return false;
if (!string.IsNullOrWhiteSpace(fullName))
{
@@ -457,15 +496,15 @@ public sealed partial class ProfileCvController : ControllerBase
var normalized = Regex.Replace(candidate, @"\s+", " ").Trim(' ', ',');
if (normalized.Length > 80) return false;
if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+,\s*[A-Z][A-Za-z.' -]+(?:,\s*[A-Z][A-Za-z.' -]+)?$")) return true;
if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+(?:\s+[A-Z][A-Za-z.' -]+){0,2}$") && !LooksLikeRoleOrHeadline(normalized)) return true;
if (Regex.IsMatch(normalized, @"^\p{Lu}[\p{L}.' -]+,\s*\p{Lu}[\p{L}.' -]+(?:,\s*\p{Lu}[\p{L}.' -]+)?$")) return true;
if (Regex.IsMatch(normalized, @"^\p{Lu}[\p{L}.' -]+(?:\s+\p{Lu}[\p{L}.' -]+){0,2}$") && !LooksLikeRoleOrHeadline(normalized)) return true;
return false;
}
private static bool LooksLikeRoleOrHeadline(string value)
{
return Regex.IsMatch(value, @"\b(real estate agent|developer|engineer|manager|consultant|specialist|analyst|designer|technician|administrator|architect|director|coordinator|assistant|lead|owner|founder|recruiter|teacher|writer|producer|officer|supervisor|sales)\b", RegexOptions.IgnoreCase);
return Regex.IsMatch(value, @"\b(real estate agent|developer|development|engineer|manager|consultant|specialist|analyst|designer|technician|administrator|architect|director|coordinator|assistant|lead|owner|founder|recruiter|teacher|writer|producer|officer|supervisor|sales|competencies)\b", RegexOptions.IgnoreCase);
}
private static bool LooksLikePersonName(string value)
@@ -527,6 +566,23 @@ public sealed partial class ProfileCvController : ControllerBase
private static List<StructuredCvLanguage> ParseLanguagesHeuristically(string content)
{
var languages = new List<StructuredCvLanguage>();
foreach (var line in content.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var paired = Regex.Split(line, @"\s*(?:[•·|]|[—–](?!\d))\s*")
.Select(item => item.Trim())
.Where(item => item.Length > 0)
.ToList();
if (paired.Count < 2) continue;
for (var index = 0; index + 1 < paired.Count; index += 2)
{
var level = HumanLanguageCatalog.ExtractLevel(paired[index + 1]);
if (level is null) continue;
foreach (var name in HumanLanguageCatalog.ExtractLanguageNames(paired[index]))
languages.Add(new StructuredCvLanguage { Name = name, Level = level });
}
}
var candidates = Regex.Split(content.Replace("\r\n", "\n"), @"[\n,;]+|(?<=[.!?])\s+")
.Select(item => item.Trim())
.Where(item => item.Length > 1);
@@ -551,6 +607,12 @@ public sealed partial class ProfileCvController : ControllerBase
private static List<StructuredCvEducation> ParseEducationHeuristically(string content)
{
var normalized = content.Replace("\r\n", "\n").Trim();
var direct = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Education", Content = normalized } }).Education;
if (direct.Count == 1 && direct.Any(item => !string.IsNullOrWhiteSpace(item.Institution)
|| !string.IsNullOrWhiteSpace(item.Start) || !string.IsNullOrWhiteSpace(item.End)))
{
return direct;
}
var blocks = Regex.Split(normalized, @"\n\s*\n|(?=###\s+)|(?=(?:Bachelor|Master|Doctor|Associate|Diploma|Certificate|BSc|BA|MSc|MA|PhD)\b)", RegexOptions.IgnoreCase)
.Select(block => block.Trim())
.Where(block => block.Length > 0)
@@ -794,11 +856,11 @@ public sealed partial class ProfileCvController : ControllerBase
foreach (var raw in lines)
{
var line = raw.Trim();
var canonicalHeading = CanonicalizeSectionHeading(line);
if (canonicalHeading is not null)
var sectionHeading = CanonicalizeSectionHeading(line) ?? DetectCustomSectionHeading(line);
if (sectionHeading is not null)
{
Flush();
currentName = canonicalHeading;
currentName = sectionHeading;
continue;
}
@@ -1220,7 +1282,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
private static string CleanSkillGroupPrefix(string skill)
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
=> Regex.Replace(skill.Trim(), @"^(?:Development|Programming Languages?|Backend|Frontend|Databases?|DevOps(?:\s*(?:&|og)\s*(?:CI/CD|Infrastructure))?|Infrastructure|Operations|Practices|Tools|Technologies|Technical Skills|Programmeringsspråk|Databaser|Infrastruktur|Drift|Praksis|Metodikk)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
private static string SeparateGluedDateAndTitle(string text)
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
@@ -1258,13 +1320,30 @@ public sealed partial class ProfileCvController : ControllerBase
normalized = normalized.TrimStart('#').Trim();
}
normalized = normalized.TrimEnd(':').Trim();
normalized = Regex.Replace(normalized, @"^\s*\d{1,2}\s*(?:[-–—.:)]\s*)+", string.Empty).Trim();
normalized = Regex.Replace(normalized, @"\s+", " ").TrimEnd(':').Trim();
if (normalized.Length == 0 || normalized.Length > 60) return null;
if (normalized.Contains('.') || normalized.Contains(" ")) return null;
return SectionAliases.TryGetValue(normalized, out var canonical) ? canonical : null;
}
private static string? DetectCustomSectionHeading(string line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var markdown = line.TrimStart().StartsWith("#", StringComparison.Ordinal);
var candidate = line.Trim().TrimStart('#').Trim();
candidate = Regex.Replace(candidate, @"^\s*\d{1,2}\s*(?:[-–—.:)]\s*)+", string.Empty).Trim().TrimEnd(':').Trim();
if (candidate.Length is < 3 or > 60 || candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 7) return null;
if (Regex.IsMatch(candidate, @"\d{4}|@|https?://|www\.|^[•+*-]")) return null;
var letters = candidate.Where(char.IsLetter).ToArray();
// A lone all-caps token is commonly a skill (SQL, AWS, DOCKER), not a new section.
// Unknown single-word headings remain available through explicit Markdown headings.
var upperHeading = letters.Length >= 3 && letters.All(char.IsUpper) && candidate.Contains(' ');
if (!markdown && !upperHeading) return null;
return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(candidate.ToLowerInvariant());
}
private static bool HasRecoverableSectionSignals(string text)
{
var sections = ParseSections(text);
@@ -1310,9 +1389,39 @@ public sealed partial class ProfileCvController : ControllerBase
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream, Encoding.UTF8);
var xml = await reader.ReadToEndAsync();
var withoutTags = Regex.Replace(xml, "<[^>]+>", " ");
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
return Regex.Replace(decoded, @"\s+", " ").Trim();
var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
var body = document.Root?.Element(word + "body");
if (body is null) return string.Empty;
static string Text(XElement element, XNamespace ns) => string.Concat(
element.Descendants(ns + "t").Select(node => node.Value));
var blocks = new List<string>();
foreach (var block in body.Elements())
{
if (block.Name == word + "p")
{
var paragraph = Text(block, word).Trim();
if (paragraph.Length == 0) continue;
var style = block.Element(word + "pPr")?.Element(word + "pStyle")?.Attribute(word + "val")?.Value ?? string.Empty;
if (style.Contains("Role", StringComparison.OrdinalIgnoreCase) && blocks.Count > 0) blocks.Add(string.Empty);
blocks.Add(style.Contains("Bullet", StringComparison.OrdinalIgnoreCase) ? $"- {paragraph}" : paragraph);
continue;
}
if (block.Name != word + "tbl") continue;
foreach (var row in block.Elements(word + "tr"))
{
var cells = row.Elements(word + "tc")
.Select(cell => string.Join(" ", cell.Elements(word + "p").Select(paragraph => Text(paragraph, word).Trim()).Where(value => value.Length > 0)))
.Where(value => value.Length > 0)
.ToList();
if (cells.Count > 0) blocks.Add(string.Join(" | ", cells));
}
}
return string.Join("\n", blocks).Trim();
}
return string.Empty;
@@ -126,8 +126,15 @@ public sealed partial class ProfileCvController : ControllerBase
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
var heuristicSummary = heuristicFallback.Summary.ToList();
var heuristicSkills = heuristicFallback.Skills.ToList();
heuristicFallback.Sections = new List<StructuredCvSection>();
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
// The section parser deliberately treats comma-separated text as lists. A prose summary is
// better represented by the sentence-aware deterministic parser, otherwise one paragraph
// is duplicated as many comma fragments during fallback merging.
if (heuristicSummary.Count > 0) fallback.Summary = heuristicSummary;
if (heuristicSkills.Count > 0) fallback.Skills = heuristicSkills;
if (classifierFallback is not null)
{
fallback = StructuredCvProfileJson.Merge(classifierFallback, fallback);
@@ -160,7 +167,10 @@ public sealed partial class ProfileCvController : ControllerBase
}
else if (ArePlausibleJobs(merged.Jobs, merged.Contact.FullName))
{
if (ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
var firstJobCameFromClassifier = merged.Metadata.Fields.TryGetValue("jobs[0].title", out var firstJobMetadata)
&& string.Equals(firstJobMetadata.Method, "classifier", StringComparison.OrdinalIgnoreCase);
if (!firstJobCameFromClassifier
&& ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
@@ -40,14 +40,23 @@ public sealed partial class ProfileCvController : ControllerBase
["core skills"] = "Skills",
["skills"] = "Skills",
["technical skills"] = "Skills",
["technologies"] = "Skills",
["tech stack"] = "Skills",
["competencies"] = "Skills",
["core competencies"] = "Skills",
["experience"] = "Work Experience",
["professional experience"] = "Work Experience",
["career history"] = "Work Experience",
["work history"] = "Work Experience",
["experience highlights"] = "Work Experience",
["work experience"] = "Work Experience",
["employment history"] = "Work Experience",
["selected achievements"] = "Selected Achievements",
["achievements"] = "Selected Achievements",
["projects"] = "Projects",
["selected projects"] = "Projects",
["education"] = "Education",
["qualifications"] = "Education",
["certifications"] = "Certifications",
["certificates"] = "Certifications",
["languages"] = "Languages",
@@ -61,12 +70,34 @@ public sealed partial class ProfileCvController : ControllerBase
["organizations"] = "Organisations",
["memberships"] = "Organisations",
["references"] = "References",
["courses"] = "Courses",
["training"] = "Courses",
["volunteer experience"] = "Volunteer Experience",
["volunteering"] = "Volunteer Experience",
["additional experience"] = "Additional Experience",
["declaration"] = "Declaration",
["sammendrag"] = "Professional Summary",
["profil"] = "Professional Summary",
["kjernekompetanse"] = "Skills",
["tekniske ferdigheter"] = "Skills",
["teknisk kompetanse"] = "Skills",
["arbeidserfaring"] = "Work Experience",
["yrkeserfaring"] = "Work Experience",
["utvalgte prosjekter"] = "Projects",
["prosjekter"] = "Projects",
["tilleggserfaring"] = "Additional Experience",
["utdanning"] = "Education",
["sertifiseringer"] = "Certifications",
["språk"] = "Languages",
["interesser"] = "Interests",
["referanser"] = "References",
["lenker"] = "Links",
};
private const long MaxFileSizeBytes = 5 * 1024 * 1024;
private const int ExtractionRunRetentionCount = 20;
private const string ParserVersion = "m005-s01";
private const string NormalizerVersion = "m005-s01";
private const string ParserVersion = "m005-s03";
private const string NormalizerVersion = "m005-s03";
private const string LlmPromptVersion = "m005-s01";
private readonly UserManager<ApplicationUser> _users;