Checkpoint for the 2026-09-11 project review remediation. Work by a previous agent that ran out of context; committed unchanged as a rollback point before the remaining findings land on top of it. F01 — the two agent directories had drifted into different processes - Six shared workflows under `resume_builder/reference/workflows/`; both `.agents/skills/` and `.claude/skills/` are now thin entrypoints that route to the same file instead of carrying full copies. - The `.claude` copies were the stale side: fixed bullet line variants and character budgets that the newer process had already dropped. Removing them is the point of the change, not collateral. - Parity/link regression test added; corrected the top-level file map, which pointed at a directory that does not exist. F02 — application status was stored in four places and disagreed - New registry `job_scout/state/applications.json` (26 records) with `application_records.py` (schema, validation, transactional write) and `application_tracker.py` (add/update, no quota). - AGENTS.md and CLAUDE.md no longer keep their own status tables; both point at `current_applications.md`, generated from the registry. - Interview-reached is now independent of outcome, so a withdrawal after an interview can record both facts. The old tables are preserved verbatim in `job_scout/state/history/application_status_before_2026-09-11.json`. Verification: `python -m unittest discover -s tests -v` — 18/18 pass. Spot-checked that the detailed SBB and Kdo Cy notes survived the migration into the registry rather than being flattened. Known unresolved, deliberately not invented (see FIX_PROGRESS.md): Aker BP's application date and its Adjacent-despite-79 classification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate canonical resume data, generation sources, and generated documents.
|
|
|
|
Usage:
|
|
python resume_builder/helpers/validate_resume_system.py
|
|
python resume_builder/helpers/validate_resume_system.py --document output/Acme/resume.tex
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
CLAIMS_PATH = ROOT / "resume_builder" / "canonical" / "claims.json"
|
|
HISTORY_PATH = ROOT / "resume_builder" / "canonical" / "historical_outputs.json"
|
|
|
|
|
|
def load_json(path: Path) -> dict:
|
|
with path.open(encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def canonical_checks(claims: dict, history: dict) -> tuple[list[str], list[str]]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
if claims.get("schema_version") != 1:
|
|
errors.append("claims.json schema_version must be 1")
|
|
if history.get("schema_version") != 1:
|
|
errors.append("historical_outputs.json schema_version must be 1")
|
|
|
|
for key in ("identity", "education", "employment", "claims", "skills"):
|
|
if not claims.get(key):
|
|
errors.append(f"claims.json missing non-empty {key}")
|
|
|
|
for collection in ("education", "employment", "claims"):
|
|
ids = [item.get("id") for item in claims.get(collection, [])]
|
|
if None in ids:
|
|
errors.append(f"{collection} contains an item without id")
|
|
duplicates = sorted({item for item in ids if ids.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"duplicate {collection} ids: {', '.join(duplicates)}")
|
|
|
|
skills = claims.get("skills", [])
|
|
skill_names = [item.get("name") for item in skills]
|
|
duplicates = sorted({item for item in skill_names if skill_names.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"duplicate skills: {', '.join(duplicates)}")
|
|
for skill in skills:
|
|
if skill.get("output") not in {
|
|
"allowed",
|
|
"allowed-with-context",
|
|
"certification-context-only",
|
|
"forbidden",
|
|
}:
|
|
errors.append(f"invalid output policy for skill {skill.get('name')}")
|
|
|
|
config = read_text(ROOT / "config.md")
|
|
bundle_names = re.findall(r"\|\s*(bundle_[a-z0-9_]+\.md)\s*\|", config)
|
|
for bundle_name in bundle_names:
|
|
if not (ROOT / "resume_builder" / "bundles" / bundle_name).exists():
|
|
errors.append(f"configured bundle does not exist: {bundle_name}")
|
|
|
|
expected_files = [
|
|
ROOT / "resume_builder" / "templates" / "resume_template.tex",
|
|
ROOT / "resume_builder" / "reference" / "resume_reference.md",
|
|
ROOT / "resume_builder" / "reference" / "critique_framework.md",
|
|
]
|
|
for path in expected_files:
|
|
if not path.exists():
|
|
errors.append(f"required workflow file missing: {path.relative_to(ROOT)}")
|
|
|
|
history_folders: list[str] = []
|
|
for key in ("unsafe_do_not_reuse", "historical_revalidate"):
|
|
for item in history.get(key, []):
|
|
history_folders.append(item["folder"] if isinstance(item, dict) else item)
|
|
duplicates = sorted({item for item in history_folders if history_folders.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"historical output folders classified twice: {', '.join(duplicates)}")
|
|
|
|
if claims["identity"].get("swiss_permit", "").startswith("UNVERIFIED"):
|
|
warnings.append("Swiss permit type remains unverified; generators must ask before naming it")
|
|
return errors, warnings
|
|
|
|
|
|
def workflow_checks() -> list[str]:
|
|
errors: list[str] = []
|
|
checks = {
|
|
ROOT / "resume_builder" / "templates" / "resume_template.tex": [
|
|
"Research Experience",
|
|
"FLIPPED format",
|
|
"Selected Publications",
|
|
],
|
|
ROOT / "resume_builder" / "reference" / "resume_reference.md": [
|
|
"ALL variable bullets",
|
|
"<= 3 lines white space",
|
|
"20-21 variable bullets",
|
|
],
|
|
ROOT / "resume_builder" / "reference" / "workflows" / "critique.md": [
|
|
"Publications 10%",
|
|
"Eight-Dimension Scoring",
|
|
],
|
|
}
|
|
for path, forbidden_phrases in checks.items():
|
|
text = read_text(path).lower()
|
|
for phrase in forbidden_phrases:
|
|
if phrase.lower() in text:
|
|
errors.append(f"obsolete workflow rule remains in {path.relative_to(ROOT)}: {phrase}")
|
|
return errors
|
|
|
|
|
|
def scan_document(path: Path, claims: dict) -> list[str]:
|
|
errors: list[str] = []
|
|
text = read_text(path)
|
|
lowered = text.lower()
|
|
for pattern in claims.get("global_forbidden_output_patterns", []):
|
|
if pattern.lower() in lowered:
|
|
errors.append(f"{path}: forbidden output pattern: {pattern}")
|
|
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):
|
|
if "\\begin{rSubsection}" not in line:
|
|
continue
|
|
if re.search(r"Production Ownership|AI-Ready|Customer-Embedded|Shipping ML", line, re.I):
|
|
errors.append(f"{path}:{line_number}: marketing theme used before formal employer/title")
|
|
return errors
|
|
|
|
|
|
TRIPLE = re.compile(r"\w+, [^,]{3,45}, [^,]{3,45} and |\w+, [^,]{3,45} and ")
|
|
|
|
|
|
def _bullet_blocks(text: str) -> list[list[str]]:
|
|
"""Bullets grouped by rSubsection (one group per position)."""
|
|
body = re.search(r"\\begin\{document\}(.*)\\end\{document\}", text, re.S)
|
|
body_text = body.group(1) if body else text
|
|
blocks = re.split(r"\\begin\{rSubsection\}", body_text)[1:]
|
|
return [re.findall(r"\\item\s+(.+)", block) for block in blocks]
|
|
|
|
|
|
def _visible_text(bullet: str) -> str:
|
|
"""Bullet text with LaTeX markup removed, so \\textbf{Owned ...} reads as 'Owned ...'."""
|
|
text = re.sub(r"\\[a-zA-Z]+\*?(\[[^]]*\])?", " ", bullet)
|
|
return re.sub(r"[{}$\\]", " ", text)
|
|
|
|
|
|
def _opening_word(bullet: str) -> str:
|
|
match = re.search(r"[A-Za-z]+", _visible_text(bullet))
|
|
return match.group(0).lower() if match else ""
|
|
|
|
|
|
def cadence_checks(path: Path) -> list[str]:
|
|
"""Bullet cadence diagnostics (resume_reference.md 7a).
|
|
|
|
Warnings only, never errors. Uniform rhythm is a style flaw, not a truth flaw:
|
|
no claim, scope or hedged verb may be bent to satisfy these. Reshape sentences.
|
|
"""
|
|
blocks = _bullet_blocks(read_text(path))
|
|
bullets = [b for block in blocks for b in block]
|
|
if len(bullets) < 6:
|
|
return []
|
|
|
|
warnings: list[str] = []
|
|
triples = [b for b in bullets if TRIPLE.search(b)]
|
|
share = len(triples) / len(bullets)
|
|
if share > 0.5:
|
|
warnings.append(
|
|
f"{path}: {len(triples)}/{len(bullets)} bullets ({share:.0%}) use an "
|
|
f"'X, Y and Z' triple (reference 7a: under ~50%). Reshape sentences, not claims."
|
|
)
|
|
|
|
for position, block in enumerate(blocks, 1):
|
|
for index in range(1, len(block)):
|
|
previous, current = _opening_word(block[index - 1]), _opening_word(block[index])
|
|
if previous and previous == current:
|
|
warnings.append(
|
|
f"{path}: position {position}, bullets {index}-{index + 1} both open "
|
|
f"with '{previous}'"
|
|
)
|
|
# Threshold calibrated on the 50 multi-bullet positions in output/: spreads are
|
|
# bimodal (1-9, then 15-17). <=4 flags the tight tail (~28%), not the median.
|
|
lengths = [len(_visible_text(b).split()) for b in block]
|
|
if len(lengths) >= 4 and max(lengths) - min(lengths) <= 4:
|
|
warnings.append(
|
|
f"{path}: position {position} bullets cluster at {min(lengths)}-{max(lengths)} "
|
|
f"words; vary length within the position, not just across the document."
|
|
)
|
|
return warnings
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--document", action="append", default=[], help="Generated .tex file to validate")
|
|
args = parser.parse_args()
|
|
|
|
claims = load_json(CLAIMS_PATH)
|
|
history = load_json(HISTORY_PATH)
|
|
errors, warnings = canonical_checks(claims, history)
|
|
errors.extend(workflow_checks())
|
|
for document in args.document:
|
|
path = Path(document)
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
if not path.exists():
|
|
errors.append(f"document not found: {path}")
|
|
else:
|
|
errors.extend(scan_document(path, claims))
|
|
warnings.extend(cadence_checks(path))
|
|
|
|
for warning in warnings:
|
|
print(f"WARN: {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))")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|