feat(cv): extend templates and extraction
This commit is contained in:
@@ -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</p>", 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\"><p>I confirm these details.</p>", html);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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("""
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
|
||||
<w:p><w:r><w:t>Technical Skills</w:t></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>Backend</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>C#, .NET</w:t></w:r></w:p></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:p><w:r><w:t>DevOps</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Docker, Linux</w:t></w:r></w:p></w:tc></w:tr>
|
||||
</w:tbl>
|
||||
<w:p><w:pPr><w:pStyle w:val="CVRole" /></w:pPr><w:r><w:t>Example Engineer</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="CVBullet" /></w:pPr><w:r><w:t>Built reliable services</w:t></w:r></w:p>
|
||||
</w:body></w:document>
|
||||
""");
|
||||
}
|
||||
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<Task<string>>(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<StructuredCvProfile>(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<StructuredCvProfile>(method.Invoke(null, new object[] { text, text }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,10 +462,13 @@ 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(' ', ',');
|
||||
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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;}}
|
||||
|
||||
+31
-3
@@ -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,6 +1075,9 @@ 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:
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user