117 lines
7.6 KiB
C#
117 lines
7.6 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";
|
||
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);
|
||
}
|
||
}
|