feat(cv): Phase 2.1-b — extract Projects, Certifications, and languages-from-prose
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.
ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
languages-from-prose rule (pull "native English", "Norwegian at B1" out
of the summary even with no Languages section; ignore programming
languages); and skill-group prefix stripping ("Development:",
"DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
enum + rules (fallback path).
Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
so those CVs still take the markdown assembly path.
Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.
The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).
Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using JobTrackerApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Phase 2.1-b — CV extraction coverage. The structured model and StructuredCvProfileJson.FromSections
|
||||
// already map Projects, Certifications and Languages headings into the profile; the gap was upstream
|
||||
// (the AI /cv/normalize prompt never emitted those headings, so the sections were dropped). These
|
||||
// tests lock the C# side so that once the normalized markdown carries # Projects / # Certifications /
|
||||
// # Languages, they reach the structured profile — and so a future change can't silently regress it.
|
||||
//
|
||||
// Content shapes mirror what the (fixed) normalizer produces for the benchmark CV
|
||||
// (Connor Babbington): a Projects section, and languages stated as "Name: Level".
|
||||
public sealed class CvExtractionCoverageTests
|
||||
{
|
||||
private static StructuredCvSection Section(string name, string content) =>
|
||||
new() { Name = name, Content = content };
|
||||
|
||||
[Fact]
|
||||
public void FromSections_maps_a_Projects_heading_into_structured_projects()
|
||||
{
|
||||
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||
{
|
||||
Section("Projects",
|
||||
"JobTrack\nFull-stack job-application tracker (React, ASP.NET Core, SQLite, Docker).\n\n" +
|
||||
"InboxIntel\nGmail analytics and safe bulk-cleanup tool in .NET 8 with PostgreSQL."),
|
||||
});
|
||||
|
||||
Assert.Equal(2, profile.Projects.Count);
|
||||
Assert.Contains(profile.Projects, p => p.Name == "JobTrack");
|
||||
Assert.Contains(profile.Projects, p => p.Name == "InboxIntel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromSections_maps_a_Certifications_heading_into_structured_certifications()
|
||||
{
|
||||
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||
{
|
||||
Section("Certifications",
|
||||
"Extended Diploma NVQ Level 3 in ICT\n\nAZ-900 Azure Fundamentals"),
|
||||
});
|
||||
|
||||
Assert.NotEmpty(profile.Certifications);
|
||||
Assert.Contains(profile.Certifications, c => (c.Name ?? "").Contains("NVQ", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromSections_maps_a_Languages_heading_with_levels()
|
||||
{
|
||||
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||
{
|
||||
Section("Languages", "English: Native\nNorwegian: B1"),
|
||||
});
|
||||
|
||||
Assert.Equal(2, profile.Languages.Count);
|
||||
Assert.Contains(profile.Languages, l => l.Name == "English" && (l.Level ?? "").Contains("Native"));
|
||||
Assert.Contains(profile.Languages, l => l.Name == "Norwegian" && (l.Level ?? "").Contains("B1"));
|
||||
}
|
||||
|
||||
// The benchmark CV has all four rich sections; confirm they coexist without one clobbering another.
|
||||
[Fact]
|
||||
public void FromSections_populates_projects_certifications_and_languages_together()
|
||||
{
|
||||
var profile = StructuredCvProfileJson.FromSections(new[]
|
||||
{
|
||||
Section("Skills", "C#\n.NET\nDocker"),
|
||||
Section("Projects", "JobTrack\nJob-application tracker."),
|
||||
Section("Certifications", "NVQ Level 3 in ICT"),
|
||||
Section("Languages", "English: Native\nNorwegian: B1"),
|
||||
});
|
||||
|
||||
Assert.NotEmpty(profile.Skills);
|
||||
Assert.Single(profile.Projects);
|
||||
Assert.NotEmpty(profile.Certifications);
|
||||
Assert.Equal(2, profile.Languages.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user