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:
cesnimda
2026-07-20 11:40:04 +02:00
parent fe9cd4dda1
commit 173187dcbb
5 changed files with 289 additions and 4 deletions
+19 -3
View File
@@ -569,6 +569,8 @@ Rules for normalized_text:
# Work Experience
# Education
# Skills
# Projects
# Certifications
# Languages
# Interests
- Under # Contact, put one plain value per line, no labels unless unavoidable:
@@ -591,7 +593,19 @@ Rules for normalized_text:
Institution, Location line
2016 - 2019 line
- detail
- Under # Skills and # Languages, use one bullet per item.
- Under # Projects, for each project use this exact shape (blank line between projects):
Project name
- one short description line covering what it is and the tech used
- Under # Certifications, one certification per line: name, then issuer and year if stated.
- Under # Skills, use one bullet per item. If skills are grouped with a category label such as
"Development:", "DevOps & Infrastructure:" or "Practices:", DROP the category label and list only
the individual skills as separate bullets. Never keep the category word as a skill.
- Under # Languages, one language per line as "Name: Level" (e.g. "English: Native", "Norwegian: B1").
IMPORTANT: languages are often stated only inside the summary or profile text (e.g. "native English
speaker", "Norwegian at B1"). When you see a spoken/written human language and any proficiency
(native, fluent, C1, B2, B1, A2, conversational, basic), add it here even if there is no dedicated
languages section in the source. Do NOT treat programming languages (C#, Python, JavaScript, SQL) as
human languages.
- Remove OCR/layout noise.
- Do not output placeholders like Not specified.
- If uncertain, omit the field/line rather than invent.
@@ -619,7 +633,7 @@ async def classify_cv_block(req: CvClassifyBlockRequest):
You classify one CV text block into structured JSON.
Return ONLY valid JSON with this exact shape:
{{
"section": "Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests|Other",
"section": "Contact|Professional Summary|Work Experience|Education|Skills|Projects|Certifications|Languages|Interests|Other",
"confidence": 0.0,
"reason": "short reason",
"title": string|null,
@@ -636,7 +650,9 @@ Rules:
- Preserve facts only.
- section must be one of the listed values.
- Use Work Experience only for job/employment blocks.
- Use Education only for degree/course/certification blocks.
- Use Education only for degree/diploma/course blocks.
- Use Projects for personal/side/portfolio project blocks (put the project name in title and details in bullets).
- Use Certifications for named certifications/licences (put the certification name in title).
- For Contact blocks, keep title/company/start/end null and bullets/summary/skills empty.
- For Professional Summary blocks, prefer summary for concise summary lines and keep bullets empty unless the source is already bullet-like.
- For Skills blocks, prefer skills for normalized skill items and keep title/company/start/end null.
+32
View File
@@ -134,6 +134,38 @@ def test_classify_block_returns_structured_json(monkeypatch):
assert payload["skills"] == ["Python", "SQL"]
def test_classify_block_supports_projects_section(monkeypatch):
# Phase 2.1-b: Projects and Certifications are now valid classified sections so project blocks
# (e.g. the benchmark CV's JobTrack/InboxIntel) are no longer dropped into "Other".
module = load_app_module(monkeypatch)
def fake_generate_json(prompt: str):
assert "Projects" in prompt # the enum now advertises Projects to the model
return {
"section": "Projects",
"confidence": 0.83,
"reason": "project block",
"title": "JobTrack",
"company": None,
"location": None,
"start": None,
"end": None,
"bullets": ["Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."],
"summary": [],
"skills": [],
}
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
client = TestClient(module.app)
response = client.post("/cv/classify-block", json={"block": "JobTrack - Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."})
assert response.status_code == 200
payload = response.json()
assert payload["section"] == "Projects"
assert payload["title"] == "JobTrack"
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})