Files
jobtrackingapp/JobTrackerApi/Services/CvDocumentInterpreter.cs
T

117 lines
7.6 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";
var sectionTitle = "Contact";
var unassigned = new List<string>();
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);
}
}