From ccd0af908c332a949f59ffe05415ecc19f6dfd07 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 27 Aug 2026 15:27:48 +0200 Subject: [PATCH] feat(cv): extend templates and extraction --- JobTrackerApi.Tests/CvBuilderTests.cs | 60 +++++++- JobTrackerApi.Tests/CvCorpusHarnessTests.cs | 15 +- .../CvExtractionCoverageTests.cs | 142 ++++++++++++++++++ .../HumanLanguageCatalogTests.cs | 8 + .../Controllers/CvVariantController.cs | 1 + .../ProfileCvController.Parsing.cs | 141 +++++++++++++++-- .../ProfileCvController.Pipeline.cs | 12 +- .../Controllers/ProfileCvController.cs | 35 ++++- JobTrackerApi/Models/CvTheme.cs | 24 ++- JobTrackerApi/Models/CvVariantSettings.cs | 31 +++- JobTrackerApi/Models/HumanLanguageCatalog.cs | 11 +- JobTrackerApi/Services/CvRenderModel.cs | 32 +++- JobTrackerApi/Services/ThemedCvRenderer.cs | 64 ++++++-- tools/summarizer/app.py | 36 ++++- tools/summarizer/tests/test_app.py | 28 ++++ 15 files changed, 586 insertions(+), 54 deletions(-) diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index fba1f49..5ff8dee 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -210,6 +210,21 @@ public sealed class CvBuilderTests Assert.DoesNotContain("transform:scale", html); } + [Fact] + public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page() + { + var profile = Rich(); + profile.Jobs.Add(new StructuredCvJob { Id = "job3", Title = "Engineer III", Company = "Example", Bullets = { "Shipped reliable services." } }); + + var settings = new CvVariantSettings { ThemeId = "code" }; + var model = CvVariantResolver.Build(profile, settings, "F", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html; + + Assert.Contains("section section-experience section-flow", html); + Assert.Contains(".section-flow{break-inside:auto", html); + Assert.DoesNotContain(":has(", html); + } + [Fact] public void Header_band_and_sidebar_contact_text_own_their_contrasting_palette() { @@ -217,12 +232,11 @@ public sealed class CvBuilderTests var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null); var modern = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html; var lightAccent = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#f8fafc" }).Html; - var technicalTheme = CvThemeCatalog.Resolve("technical"); - var technical = renderer.Render(model, technicalTheme, new CvVariantSettings { ThemeId = "technical" }).Html; + var technical = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" }).Html; Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#fff;}", modern); Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#000;}", lightAccent); - Assert.Contains($".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{technicalTheme.SidebarInk};}}", technical); + Assert.Contains(".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{color:var(--cv-accent-ink);}", technical); } [Fact] @@ -272,8 +286,8 @@ public sealed class CvBuilderTests Assert.Equal(13, settings.BaseFontSizePt); Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html); - Assert.Contains("color:#123456", html); - Assert.Contains("background:#fafafa", html); + Assert.Contains("--cv-ink:#123456", html); + Assert.Contains("--cv-paper:#fafafa", html); Assert.Contains("line-height:1.55", html); Assert.Contains("font-size:13pt", html); Assert.Contains("class=\"skills-text\">C# · SQL

", html); @@ -437,12 +451,44 @@ public sealed class CvBuilderTests Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default)); } [Fact] - public void Premium_theme_policy_keeps_three_free_themes_available() + public void Premium_theme_policy_keeps_free_themes_available() { - Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false))); + Assert.Equal(4, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false))); Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium)); Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true))); Assert.False(CvThemeCatalog.CanUse("unknown", true)); } + + [Fact] + public void Code_theme_uses_registry_driven_numbered_sections_and_shared_accent_tokens() + { + var settings = new CvVariantSettings { ThemeId = "code", AccentColor = "#126b55" }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html; + + Assert.Contains("counter-reset:cv-section", html); + Assert.Contains("--cv-accent-color:#126b55", html); + Assert.Contains("Consolas", html); + Assert.True(CvThemeCatalog.Resolve("code").AtsFriendly); + } + + [Fact] + public void Section_overrides_skill_groups_and_custom_paragraphs_share_the_render_model() + { + var settings = new CvVariantSettings + { + Sections = { new CvSectionSetting { Key = "summary", Items = new() { "Tailored summary" } } }, + SkillsStyle = "grouped", + SkillGroups = new() { new CvSkillGroupSetting { Name = "Backend", Items = { "C#", ".NET" } } }, + CustomSections = { new CvCustomSectionSetting { Key = "decl", Title = "Declaration", ContentType = "paragraphs", Items = { "I confirm these details." } } }, + }; + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html; + + Assert.Equal(new[] { "Tailored summary" }, model.Sections.Single(section => section.Key == "summary").Bullets); + Assert.Contains("class=\"skill-group-name\">Backend", html); + Assert.Contains("class=\"paragraphs\">

I confirm these details.

", html); + } + } diff --git a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs index bafb37a..37d0ce0 100644 --- a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs +++ b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs @@ -18,15 +18,16 @@ namespace JobTrackerApi.Tests; public sealed class CvCorpusHarnessTests { - private static readonly string CorpusRoot = "/home/pi/cvs"; + private const string DefaultCorpusRoot = "/home/pi/cvs"; [Fact] public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available() { - if (!Directory.Exists(CorpusRoot)) return; + var corpusRoot = ResolveCorpusRoot(); + if (!Directory.Exists(corpusRoot)) return; var ignoredPatterns = ResolveIgnoredPatterns(); - var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly) + var files = Directory.EnumerateFiles(corpusRoot, "*.*", SearchOption.TopDirectoryOnly) .Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) @@ -143,7 +144,7 @@ public sealed class CvCorpusHarnessTests } var summary = new CvBenchmarkSummary( - CorpusRoot, + corpusRoot, outputRoot, DateTimeOffset.UtcNow, entries.Count, @@ -202,6 +203,12 @@ public sealed class CvCorpusHarnessTests return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss")); } + private static string ResolveCorpusRoot() + { + var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_CORPUS_DIR"); + return string.IsNullOrWhiteSpace(configured) ? DefaultCorpusRoot : configured.Trim(); + } + private static string ResolveApprovedFixturesRoot(string outputRoot) { var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR"); diff --git a/JobTrackerApi.Tests/CvExtractionCoverageTests.cs b/JobTrackerApi.Tests/CvExtractionCoverageTests.cs index 3607b69..d764c33 100644 --- a/JobTrackerApi.Tests/CvExtractionCoverageTests.cs +++ b/JobTrackerApi.Tests/CvExtractionCoverageTests.cs @@ -1,6 +1,8 @@ using System.Reflection; +using System.IO.Compression; using JobTrackerApi.Controllers; using JobTrackerApi.Models; +using Microsoft.AspNetCore.Http; using Xunit; namespace JobTrackerApi.Tests; @@ -137,10 +139,150 @@ public sealed class CvExtractionCoverageTests Assert.Equal(4, profile.Jobs.Count); } + [Fact] + public void Numbered_editorial_headings_are_canonicalized_without_personal_fixture_data() + { + var profile = InvokeHeuristicBuilder(""" + 01 — PROFESSIONAL SUMMARY + Backend engineer focused on reliable services and clear delivery. + + 02 — CORE COMPETENCIES + C# + Docker + + 03 — PROFESSIONAL EXPERIENCE + Software Engineer + Example Council + 2020–Present + - Built maintainable APIs + """); + + Assert.NotEmpty(profile.Summary); + Assert.Contains("C#", profile.Skills); + Assert.Contains(profile.Jobs, job => job.Title == "Software Engineer"); + } + + [Fact] + public void Unknown_markdown_sections_are_preserved_for_import_review() + { + var profile = InvokeHeuristicBuilder(""" + # Professional Summary + Engineer with broad delivery experience. + + # Community Leadership + Mentored early-career developers + Organised a monthly meetup + """); + + var custom = Assert.Single(profile.OtherSections, section => section.Title == "Community Leadership"); + Assert.Contains(custom.Items, item => item.Contains("Mentored", System.StringComparison.Ordinal)); + } + + [Fact] + public void Contact_detection_uses_the_location_segment_and_rejects_section_headings() + { + var profile = InvokeHeuristicBuilder(""" + Alex Example + Platform Engineer + Tønsberg, Norway · +47 412 34 567 · alex.example@example.test + portfolio.example.dev · linkedin.com/in/alex-example + Core Competencies + Backend Development · System Design + """); + + Assert.Equal("Tønsberg, Norway", profile.Contact.Location); + Assert.Equal("portfolio.example.dev", profile.Contact.Website); + } + + [Fact] + public void Norwegian_section_headings_are_canonicalized() + { + var profile = InvokeHeuristicBuilder(""" + Sammendrag + Utvikler med erfaring fra robuste tjenester. + + Teknisk kompetanse + C# + Docker + + Arbeidserfaring + Systemutvikler + Eksempelkommune + 2020–Present + - Bygget pålitelige tjenester + + Utdanning + Bachelor i informatikk + Eksempelhøgskolen + 2017–2020 + + Språk + Norsk: B2 + English: Native + """); + + Assert.NotEmpty(profile.Summary); + Assert.Contains("C#", profile.Skills); + Assert.NotEmpty(profile.Jobs); + Assert.NotEmpty(profile.Education); + Assert.Equal(2, profile.Languages.Count); + } + + [Fact] + public void Norwegian_inline_language_pairs_keep_their_own_proficiency() + { + var profile = InvokeHeuristicBuilder(""" + Språk + Engelsk — morsmål — Norsk — A2/B1, under aktiv utvikling mot B2 + """); + + Assert.Contains(profile.Languages, language => language.Name == "English" && language.Level == "Native"); + Assert.Contains(profile.Languages, language => language.Name == "Norwegian" && language.Level == "A2/B1"); + } + + [Fact] + public async Task Docx_fallback_preserves_paragraph_and_table_boundaries() + { + await using var package = new MemoryStream(); + using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("word/document.xml"); + await using var stream = entry.Open(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(""" + + Technical Skills + + BackendC#, .NET + DevOpsDocker, Linux + + Example Engineer + Built reliable services + + """); + } + package.Position = 0; + var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx"); + var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!; + var task = Assert.IsAssignableFrom>(method.Invoke(null, new object[] { upload, ".docx" })); + var extracted = await task; + + Assert.Contains("Technical Skills\n", extracted); + Assert.Contains("Backend | C#, .NET", extracted); + Assert.Contains("DevOps | Docker, Linux", extracted); + Assert.Contains("\n\nExample Engineer\n- Built reliable services", extracted); + } + private static StructuredCvProfile InvokeProfileBuilder(string markdown) { var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!; return Assert.IsType(method.Invoke(null, new object[] { markdown })); } + private static StructuredCvProfile InvokeHeuristicBuilder(string text) + { + var method = typeof(ProfileCvController).GetMethod("BuildHeuristicStructuredCv", BindingFlags.NonPublic | BindingFlags.Static)!; + return Assert.IsType(method.Invoke(null, new object[] { text, text })); + } + } diff --git a/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs b/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs index 34e204d..db690f4 100644 --- a/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs +++ b/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs @@ -35,6 +35,14 @@ public sealed class HumanLanguageCatalogTests Assert.Equal("Norwegian", HumanLanguageCatalog.NormalizeLanguageName(alias)); } + [Fact] + public void Norwegian_cv_terms_normalize_without_changing_the_canonical_storage_language() + { + Assert.Equal("English", HumanLanguageCatalog.NormalizeLanguageName("engelsk")); + Assert.Equal("Native", HumanLanguageCatalog.ExtractLevel("morsmål")); + Assert.Equal("Professional working proficiency", HumanLanguageCatalog.ExtractLevel("profesjonelt arbeidsnivå")); + } + [Theory] [InlineData("C#")] [InlineData("Leadership")] diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index 5ed9116..e543f5c 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -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); } diff --git a/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs b/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs index bf4b431..33c69dd 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs @@ -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(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(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 ParseLanguagesHeuristically(string content) { var languages = new List(); + 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 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, @"(?\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?[\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; diff --git a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs index 45b2bf1..fa5499b 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs @@ -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; } diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index e827876..9379fb6 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -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; diff --git a/JobTrackerApi/Models/CvTheme.cs b/JobTrackerApi/Models/CvTheme.cs index b6c48bd..154b272 100644 --- a/JobTrackerApi/Models/CvTheme.cs +++ b/JobTrackerApi/Models/CvTheme.cs @@ -28,6 +28,7 @@ public sealed class CvTheme // Typography public string HeadingFont { get; init; } = "Georgia, 'Times New Roman', serif"; public string BodyFont { get; init; } = "Arial, Helvetica, sans-serif"; + public string UtilityFont { get; init; } = "'Roboto Mono', Consolas, monospace"; public double NameSizePt { get; init; } = 24; public double HeadingSizePt { get; init; } = 12; public double BodySizePt { get; init; } = 10; @@ -51,6 +52,15 @@ public sealed class CvTheme // the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it. public bool AtsFriendly { get; init; } public bool Premium { get; init; } + public bool NumberSections { get; init; } + + // Builder options this template intentionally exposes. The renderer remains generic; the + // registry tells the editor which controls make sense for a selected template. + public List<string> SupportedSettings { get; init; } = new() + { + "accent", "typography", "spacing", "skills", "page", "heading", "header", + "layout", "sidebar", "photo", "icons", + }; // Which section keys render in the sidebar for two-column layouts (ignored for single/header-band). public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" }; @@ -58,7 +68,7 @@ public sealed class CvTheme public static class CvThemeCatalog { - // 8 professional themes, all data. To add one: append here. + // Professional themes, all data. To add one: append here. public static readonly IReadOnlyList<CvTheme> Themes = new List<CvTheme> { new() @@ -134,6 +144,18 @@ public static class CvThemeCatalog PhotoShape = "circle", DefaultIcons = true, SidebarSections = new() { "contact", "skills", "languages", "interests" }, }, + new() + { + Id = "code", Name = "Code", Category = "Technical", + Description = "Developer-focused editorial layout with numbered sections and precise mono details.", + Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule", + Accent = "#0b7a63", Ink = "#171a20", Muted = "#555e6b", Line = "#d8dde3", Paper = "#ffffff", + HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif", + UtilityFont = "Consolas, 'Courier New', monospace", NameSizePt = 25.5, HeadingSizePt = 9.5, + BodySizePt = 9.4, LineHeight = 1.43, PageMarginMm = 15.24, SectionGapMm = 5.6, EntryGapMm = 4.3, + PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true, + SupportedSettings = new() { "accent", "typography", "spacing", "skills", "page" }, + }, }; public static CvTheme Resolve(string? id) diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs index 98a1e93..9b30519 100644 --- a/JobTrackerApi/Models/CvVariantSettings.cs +++ b/JobTrackerApi/Models/CvVariantSettings.cs @@ -34,7 +34,8 @@ public sealed class CvVariantSettings public double? EntryGapMm { get; set; } public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar public string? HeaderStyle { get; set; } // plain | band | centered | kicker - public string? SkillsStyle { get; set; } // tags | text + public string? SkillsStyle { get; set; } // tags | text | bullets | grouped + public List<CvSkillGroupSetting>? SkillGroups { get; set; } public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band public double? SidebarWidthMm { get; set; } public List<string>? SidebarSections { get; set; } @@ -62,6 +63,9 @@ public sealed class CvSectionSetting // master order and come last. Null => master profile order. Lets a variant reorder entries without // touching the master profile. public List<string>? ItemOrder { get; set; } + // Optional CV-specific content for bullet/tag sections. Null uses master data; an empty list is + // an intentional empty override. Entry sections continue to use stable per-item overrides. + public List<string>? Items { get; set; } } public sealed class CvItemOverride @@ -78,6 +82,14 @@ public sealed class CvCustomSectionSetting public string? Title { get; set; } public List<string> Items { get; set; } = new(); public bool Hidden { get; set; } + public string? PresetKey { get; set; } + public string? ContentType { get; set; } // paragraphs | bullets | entries +} + +public sealed class CvSkillGroupSetting +{ + public string Name { get; set; } = string.Empty; + public List<string> Items { get; set; } = new(); } public static class CvVariantSettingsJson @@ -128,7 +140,7 @@ public static class CvVariantSettingsJson s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85); s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar"); s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker"); - s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text"); + s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text", "bullets", "grouped"); s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band"); s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year"); if (!string.IsNullOrWhiteSpace(s.Language)) @@ -146,6 +158,21 @@ public static class CvVariantSettingsJson s.Sections ??= new(); s.Overrides ??= new(); s.CustomSections ??= new(); + s.SkillGroups = s.SkillGroups? + .Where(group => !string.IsNullOrWhiteSpace(group.Name) || group.Items.Any(item => !string.IsNullOrWhiteSpace(item))) + .Take(30) + .Select(group => new CvSkillGroupSetting + { + Name = (group.Name ?? string.Empty).Trim(), + Items = group.Items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => item.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(100).ToList(), + }) + .ToList(); + foreach (var section in s.CustomSections) + { + section.ContentType = NormalizeChoice(section.ContentType, "paragraphs", "bullets", "entries") ?? "bullets"; + section.PresetKey = string.IsNullOrWhiteSpace(section.PresetKey) ? null : section.PresetKey.Trim().ToLowerInvariant(); + section.Items ??= new(); + } return s; } diff --git a/JobTrackerApi/Models/HumanLanguageCatalog.cs b/JobTrackerApi/Models/HumanLanguageCatalog.cs index ab913d3..c62673d 100644 --- a/JobTrackerApi/Models/HumanLanguageCatalog.cs +++ b/JobTrackerApi/Models/HumanLanguageCatalog.cs @@ -11,7 +11,7 @@ public static class HumanLanguageCatalog private static readonly Regex WordRegex = new(@"\p{L}+", RegexOptions.Compiled); private static readonly Regex LevelRegex = new( - @"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|a1|a2|b1|b2|c1|c2|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2)\b", + @"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|morsmål|flytende|avansert|mellomnivå|nybegynner|grunnleggende|samtalenivå|profesjonelt\s+arbeidsnivå|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2|a1|a2|b1|b2|c1|c2)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); public static string? NormalizeLanguageName(string? raw) @@ -79,6 +79,14 @@ public static class HumanLanguageCatalog "working proficiency" => "Working proficiency", "limited working proficiency" => "Limited working proficiency", "full professional proficiency" => "Full professional proficiency", + "morsmål" => "Native", + "flytende" => "Fluent", + "avansert" => "Advanced", + "mellomnivå" => "Intermediate", + "nybegynner" => "Beginner", + "grunnleggende" => "Basic", + "samtalenivå" => "Conversational", + "profesjonelt arbeidsnivå" => "Professional working proficiency", _ when Regex.IsMatch(compact, @"^[ABC][12](?:\s*/\s*[ABC][12])?$", RegexOptions.IgnoreCase) => compact.ToUpperInvariant().Replace(" ", string.Empty), _ => compact, }; @@ -163,6 +171,7 @@ public static class HumanLanguageCatalog // means. They must beat culture enumeration (see Override), because ICU carries "Norwegian // Bokmål"/"Norwegian Nynorsk" and "Chinese (Simplified/Traditional)" as their own cultures. Override("norsk", "Norwegian"); + Override("engelsk", "English"); Override("bokmal", "Norwegian"); Override("bokmål", "Norwegian"); Override("nynorsk", "Norwegian"); diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs index d479ed5..4d4f716 100644 --- a/JobTrackerApi/Services/CvRenderModel.cs +++ b/JobTrackerApi/Services/CvRenderModel.cs @@ -25,13 +25,20 @@ public sealed class CvRenderSection { public string Key { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; - // bullets | tags | entries + // bullets | tags | paragraphs | skill-groups | entries public string Kind { get; set; } = "entries"; public List<string> Bullets { get; set; } = new(); public List<string> Tags { get; set; } = new(); public List<CvRenderEntry> Entries { get; set; } = new(); + public List<CvRenderSkillGroup> SkillGroups { get; set; } = new(); - public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0; + public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0 && SkillGroups.Count == 0; +} + +public sealed class CvRenderSkillGroup +{ + public string Name { get; set; } = string.Empty; + public List<string> Items { get; set; } = new(); } public sealed class CvRenderEntry @@ -114,7 +121,7 @@ public static class CvVariantResolver { Key = key, Title = Trim(custom.Title) ?? "Additional", - Kind = "bullets", + Kind = custom.ContentType == "paragraphs" ? "paragraphs" : "bullets", Bullets = Clean(custom.Items), }; } @@ -142,6 +149,11 @@ public static class CvVariantResolver { if (cfg.Hidden) continue; if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim(); + if (cfg.Items is not null && section.Kind is "bullets" or "tags") + { + if (section.Kind == "tags") section.Tags = Clean(cfg.Items); + else section.Bullets = Clean(cfg.Items); + } if (cfg.ItemOrder is { Count: > 0 } && section.Entries.Count > 1) { section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder); @@ -154,6 +166,20 @@ public static class CvVariantResolver if (!section.IsEmpty) model.Sections.Add(section); } + if (settings.SkillGroups is { Count: > 0 }) + { + var skills = model.Sections.FirstOrDefault(section => section.Key.Equals("skills", StringComparison.OrdinalIgnoreCase)); + if (skills is not null) + { + skills.Kind = "skill-groups"; + skills.Tags.Clear(); + skills.SkillGroups = settings.SkillGroups + .Select(group => new CvRenderSkillGroup { Name = Trim(group.Name) ?? string.Empty, Items = Clean(group.Items) }) + .Where(group => group.Items.Count > 0) + .ToList(); + } + } + return model; } diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index e5e29a1..4ca6bc2 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -133,11 +133,21 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer var inner = section.Kind switch { "bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>", + "paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>", + "skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>", + "tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</ul>", "tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>", "tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>", _ => string.Join("", section.Entries.Select(RenderEntry)), }; - return $@"<section class=""section""><h2 class=""section-title"">{Enc(section.Title)}</h2>{inner}</section>"; + var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty; + return $@"<section class=""section section-{Attr(section.Key)}{flowClass}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>"; + } + + private static string RenderSkillGroup(CvRenderSkillGroup group) + { + var name = string.IsNullOrWhiteSpace(group.Name) ? string.Empty : $@"<div class=""skill-group-name"">{Enc(group.Name)}</div>"; + return $@"<div class=""skill-group"">{name}<ul class=""skill-group-items"">{string.Join("", group.Items.Select(item => $@"<li>{Enc(item)}</li>"))}</ul></div>"; } private static string RenderEntry(CvRenderEntry entry) @@ -172,6 +182,17 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900; } + // A section containing several individually short entries can still exceed a printable page. + // Mark it as flowing so Chromium may move/break individual entries instead of moving the whole + // oversized section to a fresh page and leaving a large blank area behind. + private static bool IsFlowingSection(CvRenderSection section) + { + if (section.Entries.Count > 2 || section.Entries.Any(IsFlowingEntry)) return true; + if (section.SkillGroups.Count > 6) return true; + if (section.Tags.Count > 30) return true; + return section.Bullets.Count > 8 || section.Bullets.Sum(item => item?.Length ?? 0) > 1_000; + } + private static bool IsFlowingItem(string? item) => (item?.Length ?? 0) > 360; // Safe inline rich text for bullets/summary. HTML-escape EVERYTHING first (so any user markup is @@ -204,23 +225,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm; var headingCss = headingStyle switch { - "underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}", + "underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);padding-bottom:1.5mm;}", "plain" => ".section-title{letter-spacing:.01em;}", "bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}", - _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}", + _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:var(--cv-accent-color);border-bottom:1px solid var(--cv-accent-soft);padding-bottom:1.4mm;}}", }; var columnTemplate = layout == "sidebar-right" ? $"minmax(0,1fr) {F(sidebarWidth)}mm" : $"{F(sidebarWidth)}mm minmax(0,1fr)"; var layoutCss = twoColumn ? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}} -.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}} -.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}} -.sidebar .tag{{border-color:rgba(255,255,255,.4);}} -.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{t.SidebarInk};}} +.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}} +.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}} +.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}} +.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}} .sidebar a{{color:inherit;}} .main{{padding:{margin}mm;}} -.hero .name{{color:{t.SidebarInk};}}" +.hero .name{{color:var(--cv-accent-ink);}}" : $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}} .header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}} .header-band{{background:{accent};color:{headerInk};}} @@ -229,18 +250,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer .header-centered .contact{{justify-content:center;}} .header-plain{{border-bottom:2px solid {accent};}}"; + var numberedSections = t.NumberSections + ? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{t.UtilityFont};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}" + : string.Empty; + return $@" *{{box-sizing:border-box;}} +:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};}} html,body{{min-width:0;}} -body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} -.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}} +body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} +.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}} h1,h2{{font-family:{headingFont};}} .name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}} .kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}} .headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}} .head-text,.main,.sidebar,.cols>*{{min-width:0;}} .head-text{{flex:1;}} -.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}} +.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid var(--cv-accent-soft);}} .photo-square{{border-radius:2mm;}} .photo-rounded{{border-radius:5mm;}} .photo-circle{{border-radius:50%;}} @@ -249,6 +275,7 @@ h1,h2{{font-family:{headingFont};}} .contact-stacked{{flex-direction:column;gap:1.8mm;}} .contact-item{{display:inline-flex;align-items:center;gap:1.2mm;min-width:0;max-width:100%;overflow-wrap:anywhere;}} .contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}} +.main a{{color:var(--cv-accent-color);text-decoration-color:var(--cv-accent-soft);}} .contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}} .hero{{margin-bottom:{sectionGap}mm;}} .hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}} @@ -258,9 +285,16 @@ h1,h2{{font-family:{headingFont};}} {headingCss} .bullets{{margin:0;padding-left:4.5mm;}} .bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}} +.bullets li::marker{{color:var(--cv-accent-color);}} .tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}} .skills-text{{margin:0;overflow-wrap:anywhere;}} -.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}} +.tag{{border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}} +.paragraphs p{{margin:0 0 {F(1.8 * density)}mm;white-space:pre-line;}} +.skill-groups{{display:grid;gap:{F(1.7 * density)}mm;}} +.skill-group{{display:grid;grid-template-columns:minmax(24mm,.3fr) minmax(0,1fr);gap:3mm;break-inside:avoid-page;page-break-inside:avoid;}} +.skill-group-name{{font-family:{t.UtilityFont};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}} +.skill-group-items{{display:flex;flex-wrap:wrap;gap:.8mm 3mm;list-style:none;margin:0;padding:0;}} +.skill-group-items li{{overflow-wrap:anywhere;}} .entry{{margin-bottom:{entryGap}mm;}} .entry:last-child{{margin-bottom:0;}} .entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}} @@ -269,11 +303,15 @@ h1,h2{{font-family:{headingFont};}} .entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}} .entry-tags{{margin-top:1.4mm;}} {layoutCss} +{numberedSections} /* Print quality: keep normal entries whole, but allow intentionally classified long entries and long list items to flow. An unsplittable block taller than a page is otherwise clipped. */ .entry{{break-inside:avoid-page;page-break-inside:avoid;}} .entry-flow{{break-inside:auto;page-break-inside:auto;}} -.section-title{{break-after:avoid;page-break-after:avoid;}} +.section-title{{break-after:avoid-page;page-break-after:avoid;}} +.section-title + .entry,.section-title + .bullets,.section-title + .tags,.section-title + .paragraphs,.section-title + .skill-groups{{break-before:avoid-page;page-break-before:avoid;}} +.section{{break-inside:avoid-page;page-break-inside:avoid;}} +.section-flow{{break-inside:auto;page-break-inside:auto;}} .tag,.contact-item{{break-inside:avoid;}} .bullets li{{break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;overflow-wrap:anywhere;}} .bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}} diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 1fb9977..62cb8a6 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -1045,8 +1045,20 @@ async def purge_cache(): def _normalize_text(value: str) -> str: - value = value.replace("\x00", " ") - return re.sub(r"\s+", " ", value).strip() + """Normalize extraction noise without destroying section, bullet, or table boundaries.""" + value = value.replace("\x00", " ").replace("\r\n", "\n").replace("\r", "\n") + lines = [] + blank = False + for raw_line in value.split("\n"): + line = re.sub(r"[\t\f\v]+", " ", raw_line).strip() + if not line: + if lines and not blank: + lines.append("") + blank = True + continue + lines.append(line) + blank = False + return "\n".join(lines).strip() def _ocr_image(image: Image.Image) -> str: @@ -1063,7 +1075,10 @@ def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]: reader = PdfReader(io.BytesIO(data)) page_count = len(reader.pages) for page in reader.pages: - extracted_pages.append(page.extract_text() or "") + try: + extracted_pages.append(page.extract_text(extraction_mode="layout") or "") + except TypeError: + extracted_pages.append(page.extract_text() or "") except Exception: extracted_pages = [] @@ -1084,7 +1099,20 @@ def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]: def _extract_docx_text(data: bytes) -> str: document = Document(io.BytesIO(data)) - parts = [p.text.strip() for p in document.paragraphs if p.text and p.text.strip()] + parts = [] + blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs + for block in blocks: + if hasattr(block, "rows"): + for row in block.rows: + cells = [cell.text.strip() for cell in row.cells if cell.text and cell.text.strip()] + if cells: + parts.append(" | ".join(cells)) + elif getattr(block, "text", "").strip(): + text = block.text.strip() + style = (getattr(getattr(block, "style", None), "name", "") or "").lower() + if "role" in style and parts: + parts.append("") + parts.append(f"- {text}" if "bullet" in style else text) return _normalize_text("\n".join(parts)) diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index ef33dec..4aace88 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -1,4 +1,5 @@ import importlib +import io import json import sys from pathlib import Path @@ -522,6 +523,33 @@ def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch): assert "too large" in response.json()["detail"].lower() +def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch): + module = load_app_module(monkeypatch) + + normalized = module._normalize_text("PROFILE\n\nEngineer\n• Built APIs\n• Shipped services") + + assert normalized.splitlines() == ["PROFILE", "", "Engineer", "• Built APIs", "• Shipped services"] + + +def test_docx_extraction_preserves_paragraph_and_table_boundaries(monkeypatch): + module = load_app_module(monkeypatch) + document = module.Document() + document.add_heading("Technical Skills", level=1) + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Backend" + table.cell(0, 1).text = "C#, .NET" + table.cell(1, 0).text = "DevOps" + table.cell(1, 1).text = "Docker, Linux" + payload = io.BytesIO() + document.save(payload) + + extracted = module._extract_docx_text(payload.getvalue()) + + assert "Technical Skills" in extracted + assert "Backend | C#, .NET" in extracted + assert "DevOps | Docker, Linux" in extracted + + def test_cache_purge_requires_service_token_and_clears_content(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") module.cache["synthetic-key"] = "synthetic-summary"