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:
+19
-3
@@ -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.
|
||||
|
||||
@@ -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": []})
|
||||
|
||||
Reference in New Issue
Block a user