|
|
|
@@ -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;
|
|
|
|
|