# Phase 2.1 — CV extraction: current implementation, weaknesses, proposal > 2026-07-20. Investigation deliverable for Phase 2.1. Grounded in the code and in a real benchmark > CV (Connor Babbington, Systems Developer). No code changed yet — this is the "explain → identify → > propose" step before any implementation. ## 1. Current implementation (verified in code) Upload flow (`ProfileCvController`, `CvProcessingQueue`, `tools/summarizer/app.py`): ``` Upload file → CvUploadArtifact stored on disk, CvExtractionRun queued ↓ (background) ProcessQueuedRunAsync Extract text (PDF/DOCX/image OCR) ↓ AI /cv/normalize → LLM rewrites messy text into markdown-sectioned CV text ↓ split into blocks, AI /cv/classify-block per block → {section, fields, confidence} ↓ assemble StructuredCvProfile (+ per-field metadata: confidence, method, sourceBlockId, reviewState) ↓ ApplyQueuedRunResultAsync → SaveVersionAsync + user.ProfileCvStructureJson = ← REPLACE ↓ run.Status = "applied" (no user confirmation) ``` The structured model (`Models/StructuredCvProfile.cs`) already has `Contact, Summary, Jobs, Education, Certifications, Projects, Skills, Languages` and per-field confidence metadata. Confidence is **captured and displayed** (the review chips) but used for nothing else. ## 2. Weaknesses (code-verified, then benchmark-verified) ### Architectural (the big two) - **A1 — Replace, not merge.** `ApplyQueuedRunResultAsync` overwrites the entire profile every run (`SaveVersionAsync(structuredCv)` + `user.ProfileCvStructureJson = structuredJson`). Re-importing a CV, or a slightly worse OCR pass, **discards** whatever the user curated. This is the exact failure the vision names: the profile oscillates instead of getting richer. There is no dedup, no field-level merge, no "keep the better value". - **A2 — No review-before-apply.** The run auto-applies (`Status = "applied"`). The user never sees "we found 4 experiences, 15 skills, ⚠ 1 language" and never approves. The confidence metadata that would drive such a screen is already computed and then ignored. ### Extraction quality (verified against the benchmark CV) - **Q1 — Projects and Certifications are dropped.** The `/cv/normalize` heading list (`app.py:566-573`) and the `/cv/classify-block` section enum (`app.py:622`) include only Contact / Summary / Work Experience / Education / Skills / Languages / Interests — **no Projects, no Certifications** — even though the data model and `StructuredCvProfileJson` fully support them. The benchmark CV's entire **Projects** section (JobTrack, InboxIntel, infra lab) is lost. - **Q2 — Languages in prose are missed.** The benchmark states languages only inside the summary ("Native English speaker; Norwegian at B1"). There is no `# Languages` block for the classifier to pick up, and normalize won't synthesise one from prose, so **English/Norwegian + levels are lost** as structured languages. - **Q3 — Grouped skills leak their category label.** "Development: C#, .NET, Python…" — the classifier turns skill lines into items but nothing strips the "Development:" / "DevOps & Infrastructure:" / "Practices:" prefixes, so a skill like "Development: C#" or a junk "Development" item can appear. - **Q4 — Glued date/title runs.** Text extraction yields "2015–2023System Developer — Warwickshire County Council, UK" with no space between the date range and the title. The classifier expects clean `start`/`end`; a two-hop normalize→classify can mis-split or drop the date. - **Q5 — Nested "Earlier roles (part-time)" list.** Three secondary jobs (Royal Vapes, The Hodcarrier, Nuffield Health) sit as sub-bullets under a heading, not as standard entries. They are likely mis-classified as bullets of the parent job or dropped. - **Q6 — Two-hop LLM loses whole-CV context.** normalize (rewrites text, can hallucinate/omit) then per-block classify (no cross-block view) means duplicate or mis-sectioned entries and no dedup. - **Q7 — Encoding.** PDF text extraction returns mojibake for `ø`, en-dashes and apostrophes (`T�nsberg`, `years�`, `2015�2023`) depending on the extractor. Downstream this corrupts company names, locations and dates. (The DB round-trip for `æøå` is already fixed; this is the *extraction* side.) ### Confidence - **C1 — Confidence is display-only.** It is computed per field and shown as chips, but never used to (a) gate what auto-applies, (b) flag low-confidence items for review, or (c) decide merge-vs-keep. ## 3. Proposed architecture Keep the strengths (text extraction, per-field confidence metadata, the structured model) and add the two missing layers plus targeted extraction fixes. ``` Extract → AI structured extraction (improved) → Validate → Normalize → DIFF against current profile (new) → REVIEW screen: "We found …" (new, confidence-aware) → MERGE on accept (new: field-level, dedup, confidence-gated) ← never a blind replace ``` - **Merge engine (A1).** On accept, merge per entity: match experiences/education/projects by a stable key (company+title+dates / institution+qualification / project name), update a field only when the incoming confidence is high **and** differs, add genuinely new items, and never delete a curated item the import didn't mention. Skills/languages dedup case-insensitively; language levels update only on higher confidence. - **Review screen (A2).** Reuse the diff: show counts and per-category adds/updates with ✓ (high) and ⚠ (low-confidence) markers, and Accept all / Review individually / Discard. Nothing writes until accept. (This is also Phase 2's "Import CV review" screen from the workspace refactor — one build serves both.) - **Extraction fixes (Q1–Q7).** Add Projects + Certifications to both prompts and the block assembler; synthesise Languages from summary prose (or a dedicated language pass); strip skill-group prefixes; harden date/title splitting; handle the "earlier/part-time roles" pattern; fix extraction-time encoding. - **Confidence gating (C1).** Drive the review markers and the merge rules from the existing per-field confidence — no new scoring needed to start, just *use* it. ## 4. Recommended increment order (small, verified, deployable each) 1. **2.1-a — Merge instead of replace + review gate** (highest value, the vision's core). Backend diff+merge engine with tests; frontend review screen. Nothing auto-overwrites again. 2. **2.1-b — Extraction coverage: Projects, Certifications, Languages-from-prose** (Q1, Q2). Prompt + assembler + parser, benchmarked on the CV. **— DELIVERED 2026-07-20.** 3. **2.1-c — Extraction cleanup: skill-group prefixes, glued dates, part-time roles, encoding** (Q3–Q5, Q7). 4. **2.1-d — Confidence-driven review markers and merge gating** (C1), once the review screen exists. Each ships independently and leaves production green. ## 5. Benchmark as regression fixture Save the benchmark CV's expected structured output as a test fixture: 5 experiences (2 primary + 3 part-time) or a documented decision on the part-time roles, 3 projects, grouped skills flattened, English (Native) + Norwegian (B1) languages, education entry, contact with Norwegian location intact. Extraction changes are measured against it — without overfitting (the rules must generalise). ## 2.1-b delivered — extraction coverage (2026-07-20) **What changed.** The gap was upstream only: the C# assembler (`StructuredCvProfileJson.FromSections` + `BuildStructuredCvFromNormalizedMarkdown`) already maps `Projects`, `Certifications` and `Languages` headings — the AI `normalize` prompt just never emitted them, so they were dropped. - `tools/summarizer/app.py` `/cv/normalize`: added `# Projects` and `# Certifications` headings with shapes; added a **languages-from-prose** rule (extract "native English", "Norwegian B1" from the summary even without a Languages section; ignore programming languages); added **skill-group prefix stripping** ("Development:", "DevOps & Infrastructure:", "Practices:" are dropped, only the skills remain). - `/cv/classify-block`: added `Projects` and `Certifications` to the section enum + rules (fallback path). - `ProfileCvController.LooksLikeNormalizedMarkdownCv`: recognises `# Projects` / `# Certifications` so a CV whose structured content is mostly those sections still takes the markdown path. **Verification.** 4 new backend tests (`CvExtractionCoverageTests`) lock the C# mapping of Projects/Certifications/Languages; 1 new ai-service test (`test_classify_block_supports_projects_section`). 426 backend tests and 17 ai-service tests pass; `app.py` compiles. The LLM behaviour itself (prompt → headings) could not be run here (no Ollama), but the C# side that consumes the headings is proven, and the prompt change is additive/contract-safe. **Deployment note.** These prompt changes live in the **ai-service container**, which `deploy.sh` does **not** rebuild by default. Deploy with `DEPLOY_BUILD_AI_SERVICE=true ./deploy/deploy.sh` (or rebuild `ai-service` manually) or the extraction change won't take effect. No database or backend schema change. **Not done here (moved to 2.1-c):** deterministic C# safety-nets for skill-prefix stripping and glued-date splitting, and the "earlier/part-time roles" pattern. 2.1-b relies on the prompt for those; 2.1-c hardens them deterministically. ## 6. Open product decisions (need a call before building) - **Merge matching keys** — how aggressively to treat two experiences as "the same" (company+title vs fuzzy). Conservative (fewer merges, some dupes) vs aggressive (cleaner, risk of wrong merges). - **Part-time/earlier roles** — separate experience entries, or a sub-list on the primary role? - **Auto-apply threshold** — does anything ever apply without review (e.g. an empty profile's first import), or is review always required?