fix(F15-F16): stop losing dates, contacts and whole daily reports

F15 - dates and metadata kept their meaning
- The Taleo parser stored an application DEADLINE in `posted`, so a role closing
  on 30 September looked as though it had been published then - backwards for a
  freshness signal. It now populates `deadline`, and `posted` stays empty
  because that board genuinely publishes none.
- The Prospective adapter read unit, Lohnklasse and a named contact, folded them
  into `description` for scoring, and `_process_company` then discarded
  `description` - losing exactly the fields that decide level, pay and whether a
  cold submit is necessary. They are now carried as fields through to the report
  and the JSON dump, alongside `date_provenance`.
- Named contacts now appear in report rows. Channel is the rationed variable
  since 2026-09-05, so a published, reachable name is the difference between a
  cold submit being a choice and being the only option.

F16 - writes stopped destroying delivered work
- Every run of a day wrote `<date>.md`/`.json`, so a restricted run replaced the
  full daily report. This is not hypothetical: it happened twice in this session
  and both times the 37-company run had to be repeated by hand. Restricted runs
  now write `<date>.only-kdocy.md` and say so.
- `save_seen()` ran in a `finally:` BEFORE the report was written. A failed
  report write therefore left roles marked seen, so a `--new-only` retry would
  hide precisely the hits that were never delivered. Seen-state is now committed
  only after report and dump are on disk - and never by a restricted run, which
  did not look at the whole board.
- Atomic writes used a fixed `.tmp` name; two writers could share it and
  os.replace the wrong content into place. Unique temp names now.

34/34 tests pass; validated on a live 37-company run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-09-11 20:19:57 +02:00
1 parent ce8619b8e3
commit c7e8d4e058
2 files changed
+79 -15

No files matched your search

+12 -6
View File
@@ -6,12 +6,13 @@ Do not rewrite submitted application archives or invent missing evidence.
## Resume point ## Resume point
F01–F14 complete. **Next: F15** — typed dates and retained contact/compensation metadata F01–F16 complete. **Next: F17** — reproducible builds and safe historical-archive
(`job_scout/scout.py`: the Taleo parser writes a deadline into `posted`, and the Prospective classification. Start from the 13 older packages listed in the F05/F06 verification notes
path's unit/Lohnklasse/contact are folded into `description`, which `_process_company` discards). below; they carry genuine findings under today's rules and must NOT be silently rewritten.
F17 also needs the 45-document LaTeX sweep, which has not been re-run this session.
Read this file and inspect `git log`/`git diff` before resuming; do not repeat completed work. Read this file and inspect `git log`/`git diff` before resuming; do not repeat completed work.
Checkpoints: `afdef7e` (F01+F02) · `fc5f3e4` (F03–F06) · `ce5b7f5` (F07) · `3519b2f` (F08–F10) · `afd8174` (F11–F13) · plus F14. Checkpoints: `afdef7e` (F01+F02) · `fc5f3e4` (F03–F06) · `ce5b7f5` (F07) · `3519b2f` (F08–F10) · `afd8174` (F11–F13) · `ce8619b` (F14) · plus F15–F16.
## User rulings — 2026-09-11 ## User rulings — 2026-09-11
@@ -40,8 +41,8 @@ Checkpoints: `afdef7e` (F01+F02) · `fc5f3e4` (F03–F06) · `ce5b7f5` (F07) ·
| F12 | DONE | Whole-word place matching (Bernau no longer reads as Bern); ~25 Swiss towns incl. Thun and Zimmerwald; Danish cities; pan-regional scope no longer beats an explicit onsite/one-country restriction. All 14 review cases correct | | F12 | DONE | Whole-word place matching (Bernau no longer reads as Bern); ~25 Swiss towns incl. Thun and Zimmerwald; Danish cities; pan-regional scope no longer beats an explicit onsite/one-country restriction. All 14 review cases correct |
| F13 | DONE | Identifying query params kept in the key; read and write paths share `lookup_decision`. **Live run recovered 7 reposted RUAG decisions**, labelled RELATED rather than applied as identical | | F13 | DONE | Identifying query params kept in the key; read and write paths share `lookup_decision`. **Live run recovered 7 reposted RUAG decisions**, labelled RELATED rather than applied as identical |
| F14 | DONE | Buckets renamed to keyword relevance with an explicit 'NOT a fit judgement'; German titles and platform/DevOps vocabulary added. Kdo Cy *ICT-System Engineer (DevSecOps/Platform Automation)* went 0→10; *Senior Dateningenieur* 1→3, level with its English twin | | F14 | DONE | Buckets renamed to keyword relevance with an explicit 'NOT a fit judgement'; German titles and platform/DevOps vocabulary added. Kdo Cy *ICT-System Engineer (DevSecOps/Platform Automation)* went 0→10; *Senior Dateningenieur* 1→3, level with its English twin |
| F15 | TODO | Typed dates and retained contact/compensation metadata | | F15 | DONE | Taleo deadline moved out of `posted` (a closing date was making roles look freshly published); `unit`, `compensation`, `contact` and `date_provenance` carried as fields through to the report and JSON. Named contacts now surface — directly serving the channel rule |
| F16 | TODO | Run-scoped reports, transactional persistence and scan coverage | | F16 | DONE | Restricted runs write `<date>.only-x.md` and leave the daily report intact; seen-state committed only after the report is on disk, and never by a restricted run; unique temp names for atomic writes |
| F17 | TODO | Reproducible new builds and safe historical archive classification | | F17 | TODO | Reproducible new builds and safe historical archive classification |
| F18 | TODO | Evidence earlier on page one; rendered skills budget | | F18 | TODO | Evidence earlier on page one; rendered skills budget |
| F19 | TODO | Education layout, phrasing guidance, reproducible word counts | | F19 | TODO | Education layout, phrasing guidance, reproducible word counts |
@@ -90,6 +91,11 @@ mid-remediation — it would destroy the ability to attribute a regression to a
- F14 note: the score floor on RUAG/SBB/BKW/PostFinance/Kdo Cy was a workaround for missing German - F14 note: the score floor on RUAG/SBB/BKW/PostFinance/Kdo Cy was a workaround for missing German
vocabulary. The vocabulary is now the fix; the floors can probably be reduced later, but they were vocabulary. The vocabulary is now the fix; the floors can probably be reduced later, but they were
left in place to keep this diff attributable. left in place to keep this diff attributable.
- F15/F16 validated on a live 37-company run: named contacts with phone numbers now appear in
report rows, NATO/Taleo closing dates render as Bewerbungsfrist rather than Posted, and
`--only=kdocy` wrote `2026-09-11.only-kdocy.md` while leaving the full report untouched.
- F16 context: the daily-report clobber is not hypothetical. It happened twice in this session
(2026-09-10 and 09-11) and both times the full run had to be repeated by hand.
- Not re-run this session: the 45-document LaTeX build sweep and the visual sampling. - Not re-run this session: the 45-document LaTeX build sweep and the visual sampling.
## Completion rules ## Completion rules
+67 -9
View File
@@ -38,6 +38,7 @@ import os
import re import re
import ssl import ssl
import sys import sys
import tempfile
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import lru_cache from functools import lru_cache
@@ -1172,15 +1173,19 @@ def _parse_taleo_jobsearch_html(html, host, career_section):
deadline = _unq(m.group(5)) deadline = _unq(m.group(5))
body = _unq(m.group(6)) body = _unq(m.group(6))
grade = _unq(m.group(7)) grade = _unq(m.group(7))
# List view exposes application deadline only (not a posted-on date). We store # List view exposes an application DEADLINE only, never a posted-on date. It used to
# the date part in `posted` so the report surfaces the closing date. # be stored in `posted`, which made a role closing soon look freshly published -
# backwards for a freshness signal (F15). It now goes to `deadline`, and `posted`
# stays empty because this board genuinely does not publish one.
deadline_short = deadline.split(",")[0].strip() if deadline else "" deadline_short = deadline.split(",")[0].strip() if deadline else ""
jobs.append({ jobs.append({
"id": job_no, "id": job_no,
"title": title, "title": title,
"location": loc, "location": loc,
"url": f"{detail_base}?job={job_no}&lang=en", "url": f"{detail_base}?job={job_no}&lang=en",
"posted": deadline_short, "posted": "",
"deadline": deadline_short,
"date_provenance": "taleo list view exposes a closing date only; no publication date",
"description": f"{body} {grade}".strip()[:500], "description": f"{body} {grade}".strip()[:500],
}) })
if jobs: if jobs:
@@ -1349,6 +1354,13 @@ def fetch_prospective(args):
"url": (j.get("links") or {}).get("directlink", ""), "url": (j.get("links") or {}).get("directlink", ""),
"posted": (j.get("start_date") or "")[:10], "posted": (j.get("start_date") or "")[:10],
"deadline": deadline, "deadline": deadline,
"date_provenance": "prospective start_date = publication; end_date = application deadline",
# Carried as fields, not just folded into description: these decide level, pay and
# whether a cold submit is even necessary (F15).
"unit": unit,
"compensation": f"Lohnklasse {lohnklasse}" if lohnklasse else "",
"contact": re.sub(r"\s+", " ", html.unescape(
re.sub(r"<[^>]+>", " ", str(szas.get("sza_contact", ""))))).strip()[:300],
"description": re.sub(r"\s+", " ", desc)[:1200], "description": re.sub(r"\s+", " ", desc)[:1200],
}) })
return jobs return jobs
@@ -1871,9 +1883,17 @@ def score_job(job, title_only=False):
def _atomic_write_json(path, data): def _atomic_write_json(path, data):
"""Write JSON via temp + os.replace so a crash mid-write cannot truncate state files.""" """Write JSON via temp + os.replace so a crash mid-write cannot truncate state files."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") # Unique temp name (F16): a fixed ".tmp" let two concurrent writers share one temp file
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") # and os.replace the wrong content into place.
os.replace(tmp, path) descriptor, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(json.dumps(data, indent=2, ensure_ascii=False))
os.replace(tmp, path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
def load_seen(): def load_seen():
@@ -2185,6 +2205,14 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
lines.append(f"- Posted: {r['posted']}") lines.append(f"- Posted: {r['posted']}")
if r.get("deadline"): if r.get("deadline"):
lines.append(f"- **Bewerbungsfrist: {r['deadline']}**") lines.append(f"- **Bewerbungsfrist: {r['deadline']}**")
if r.get("unit"):
lines.append(f"- Einheit: {r['unit']}")
if r.get("compensation"):
lines.append(f"- Vergütung: {r['compensation']}")
if r.get("contact"):
# Channel is the rationed variable: a published, reachable name means a cold
# submit is a choice rather than the only option.
lines.append(f"- **Ansprechperson: {r['contact']}**")
lines.append(f"- URL: {r['url']}") lines.append(f"- URL: {r['url']}")
if d: if d:
note = f" — {d['note']}" if d.get("note") else "" note = f" — {d['note']}" if d.get("note") else ""
@@ -2332,6 +2360,10 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape):
"title": j["title"], "location": j["location"], "title": j["title"], "location": j["location"],
"url": j["url"], "posted": j.get("posted", ""), "url": j["url"], "posted": j.get("posted", ""),
"deadline": j.get("deadline", ""), "deadline": j.get("deadline", ""),
"date_provenance": j.get("date_provenance", ""),
"unit": j.get("unit", ""),
"compensation": j.get("compensation", ""),
"contact": j.get("contact", ""),
"score": score, "pos": pos, "neg": neg, "score": score, "pos": pos, "neg": neg,
"in_ch": in_ch, "remote": is_remote, "relocation": is_relocation, "in_ch": in_ch, "remote": is_remote, "relocation": is_relocation,
"is_new": is_new, "is_new": is_new,
@@ -2469,7 +2501,9 @@ def main():
"scraped": stat["scraped"], "date": today, "display": display, "scraped": stat["scraped"], "date": today, "display": display,
} }
finally: finally:
save_seen(seen) # Seen-state is NOT committed here (F16). It used to be, which meant a failed report
# write left roles marked as seen and a `--new-only` retry hid exactly the hits that
# were never delivered. It is committed after the report is on disk.
save_last_scrape(new_last_scrape) save_last_scrape(new_last_scrape)
_close_browser() _close_browser()
@@ -2481,17 +2515,33 @@ def main():
all_results.sort(key=lambda r: (-r["score"], r["company"], r["title"])) all_results.sort(key=lambda r: (-r["score"], r["company"], r["title"]))
REPORTS_DIR.mkdir(parents=True, exist_ok=True) REPORTS_DIR.mkdir(parents=True, exist_ok=True)
report_path = REPORTS_DIR / f"{today}.md" # Run scope in the filename (F16). A partial run used to overwrite the full daily report:
# `--only=kdocy` replaced a 37-company report with seven rows, and the full run had to be
# repeated by hand. Only an unrestricted run owns the plain `<date>` name.
scope = []
if only:
scope.append(f"only-{only}")
if new_only:
scope.append("new-only")
if hide_decided:
scope.append("undecided")
stem = today if not scope else f"{today}.{'.'.join(scope)}"
if scope:
print(f"Restricted run: writing {stem}.md/.json, leaving the full daily report intact.",
file=sys.stderr)
report_path = REPORTS_DIR / f"{stem}.md"
write_report(report_path, all_results, errors, new_only, include_weak, write_report(report_path, all_results, errors, new_only, include_weak,
stats=stats, total_secs=total_secs, stats=stats, total_secs=total_secs,
decisions=dec_index, hide_decided=hide_decided) decisions=dec_index, hide_decided=hide_decided)
# Plain data dump of every eligible role, unfiltered by keyword score — fit judgment # Plain data dump of every eligible role, unfiltered by keyword score — fit judgment
# for these is done in conversation against the profile, not by the keyword scorer. # for these is done in conversation against the profile, not by the keyword scorer.
json_path = REPORTS_DIR / f"{today}.json" json_path = REPORTS_DIR / f"{stem}.json"
dump = [{ dump = [{
"company": r["company"], "title": r["title"], "location": r["location"], "company": r["company"], "title": r["title"], "location": r["location"],
"url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""), "url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""),
"date_provenance": r.get("date_provenance", ""), "unit": r.get("unit", ""),
"compensation": r.get("compensation", ""), "contact": r.get("contact", ""),
"is_new": r["is_new"], "is_new": r["is_new"],
"decision": lookup_decision(dec_index, decisions, r["url"]), "decision": lookup_decision(dec_index, decisions, r["url"]),
"keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]}, "keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]},
@@ -2501,6 +2551,14 @@ def main():
} for r in all_results] } for r in all_results]
_atomic_write_json(json_path, dump) _atomic_write_json(json_path, dump)
# Report and dump are on disk, so the run genuinely delivered: now the roles may be
# marked seen. A restricted run must not mark anything seen at all - it did not look at
# the whole board, and doing so would hide those roles from the next full run.
if scope:
print("Restricted run: seen-state deliberately NOT updated.", file=sys.stderr)
else:
save_seen(seen)
n_new = sum(1 for r in all_results if r["is_new"]) n_new = sum(1 for r in all_results if r["is_new"])
print(f"\nReport written: {report_path}", file=sys.stderr) print(f"\nReport written: {report_path}", file=sys.stderr)
print(f"Total matches: {len(all_results)} ({n_new} new) | " print(f"Total matches: {len(all_results)} ({n_new} new) | "