From fc5f3e49ba890d80d24ac44472e67e6e1565a253 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 11 Sep 2026 19:33:24 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20review=20findings=20F03-F06=20=E2=80=94?= =?UTF-8?q?=20quotas,=20rule=20hierarchy=20and=20a=20validator=20that=20ch?= =?UTF-8?q?ecks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F03 — application records - Quota already removed with F02's tracker; verified uncapped and that "interview reached" survives a later withdrawal. - Aker BP: application date confirmed 2026-07-29 by the user, settling the one-day conflict. Its Adjacent-despite-79 classification is kept, NOT reclassified: the user does not remember the rationale and judged it immaterial on a closed role, and silently reclassifying to make the arithmetic tidy is the exact objection the review raised. Recorded as an explicit override reason with a rubric version instead. F04 — rule hierarchy - Three levels stated in AGENTS.md and CLAUDE.md: immutable evidence rules, standing user preferences, market/employer presentation. - Deliverables separated from QA. The .tex stays the artefact of record; you still compile, into the application's own output/ folder, because the user cannot approve what he cannot open. - `resume_reference.md` said "Capgemini/Bundeswehr: include only when directly relevant", contradicting the standing never-include rule. Split and fixed. F05 — a green validator now means something - Per-claim `forbidden` wording is enforced. 14 of 26 claims carried such a list and NONE of it was checked, which is why "owned the full ML lifecycle" passed. Matching tolerates inserted adjectives: the real regression read "Built a decentralized Data Mesh" and one word defeated the literal list. - Added identity (e-mail/phone), employer-date, placeholder and document-exclusion checks. Capgemini's omission rule now has a canonical home in claims.json instead of living only in memory and config.md. - Registered Groovy and Snowflake as forbidden skills. Both were absent from claims.json entirely, so "forbidden" existed in prose with nothing enforcing it. - PASS no longer reads as a truth certificate: it prints what it did not check — scope implications, bullet-to-claim support, whether a number is real. F06 — the scanner stopped fighting the documents - LaTeX comments are no longer claims; `% never claim Terraform` was a finding before, which taught the workflow to write around the checker. - Markup, accent macros and umlauts are normalized, so `\textbf{Snow}flake` cannot smuggle a forbidden skill past a substring test and `Universit\"at der Bundeswehr M\"unchen` is recognisably a school. - Honest denials pass in English and German. - The triple-list cadence hint knew only English "and", so it silently did nothing on every German package. Advisory only, and now labelled as such. Verification - The six counter-proofs from the report are caught: 9 errors on one synthetic document, where the old scanner found none. - All six packages built under current rules scan clean — SBB, NVIDIA, RUAG, Kdo Cy, Citadel, Aker BP. Four false positives of my own were found and fixed on the way there; each is now a regression test. - 13 older packages do report findings. Those look like genuine historical violations and are deliberately NOT fixed here: F17 governs archives and they must not be silently rewritten. - `tests/test_document_scanner.py` added. Full suite 31/31 pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM --- AGENTS.md | 26 ++- CLAUDE.md | 26 ++- FIX_PROGRESS.md | 62 +++++- job_scout/state/application_cohort.json | 12 +- resume_builder/canonical/claims.json | 24 ++- .../helpers/validate_resume_system.py | 190 +++++++++++++++++- resume_builder/reference/resume_reference.md | 4 +- tests/test_document_scanner.py | 108 ++++++++++ 8 files changed, 421 insertions(+), 31 deletions(-) create mode 100644 tests/test_document_scanner.py diff --git a/AGENTS.md b/AGENTS.md index dafdd6b..f72887c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,10 +52,30 @@ You are simultaneously: You write as the strategist but critique as the reader. -**Hard rules:** -- Output .tex files ONLY. User compiles locally. +**Rule hierarchy.** When two instructions conflict, the higher level wins. State which level +you are applying when you deviate. + +1. **Evidence rules (immutable).** Anti-fabrication, Scope Discipline, provenance, verb discipline, + `claims.json` as highest authority. These are never overridden by a JD, a market convention or a + user styling preference. **Accuracy > Relevance > Impact > ATS > Brevity.** +2. **Standing user preferences.** Recorded in `config.md` and the KB Corrections Log — e.g. Capgemini + is omitted from every document and the resulting gap is intentional. Changed only by the user. +3. **Market and employer presentation.** Profile choice (International Tech vs Swiss/DACH), section + order, language, cover-letter convention. Adapt these per target; they never license a level-1 or + level-2 breach. + +**Deliverables vs QA — these are different things and were previously conflated:** +- **The `.tex` is the deliverable and the source of truth.** Never hand over a PDF as the artefact of + record, and never edit a PDF in place. +- **You still compile.** Run `validate_resume_system.py`, compile with the local LaTeX distribution, + and inspect the rendered pages before calling anything finished — a clean box count is not a + visual check. +- **Compile into the application's own `output/` folder**, not a temp directory, so the user can + review what he is approving (`config.md` Output Rules; rule added after a package shipped with no + reviewable PDF on 2026-08-27). "Output .tex only" means the .tex is authoritative, **not** that the + user should be handed nothing to look at. +- Refresh any submission-named copy on every recompile; it is a copy, not a build target. - Read `config.md` for email, provenance flags, and output preferences. -- **Accuracy > Relevance > Impact > ATS > Brevity** ## Evidence-First Workflow diff --git a/CLAUDE.md b/CLAUDE.md index 45349b8..916e91c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,30 @@ You are simultaneously: You write as the strategist but critique as the reader. -**Hard rules:** -- Output .tex files ONLY. User compiles locally. +**Rule hierarchy.** When two instructions conflict, the higher level wins. State which level +you are applying when you deviate. + +1. **Evidence rules (immutable).** Anti-fabrication, Scope Discipline, provenance, verb discipline, + `claims.json` as highest authority. These are never overridden by a JD, a market convention or a + user styling preference. **Accuracy > Relevance > Impact > ATS > Brevity.** +2. **Standing user preferences.** Recorded in `config.md` and the KB Corrections Log — e.g. Capgemini + is omitted from every document and the resulting gap is intentional. Changed only by the user. +3. **Market and employer presentation.** Profile choice (International Tech vs Swiss/DACH), section + order, language, cover-letter convention. Adapt these per target; they never license a level-1 or + level-2 breach. + +**Deliverables vs QA — these are different things and were previously conflated:** +- **The `.tex` is the deliverable and the source of truth.** Never hand over a PDF as the artefact of + record, and never edit a PDF in place. +- **You still compile.** Run `validate_resume_system.py`, compile with the local LaTeX distribution, + and inspect the rendered pages before calling anything finished — a clean box count is not a + visual check. +- **Compile into the application's own `output/` folder**, not a temp directory, so the user can + review what he is approving (`config.md` Output Rules; rule added after a package shipped with no + reviewable PDF on 2026-08-27). "Output .tex only" means the .tex is authoritative, **not** that the + user should be handed nothing to look at. +- Refresh any submission-named copy on every recompile; it is a copy, not a build target. - Read `config.md` for email, provenance flags, and output preferences. -- **Accuracy > Relevance > Impact > ATS > Brevity** ## Evidence-First Workflow diff --git a/FIX_PROGRESS.md b/FIX_PROGRESS.md index 083d81a..31ae850 100644 --- a/FIX_PROGRESS.md +++ b/FIX_PROGRESS.md @@ -6,19 +6,31 @@ Do not rewrite submitted application archives or invent missing evidence. ## Resume point -F01 and F02 complete. Next: F03, route tracker mutations through the authoritative registry, remove quotas and validate events. -Read this file and inspect `git diff` before resuming; do not repeat completed work. +F01–F06 complete. **Next: F07** — propagate evidence corrections into active writing sources +(start at `bundles/bundle_ml_ai_engineer.md:74,81`, the PyTorch context loss named in the report). +Read this file and inspect `git log`/`git diff` before resuming; do not repeat completed work. + +Checkpoints so far: `afdef7e` (F01+F02), and the commit that carries F03–F06. + +## User rulings — 2026-09-11 + +- **Aker BP application date: 2026-07-29** (confirmed; the CLAUDE.md cell saying 07-30 was wrong). +- **Aker BP Adjacent-despite-79:** user does not remember and considers it immaterial, role closed + and rejected — *"mach wie du es am saubersten findest"*. Resolved as: keep the recorded class, + record an honest override reason saying the rationale is unrecoverable, and do **not** reclassify. + Silent reclassification was the review's own objection. +- **F18/F19 document layout changes: authorized.** ## Checklist | Finding | Status | Scope / verification | |---|---|---| -| F01 | DONE | Six shared workflows; both agent entrypoints route identically; parity/link regression test; 10 tests pass | +| F01 | DONE | Six shared workflows; both agent entrypoints route identically; parity/link regression test | | F02 | DONE | 26 registry records, preserved original tables, common current view, interview events independent of outcome | -| F03 | TODO | Remove quota, validate and safely update application records | -| F04 | TODO | Rule hierarchy, deliverables and approval semantics | -| F05 | TODO | Structured document evidence and honest mechanical/semantic results | -| F06 | TODO | LaTeX normalization, schema errors and advisory style diagnostics | +| F03 | DONE | Quota gone (tracker is uncapped, `cohort_tracker.py` is a shim); Aker BP date and override recorded; validation enforces unique IDs, ISO dates, score range and gate/class consistency | +| F04 | DONE | Three-level rule hierarchy in AGENTS.md + CLAUDE.md; deliverable vs QA separated; `resume_reference.md` Capgemini contradiction removed | +| F05 | DONE | Per-claim forbidden wording enforced, identity/employer/placeholder/exclusion checks, `document_exclusions` given a canonical home, PASS no longer claims to be a truth certificate | +| F06 | DONE | LaTeX comments, markup, accent macros and umlauts normalized; negations respected; German `und` in the cadence hint; advisory warnings labelled as never a gate | | F07 | TODO | Propagate evidence corrections into active writing sources | | F08 | TODO | Normalize source/context/ownership metadata without inventing evidence | | F09 | TODO | Safe professional templates; clearly separate academic legacy | @@ -33,15 +45,43 @@ Read this file and inspect `git diff` before resuming; do not repeat completed w | F18 | TODO | Evidence earlier on page one; rendered skills budget | | F19 | TODO | Education layout, phrasing guidance, reproducible word counts | +**Sequencing note:** F11–F16 all touch `scout.py`. Fix behaviours in place and keep each +finding's diff separately verifiable. **Do not start the ~2400-line file split F16 suggests** +mid-remediation — it would destroy the ability to attribute a regression to a specific fix. + ## Verification log - Review baseline: 9 existing tests passed; 43/45 LaTeX builds succeeded. See the report for limitations. -- F01: moved current workflows into `resume_builder/reference/workflows/`; preserved skill frontmatter and Claude invocation support. Corrected the top-level file map and added this resume pointer. `python -m unittest discover -s tests -v`: 10/10 pass. The workflow validator now checks the shared critique implementation. -- F01 limitation: external `quick_validate.py` could not start because PyYAML is absent; repository tests validate frontmatter and both routing links. -- F02: imported 26 applications from the newer CLAUDE table and seven structured cohort records. Original tables/cohort preserved in `job_scout/state/history/application_status_before_2026-09-11.json`. `application_records.py --check` detects stale projections. Aker BP's July 29/30 date discrepancy and original Adjacent override remain explicitly unresolved. No date or rubric explanation invented. +- F01: moved current workflows into `resume_builder/reference/workflows/`; preserved skill frontmatter + and Claude invocation support. `python -m unittest discover -s tests -v`: 10/10 pass. +- F01 limitation: external `quick_validate.py` could not start because PyYAML is absent; repository + tests validate frontmatter and both routing links. +- F02: imported 26 applications from the newer CLAUDE table and seven structured cohort records. + Originals preserved in `job_scout/state/history/application_status_before_2026-09-11.json`. +- F03: `application_tracker.py summary` runs uncapped and reports "interview reached: 1" for SBB + despite the withdrawal — the F02 separation working. Aker BP written through + `records.transaction()`, so validation and view regeneration ran; `--check` reports views current. +- F04: `resume_reference.md` said "Capgemini/Bundeswehr: include only when directly relevant", + contradicting the standing never-include rule. Corrected, and the two rules separated. +- F05/F06 verification — **the counter-proof document is the evidence**: + - All six counter-proofs from the report are now caught (9 errors on one synthetic document). + - `% Never claim Terraform` (a comment) and an honest "no Terraform experience" — English and + German — correctly produce **no** finding. + - **All six packages built under current rules scan clean: SBB, NVIDIA, RUAG, Kdo Cy, Citadel, + Aker BP — 0 errors each.** Four false positives of my own making were found and fixed along the + way (title collision across employers, education institution vs employment entry, umlauts, + LaTeX accent macros). Each is now a regression test. + - 13 older packages DO report findings (Apple 9, Microsoft Principal FDE 8, …). These look like + genuine historical violations under today's stricter rules — "petabyte scale", "own the AWS data + platform", uncontextualised PyTorch/TensorFlow. **Not fixed: F17 governs historical archives and + they must not be silently rewritten.** Feed this list into F17. + - `tests/test_document_scanner.py` added: 8 must-fail, 4 must-not-fail, plus a guard that current + packages stay clean. Full suite 31/31 pass. +- Not re-run this session: the 45-document LaTeX build sweep and the visual sampling. ## Completion rules DONE requires implementation plus relevant checks. Record partial work and exact next action. Missing factual evidence must remain explicitly unresolved, never silently promoted to verified. -Historical packages are preserved; fixes apply to active sources and future generation unless specifically noted. +Historical packages are preserved; fixes apply to active sources and future generation unless +specifically noted. diff --git a/job_scout/state/application_cohort.json b/job_scout/state/application_cohort.json index fd07b1b..2a626ea 100644 --- a/job_scout/state/application_cohort.json +++ b/job_scout/state/application_cohort.json @@ -223,13 +223,17 @@ { "type": "rejected_no_interview", "date": "2026-08-27" + }, + { + "type": "record_correction", + "date": "2026-09-11", + "details": "Application date CONFIRMED BY THE USER as 2026-07-29, settling the one-day conflict between the cohort record (07-29) and the CLAUDE.md session cell (07-30). The legacy import event above still quotes 07-30; it is preserved verbatim as the historical text and is not edited." } ], "cohort_id": "evidence-first-2026-01", - "unresolved": [ - "Application date conflicts: cohort says 2026-07-29; CLAUDE says 2026-07-30. Preserve cohort date pending source confirmation." - ], - "fit_override_reason": "Legacy Adjacent classification retained; numeric score alone would suggest Core. Original rubric/override needs confirmation." + "fit_override_reason": "Recorded Adjacent although Evidence Fit 79 with a passing hard gate would score Core under the numeric rubric. The original rationale is NOT RECOVERABLE: the user was asked on 2026-09-11 and does not remember, and judged it immaterial because the role is closed and rejected. Deliberately NOT reclassified to Core - that would be the silent reclassification the review objected to, and it would rewrite a historical judgement to make the arithmetic tidy. The contemporaneous note points at a real reason rather than an error: the title-defining responsibility (authoring an enterprise framework) was only Adjacent, and application_strategy.md says a defining responsibility that is only Adjacent pushes a role down a class. Treated as a judgement recorded under the pre-2026-09-05 rubric, not as a data error. No future record may cite this one as precedent without stating its own reason.", + "rubric_version": "pre-2026-09-05-cohort", + "classification_note_for_stats": "Fit-class counts that include this row mix one pre-2026-09-05 judgement with post-rubric entries. Immaterial for a closed role; state it if the distribution is ever used as evidence." }, { "company": "Google", diff --git a/resume_builder/canonical/claims.json b/resume_builder/canonical/claims.json index 4a00faa..9a95c4a 100644 --- a/resume_builder/canonical/claims.json +++ b/resume_builder/canonical/claims.json @@ -702,9 +702,21 @@ "evidence": "pending-user-clarification", "output": "forbidden", "note": "RESOLVED 2026-08-27, and the answer is NO. User confirmed: Ansible playbooks lived in Git, but execution was PUSH-based out of Jenkins/GitLab pipelines - there was no pull-based reconciliation agent (Argo CD, Flux). That is version-controlled, CI-driven infrastructure automation, not GitOps. The word stays forbidden in every document. The SUBSTANCE is writable and should be written: 'versionskontrollierte Infrastrukturautomatisierung mit Ansible aus Git, ausgefuehrt ueber Jenkins-/GitLab-Pipelines'. The Kdo Cy JD names 'Pull-GitOps' explicitly, so precision here is an asset, not a loss." + }, + { + "name": "Groovy", + "evidence": "none", + "output": "forbidden", + "note": "Named in the NVIDIA JD alongside Bash and Python. Bash was confirmed and added 2026-09-05; Groovy was explicitly NOT - it stays unverified. Registered as a skill 2026-09-11 (review finding F05) so the document scanner can actually catch it: it was previously absent from claims.json entirely, which meant 'forbidden' existed only in prose and nothing enforced it." + }, + { + "name": "Snowflake", + "evidence": "none", + "output": "forbidden", + "note": "Repeatedly named in target JDs (SBB, Snowflake Inc. reqs). The honest substitution is Teradata, Oracle, Redshift and Athena/Iceberg - see the SBB session file. Registered 2026-09-11 (review finding F05) for the same reason as Groovy. Note this forbids CLAIMING the skill; naming Snowflake as an employer or as a JD requirement in prose is not a claim, and the negation guard in the scanner allows an honest 'no Snowflake experience'." } ], - "global_forbidden_output_patterns": [ + "global_forbidden_output_patterns": [ "petabyte scale", "petabyte-scale", "own the AWS data platform", @@ -712,5 +724,15 @@ "customer-embedded delivery", "formal model evaluation", "3 consecutive years" + ], + "document_exclusions": [ + { + "term": "Capgemini", + "scope": "every resume, CV and cover letter", + "reason": "Standing user preference: six months only (Nov 2014 - May 2015); the user does not want the short-stay impression. The resulting gap in the visible chronology is INTENTIONAL and must never be 'fixed' or explained away. The visible record starts at Generali, May 2015, which is also what any 'over ten years' tenure wording is measured against.", + "applies_to_documents_only": true, + "note": "This is a DOCUMENT rule, not a conversation rule. If an interviewer asks directly what the first civilian role was, naming it plainly is correct - declining to name a former employer reads worse than a six-month stay.", + "source": "User-confirmed 2026-08-27 after it regressed into the RUAG C5I draft; given a canonical home 2026-09-11 (review finding F05), having previously lived only in memory and config.md." + } ] } diff --git a/resume_builder/helpers/validate_resume_system.py b/resume_builder/helpers/validate_resume_system.py index 09b1f72..23d33b3 100644 --- a/resume_builder/helpers/validate_resume_system.py +++ b/resume_builder/helpers/validate_resume_system.py @@ -12,6 +12,7 @@ import argparse import json import re import sys +from functools import lru_cache from pathlib import Path @@ -92,6 +93,15 @@ def canonical_checks(claims: dict, history: dict) -> tuple[list[str], list[str]] return errors, warnings +UNCHECKED = ( + "whether a sentence implies ownership or scale beyond the evidence (Scope Discipline)", + "whether each bullet is actually supported by the claim it was planned from", + "whether a number in the document is real - unverified metrics look identical to real ones", + "whether tone, emphasis and ordering suit this employer", + "anything in a language or phrasing the pattern lists do not cover", +) + + def workflow_checks() -> list[str]: errors: list[str] = [] checks = { @@ -118,18 +128,172 @@ def workflow_checks() -> list[str]: return errors +_COMMENT = re.compile(r"(?]*>>", re.I) +# "no Terraform experience", "keine Terraform-Erfahrung", "not worked with Terraform" +_NEGATION = re.compile( + r"\b(?:no|not|never|without|kein|keine|keinerlei|ohne|nicht)\b[^.;]{0,60}$", re.I) + + +# LaTeX accent macros: \"a, 'e, \`e, \^o, \~n, plus \ss. German documents use these instead +# of literal umlauts, and leaving them in made "Universit\"at der Bundeswehr" unmatchable. +_ACCENT = re.compile(r"""\\["'`^~=.]\s*\{?([a-zA-Z])\}?""") +_SSHARP = re.compile(r"\\ss\b") + + +def _deaccent(raw: str) -> str: + """Fold LaTeX accent macros to their base letter. + + German packages write Universit\\"at der Bundeswehr M\\"unchen rather than literal + umlauts. Without this the employer/education comparison could not see the institution + name at all, which produced a false positive on several correct resumes. + """ + return _SSHARP.sub("ss", _ACCENT.sub(r"\1", raw)) + + +def visible_text(raw: str) -> str: + """Document text as a reader sees it. + + Three fixes for the scanner, all from review finding F06: + * LaTeX COMMENTS are dropped. A line such as `% never claim Terraform` is a note to + the author, not a claim, and flagging it taught the workflow to write around the + checker instead of fixing the document. + * MARKUP is removed but its argument text kept, so `\\textbf{Snow}flake` can no longer + smuggle a forbidden skill past a plain substring comparison. + * Whitespace is collapsed so a term split across a line break still matches. + """ + text = _deaccent(_COMMENT.sub("", raw)) + text = _MARKUP.sub(" ", text) + text = text.replace("{", " ").replace("}", " ").replace("~", " ") + return re.sub(r"\s+", " ", text) + + +@lru_cache(maxsize=512) +def _forbidden_phrase(phrase: str) -> re.Pattern: + """Forbidden wording, tolerant of inserted qualifiers. + + The canonical lists are literal ("built a Data Mesh"), but the regression this rule + exists to stop was worded "Built a decentralized Data Mesh" - one adjective defeated the + substring test. Allow up to two extra words between tokens. A false positive here costs + a rewording; a false negative ships a scope claim the evidence does not support. + """ + tokens = [re.escape(token) for token in phrase.lower().split()] + return re.compile(r"(? str: + """Umlaut-insensitive form. claims.json stores 'Universitat der Bundeswehr Munchen' + while documents render 'Universitaet der Bundeswehr Muenchen', and that mismatch made an + education institution look like the Bundeswehr employment entry on three correct resumes.""" + return text.translate(_FOLD).lower() + + +def _mentions(haystack: str, term: str) -> bool: + return re.search(r"(? bool: + """True when every mention of the term sits behind a negation.""" + hits = list(re.finditer(r"(? list[str]: + """Deterministic, mechanically checkable properties of a generated document. + + This is NOT a truth check and must never be reported as one. It cannot judge whether a + sentence implies ownership the evidence does not support; that stays a reading task. + See `UNCHECKED` and the summary printed by main(). + """ errors: list[str] = [] - text = read_text(path) + raw = read_text(path) + text = visible_text(raw) lowered = text.lower() + # Second view with grouping braces closed up rather than spaced out, so a name split by + # markup -- extbf{Snow}flake -- cannot slip past the skill comparison. + dense = re.sub(r"\s+", " ", _MARKUP.sub(" ", _deaccent(_COMMENT.sub("", raw))).replace("{", "").replace("}", "")).lower() + for pattern in claims.get("global_forbidden_output_patterns", []): if pattern.lower() in lowered: errors.append(f"{path}: forbidden output pattern: {pattern}") + + # Per-claim forbidden wording. 14 of 26 claims carry a `forbidden` list and NONE of it + # was enforced before: exactly the gap that let "owned the full ML lifecycle" pass. + for claim in claims.get("claims", []): + for phrase in claim.get("forbidden", []): + if not isinstance(phrase, str) or len(phrase) < 8: + continue # too short to match safely as a substring + if _forbidden_phrase(phrase).search(lowered): + errors.append(f"{path}: {claim.get('id','?')} forbidden wording: {phrase}") + for skill in claims.get("skills", []): - if skill.get("output") == "forbidden" and skill["name"].lower() in lowered: - errors.append(f"{path}: forbidden or unverified skill: {skill['name']}") - if "\\begin{rsubsection}" in lowered: - for line_number, line in enumerate(text.splitlines(), 1): + name, output = skill["name"], skill.get("output") + seen_plain = _mentions(lowered, name) + seen_dense = _mentions(dense, name) + if output == "forbidden" and (seen_plain or seen_dense) and not _negated(lowered if seen_plain else dense, name): + errors.append(f"{path}: forbidden or unverified skill: {name}") + if output == "certification-context-only" and _mentions(lowered, name): + window = " ".join( + lowered[max(0, m.start() - 200):m.start() + 200] + for m in re.finditer(re.escape(name.lower()), lowered)) + if not re.search(r"certificat|zertifik|coursera|nanodegree|kurs|course|training|weiterbildung", window): + errors.append( + f"{path}: {name} is certification-context-only and appears without that context") + + for term in (exclusion["term"] for exclusion in claims.get("document_exclusions", [])): + if _mentions(lowered, term): + errors.append(f"{path}: excluded from all documents by standing user preference: {term}") + + # Scan the comment-stripped RAW text: _MARKUP treats "\item [GENERATE: x]" as a command + # with an optional argument and would swallow the placeholder whole. + for match in _PLACEHOLDER.finditer(_COMMENT.sub("", raw)): + errors.append(f"{path}: unresolved placeholder left in the document: {match.group(0)!r}") + + identity = claims.get("identity", {}) + for field in ("email", "phone"): + value = identity.get(field) + if not value: + continue + compact = re.sub(r"[\s()-]", "", lowered) + if re.sub(r"[\s()-]", "", value.lower()) not in compact: + kind = "e-mail" if field == "email" else "phone number" + errors.append(f"{path}: canonical {kind} not found ({value}); check for a wrong contact line") + + # Anchor the date check on the EMPLOYER NAME, never on the job title: titles collide + # across employers ("Research Software Engineer" contains "Software Engineer") and that + # produced a false positive on two real resumes. Employers excluded from documents are + # skipped outright - their absence is the point, not a defect. + excluded = {item["term"].lower() for item in claims.get("document_exclusions", [])} + schooling_removed = fold(lowered) + for school in claims.get("education", []): + for key in ("institution", "thesis_institution"): + institution = fold(school.get(key) or "") + if len(institution) > 5: + schooling_removed = schooling_removed.replace(institution, " ") + for employer in claims.get("employment", []): + name = (employer.get("employer") or "").lower() + if not name or any(term in name for term in excluded): + continue + short = re.split(r"\s*\(|,| gmbh| ag| asa", name)[0].strip() + # Education institutions are removed first: "Universitaet der Bundeswehr Muenchen" is a + # school, not the Bundeswehr employment entry, and matching it flagged three real + # resumes that were entirely correct. + if len(short) < 4 or not _mentions(schooling_removed, fold(short)): + continue + start = employer.get("start", "") + year = start.split("-")[0] if start else "" + if year and year not in text: + errors.append( + f"{path}: {employer.get('id','?')} ({employer.get('employer')}) appears but its " + f"canonical start year {year} is absent; verify the dates") + + if "\\begin{rsubsection}" in raw.lower(): + for line_number, line in enumerate(raw.splitlines(), 1): if "\\begin{rSubsection}" not in line: continue if re.search(r"Production Ownership|AI-Ready|Customer-Embedded|Shipping ML", line, re.I): @@ -137,7 +301,10 @@ def scan_document(path: Path, claims: dict) -> list[str]: return errors -TRIPLE = re.compile(r"\w+, [^,]{3,45}, [^,]{3,45} and |\w+, [^,]{3,45} and ") +# Advisory only. German documents use "und"; without it the cadence hint silently +# did nothing on every German package. No external evidence supports a hard ~50% limit, +# so this stays a hint and never an error (F06). +TRIPLE = re.compile(r"\w+, [^,]{3,45}, [^,]{3,45} (?:and|und) |\w+, [^,]{3,45} (?:and|und) ") def _bullet_blocks(text: str) -> list[list[str]]: @@ -217,14 +384,21 @@ def main() -> int: errors.extend(scan_document(path, claims)) warnings.extend(cadence_checks(path)) + if args.document: + print("Mechanical checks only. NOT verified automatically:") + for item in UNCHECKED: + print(f" - {item}") + print() for warning in warnings: - print(f"WARN: {warning}") + print(f"WARN (advisory, never a gate): {warning}") for error in errors: print(f"ERROR: {error}") if errors: print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)") return 1 - print(f"PASS: canonical system valid ({len(warnings)} warning(s))") + scope = "mechanical checks passed" if args.document else "canonical system valid" + print(f"PASS: {scope} ({len(warnings)} advisory warning(s)). " + "This is not a truth certificate - see the unchecked list above.") return 0 diff --git a/resume_builder/reference/resume_reference.md b/resume_builder/reference/resume_reference.md index fcc97e3..ca109f3 100644 --- a/resume_builder/reference/resume_reference.md +++ b/resume_builder/reference/resume_reference.md @@ -59,7 +59,9 @@ Use relevance and evidence, not page-filling quotas. For Dennis's normal 2-page - Swisscom: 4--5 bullets. - Bosch: 3--4 bullets. - Fraunhofer, Vizrt and Generali: 0--1 bullet each. -- Capgemini/Bundeswehr: include only when directly relevant. +- **Capgemini: NEVER include.** Standing user preference (`config.md`); the resulting + Nov 2014 - May 2015 gap is intentional and must not be "fixed" or explained away. +- Bundeswehr: include only when directly relevant (defence, public-sector and security employers). - Typical total: 11--14 bullets. This is a guide, not a requirement. A strong shorter resume is better than a full page padded with weak evidence. White space is acceptable when hierarchy and readability are strong. diff --git a/tests/test_document_scanner.py b/tests/test_document_scanner.py new file mode 100644 index 0000000..71b0b8d --- /dev/null +++ b/tests/test_document_scanner.py @@ -0,0 +1,108 @@ +"""Negative regression tests for the document scanner (review findings F05 and F06). + +Each case below is a counter-proof from `output/Project_Review_2026-09-11/project_review.tex` +that the scanner accepted before 2026-09-11. The point of these tests is that a green +validator run means something; they are not a claim that the scanner proves truthfulness. +See `validate_resume_system.UNCHECKED` for what deliberately stays a reading task. +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "resume_builder" / "helpers")) + +import validate_resume_system as validator # noqa: E402 + +CLAIMS = validator.load_json(validator.CLAIMS_PATH) + + +def scan(body: str) -> str: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "candidate.tex" + path.write_text( + "\\documentclass{resume}\n\\begin{document}\n" + "\\contactline{dennis@thiessen.io $\\vert$ +41 795 955 585}\n" + f"{body}\n\\end{{document}}\n", + encoding="utf-8") + return " | ".join(validator.scan_document(path, CLAIMS)) + + +class ScannerCatchesTests(unittest.TestCase): + """Things that must fail. Every one of these passed before the fix.""" + + def test_solo_ownership_of_a_company_scale_platform(self): + # The recorded regression wording carried an adjective the literal list missed. + self.assertIn("SW-7", scan(r"\item Built a decentralized Data Mesh for the group.")) + + def test_full_ml_lifecycle_ownership(self): + self.assertIn("BS-1", scan(r"\item I owned the full ML lifecycle end to end.")) + + def test_employer_excluded_from_all_documents(self): + self.assertIn("Capgemini", scan(r"\item Capgemini Deutschland GmbH, Nov 2014 -- May 2015.")) + + def test_certification_only_skill_without_its_context(self): + self.assertIn("PyTorch", scan(r"\item Trained production models in PyTorch.")) + + def test_forbidden_skill_split_by_markup(self): + # \textbf{Snow}flake defeated a plain substring comparison. + self.assertIn("Snowflake", scan(r"\item Delivered \textbf{Snow}flake pipelines.")) + + def test_unregistered_but_forbidden_skill(self): + self.assertIn("Groovy", scan(r"\item Maintained Groovy build jobs.")) + + def test_unresolved_placeholder(self): + self.assertIn("placeholder", scan(r"\item [GENERATE: Bullet]")) + + def test_wrong_contact_details(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "wrong.tex" + path.write_text( + "\\documentclass{resume}\n\\begin{document}\n" + "\\contactline{wrong@example.com $\\vert$ +41 000 000 000}\n" + "\\end{document}\n", encoding="utf-8") + found = " | ".join(validator.scan_document(path, CLAIMS)) + self.assertIn("e-mail", found) + self.assertIn("phone", found) + + +class ScannerAllowsTests(unittest.TestCase): + """Things that must NOT fail. False positives taught the workflow to write around the checker.""" + + def test_latex_comment_is_not_a_claim(self): + self.assertEqual("", scan("% Never claim Terraform experience\n\\item Built pipelines.")) + + def test_honestly_denying_a_skill_is_not_claiming_it(self): + self.assertEqual("", scan(r"\item I have no Terraform experience and say so openly.")) + + def test_german_denial_is_not_claiming_it(self): + self.assertEqual("", scan(r"\item Keine Terraform-Erfahrung vorhanden.")) + + def test_education_institution_is_not_the_employment_entry(self): + # "Universitaet der Bundeswehr Muenchen" is a school, not the Bundeswehr employment + # record, and in German packages it is written with LaTeX accent macros. + body = r'\compactentry{M.Eng., Universit\"at der Bundeswehr M\"unchen}{Apr 2012 -- Oct 2013}' + self.assertNotIn("BUNDESWEHR", scan(body)) + + +class ShippedDocumentsTests(unittest.TestCase): + """The packages built under the current rules must stay clean.""" + + CURRENT = ( + "SBB_DataEngineer_AssetMgmt", "NVIDIA_SrDevOps_Zurich", "RUAG_AI_Engineer_C5I", + "KdoCy_DevOps_Data_Platform", "CitadelSecurities_PlatformEngineer", + "AkerBP_Data_Product_Architect", + ) + + def test_current_packages_have_no_findings(self): + for folder in self.CURRENT: + for document in (ROOT / "output" / folder).glob("e2e_*resume.tex"): + with self.subTest(document=document.name): + self.assertEqual([], validator.scan_document(document, CLAIMS)) + + +if __name__ == "__main__": + unittest.main()