diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9189f8..6318b11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,14 +73,46 @@ jobs: - run: pip install -r service/requirements-test.txt - run: python -m pytest service/tests tests/test_ingest.py -q revalidate: - name: weekly re-validation (report only) + name: weekly re-validation + answers re-check if: github.event_name == 'schedule' runs-on: ubuntu-latest + permissions: + contents: write # pushes the rechecked answers files to a recheck/ branch; the PR is opened by hand steps: - uses: actions/checkout@v4 + - name: the app's parsers (csv / feed / xml / text samples are parsed exactly as its cards parse them) + uses: actions/checkout@v4 + with: + repository: SecureCloudGroup/SmartBrain_3000 + path: .app + sparse-checkout: app/smartbrain_3000 - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install "httpx==0.28.1" "duckdb==1.5.4" - - run: python -m sourcetool validate --only curated - - run: git diff --stat + - run: pip install "httpx==0.28.1" "duckdb==1.5.4" cryptography + - name: re-validate curated records (report only) + run: python -m sourcetool validate --only curated || true + - name: re-check every answers file against a fresh live sample + id: recheck + env: + SMARTBRAIN_APP: ${{ github.workspace }}/.app/app + run: | + set +e + python -m sourcetool answers-recheck | tee recheck.log + echo "rc=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: push the changed answers files for a PR + if: always() + run: | + rm -rf .app + git add -A answers sources + if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi + BR="recheck/$(date -u +%F)" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -q -m "answers-recheck $(date -u +%F): $(tail -1 recheck.log)" + git push -f origin "HEAD:refs/heads/$BR" + { echo "## answers-recheck"; echo; echo '```'; cat recheck.log; echo '```'; echo; + echo "Branch \`$BR\` pushed — open the PR: https://github.com/${GITHUB_REPOSITORY}/compare/main...$BR"; } >> "$GITHUB_STEP_SUMMARY" + - name: fail the run when any file failed or drifted + if: steps.recheck.outputs.rc != '0' + run: exit 1 diff --git a/answers/bbc-world.json b/answers/bbc-world.json index 760ad5e..3e6c3bb 100644 --- a/answers/bbc-world.json +++ b/answers/bbc-world.json @@ -40,5 +40,5 @@ } ], "sample_url": "https://feeds.bbci.co.uk/news/world/rss.xml", - "checked": "2026-09-28" + "checked": "2026-10-06" } diff --git a/answers/fred-apu0000702111.json b/answers/fred-apu0000702111.json index b3bb68b..a09c18a 100644 --- a/answers/fred-apu0000702111.json +++ b/answers/fred-apu0000702111.json @@ -69,5 +69,5 @@ } ], "sample_url": "https://fred.stlouisfed.org/graph/fredgraph.csv?id=APU0000702111", - "checked": "2026-09-28" + "checked": "2026-10-06" } diff --git a/answers/npr-news.json b/answers/npr-news.json index ec767ae..67c4dfc 100644 --- a/answers/npr-news.json +++ b/answers/npr-news.json @@ -30,5 +30,5 @@ } ], "sample_url": "https://feeds.npr.org/1001/rss.xml", - "checked": "2026-09-28" + "checked": "2026-10-06" } diff --git a/answers/nws-alerts-point.json b/answers/nws-alerts-point.json index ec6a8b0..f1d11b4 100644 --- a/answers/nws-alerts-point.json +++ b/answers/nws-alerts-point.json @@ -67,5 +67,5 @@ } ], "sample_url": "https://api.weather.gov/alerts/active?point=63.45,-145.75", - "checked": "2026-09-29" + "checked": "2026-10-06" } diff --git a/answers/open-meteo-forecast.json b/answers/open-meteo-forecast.json index 2e9c241..bae6575 100644 --- a/answers/open-meteo-forecast.json +++ b/answers/open-meteo-forecast.json @@ -339,5 +339,5 @@ } ], "sample_url": "https://api.open-meteo.com/v1/forecast?latitude=32.7765&longitude=-79.9311¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,precipitation_probability,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,snowfall_sum,sunrise,sunset&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=auto", - "checked": "2026-09-29" + "checked": "2026-10-06" } diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index cad6aea..402a7e8 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -26,7 +26,7 @@ Each source is one JSON object per line in `sources/**/*.jsonl`. Users' suggesti | `votes` | `{yes, no}`, aggregated from users' Yes/No answers through the Library API. | | `signals` | Harvest evidence: monthly views, last data update, column names. | | `origin` | Who or what created the record, and when. | -| `answers` | Curated sources: which paths of the response answer which questions, so the app builds cards without guessing paths. Authored in `answers/.json` (`{source_id, answers, sample_url, checked}`) and merged into the record by `sourcetool build`. A source has 1 to 12 answers, each `{name, label, words, primary?, kind: value\|list\|columns, ...}`; a path segment may be a whole `{param}` of the record, a key the plain names can't spell is quoted (`description["#cdata-section"]`), and a list may `filter` its rows by one or by a short fixed value the response uses (`"Final"`). A `time` whose values carry no zone is shown as the source's local clock; `"utc": true` says those zoneless values are UTC. Spec v1.2 adds what an answer delivers (below). The closed keys, types and limits are in `sourcetool/answers.py` (`answer_problems`), and every path must resolve in a real sample (`check_sample`, run live by `sourcetool answers-check`): the first row of a list in full, and in every later row and every column element a present cell must hold its declared type while a cell the row lacks is allowed (the card shows "—" for it). | +| `answers` | Curated sources: which paths of the response answer which questions, so the app builds cards without guessing paths. Authored in `answers/.json` (`{source_id, answers, sample_url, checked}`, plus `status` and `last_failure` written only by `sourcetool answers-recheck`: a file whose fresh live sample fails the check on two later days is `"drifted"`, `build` holds it back and the app offers the source as a link until a recheck passes and drops the keys again) and merged into the record by `sourcetool build`. A source has 1 to 12 answers, each `{name, label, words, primary?, kind: value\|list\|columns, ...}`; a path segment may be a whole `{param}` of the record, a key the plain names can't spell is quoted (`description["#cdata-section"]`), and a list may `filter` its rows by one or by a short fixed value the response uses (`"Final"`). A `time` whose values carry no zone is shown as the source's local clock; `"utc": true` says those zoneless values are UTC. Spec v1.2 adds what an answer delivers (below). The closed keys, types and limits are in `sourcetool/answers.py` (`answer_problems`), and every path must resolve in a real sample (`check_sample`, run live by `sourcetool answers-check`): the first row of a list in full, and in every later row and every column element a present cell must hold its declared type while a cell the row lacks is allowed (the card shows "—" for it). | ### Answers spec v1.2: what an answer delivers diff --git a/sourcetool/__main__.py b/sourcetool/__main__.py index 330e38e..ee1c800 100644 --- a/sourcetool/__main__.py +++ b/sourcetool/__main__.py @@ -18,6 +18,8 @@ python -m sourcetool answers-check [ID ...] fetch samples, check answers/ paths (live) python -m sourcetool answers-check --lint record + keyword lints (offline, no fetch) python -m sourcetool answers-generate write answers for curated feeds + FRED series + python -m sourcetool answers-recheck [--since DAYS] [ID ...] re-fetch live samples; PASS stamps checked, + two failures on later days mark the file drifted python -m sourcetool asks-overlap --against FILE [FILE ...] report asks that overlap any labeled set (exact + jaccard>=0.75); exit nonzero if any @@ -210,6 +212,11 @@ def cmd_answers_generate(args) -> int: return answers.cmd_generate(args) +def cmd_answers_recheck(args) -> int: + from . import recheck + return recheck.main(args) + + def cmd_asks_overlap(args) -> int: """`asks-overlap --against FILE [FILE ...]`: report asks that overlap labeled eval sets outside the repo. Prints a per-file breakdown (exact / near) and returns 1 if any overlap is found.""" @@ -236,7 +243,7 @@ def cmd_asks_overlap(args) -> int: def main(argv: list[str]) -> int: if not argv or argv[0] not in {"check", "validate", "harvest", "build", "lookup", "coverage", "resolvers", "policies", "fills", "evalres", "evallookup", "ingest", "answers-check", - "answers-generate", "asks-overlap"}: + "answers-generate", "answers-recheck", "asks-overlap"}: print(__doc__) return 2 return globals()["cmd_" + argv[0].replace("-", "_")](argv[1:]) diff --git a/sourcetool/answers.py b/sourcetool/answers.py index 62bdfc4..afa6bdc 100644 --- a/sourcetool/answers.py +++ b/sourcetool/answers.py @@ -48,6 +48,10 @@ ROW_KEYS = {"path", "label", "type", "unit", "unit_path", "utc", "tbd_if"} # utc: zoneless times are UTC COLUMN_KEYS = (ROW_KEYS - {"tbd_if"}) | {"codes"} FILE_KEYS = {"source_id", "answers", "sample_url", "checked"} +# spec v1.3 (2026-10-06): written by `answers-recheck`, never by hand — a file whose live sample failed the check +# twice on later days is "drifted": build holds it back and the app offers the source as a link until it passes again +FILE_OPTIONAL_KEYS = {"status", "last_failure"} +FILE_STATUSES = ("drifted",) MAX_ANSWERS = 12 _NAME = re.compile(r"[a-z][a-z0-9_]{0,39}") @@ -585,10 +589,25 @@ def params_of(rec: dict) -> dict: return {q["name"]: q.get("example") for q in rec["access"].get("params", [])} -def load_answers(params_by_id: dict[str, dict], directory: Path = ANSWERS) -> tuple[dict[str, list], list[str]]: +def held_back(directory: Path = ANSWERS) -> list[str]: + """The source ids whose answers file is marked drifted (served as links until a recheck passes).""" + out = [] + for f in sorted(directory.glob("*.json")): + try: + d = json.loads(f.read_text()) + except ValueError: + continue + if isinstance(d, dict) and d.get("status") == "drifted": + out.append(f.stem) + return out + + +def load_answers(params_by_id: dict[str, dict], directory: Path = ANSWERS, + include_drifted: bool = False) -> tuple[dict[str, list], list[str]]: """Read every answers/.json: ({source_id: answers}, problems). `params_by_id` maps each record id to its params (`params_of`). A file whose id has no record, or whose answers fail the schema, is a - problem (build refuses it).""" + problem (build refuses it). A file marked drifted is validated but left out unless `include_drifted` + (the live check and the recheck still want it; build and the lints do not).""" out: dict[str, list] = {} errs: list[str] = [] for f in sorted(directory.glob("*.json")): @@ -597,8 +616,8 @@ def load_answers(params_by_id: dict[str, dict], directory: Path = ANSWERS) -> tu except ValueError as e: errs.append(f"{f.name}: not JSON ({e})") continue - if not isinstance(d, dict) or set(d) != FILE_KEYS: - errs.append(f"{f.name}: must hold exactly {sorted(FILE_KEYS)}") + if not isinstance(d, dict) or not FILE_KEYS <= set(d) or not set(d) <= FILE_KEYS | FILE_OPTIONAL_KEYS: + errs.append(f"{f.name}: must hold exactly {sorted(FILE_KEYS)} (plus optional {sorted(FILE_OPTIONAL_KEYS)})") continue sid = d["source_id"] if sid != f.stem: @@ -612,8 +631,12 @@ def load_answers(params_by_id: dict[str, dict], directory: Path = ANSWERS) -> tu bad.append("sample_url must be the https URL fetched") if not isinstance(d["checked"], str) or not _DATE.fullmatch(d["checked"]): bad.append("checked must be YYYY-MM-DD") + if "status" in d and d["status"] not in FILE_STATUSES: + bad.append(f"status must be one of {'/'.join(FILE_STATUSES)}") + if "last_failure" in d and not (isinstance(d["last_failure"], str) and _DATE.fullmatch(d["last_failure"])): + bad.append("last_failure must be YYYY-MM-DD") errs += [f"{f.name}: {e}" for e in bad] - if not bad: + if not bad and (include_drifted or d.get("status") != "drifted"): out[sid] = d["answers"] return out, errs @@ -627,7 +650,10 @@ def _records(tier: str | None = None) -> dict[str, dict]: # --- live samples ---------------------------------------------------------------------------------- class Skip(Exception): - """The source cannot be sampled without a user's own value or key.""" + """The source cannot be sampled HERE: it needs a user's own value or key, or (csv / feed / xml / text) the + app's parsers from a SmartBrain_3000 checkout this machine lacks. A skip is never the source's fault, so + `answers-recheck` leaves the file alone (Phase 2a, 2026-10-06: before this a missing checkout read as FAIL, + and the weekly job would have drifted every non-JSON source within two weeks).""" class Failed(Exception): @@ -645,8 +671,8 @@ def _formats(): try: from smartbrain_3000 import formats except ImportError as e: - raise Failed(f"the app's parsers are missing ({APP}/smartbrain_3000/formats.py: {e}); " - "csv/feed/xml/text samples need a SmartBrain_3000 checkout") from None + raise Skip(f"the app's parsers are missing ({APP}/smartbrain_3000/formats.py: {e}); " + "csv/feed/xml/text samples need a SmartBrain_3000 checkout") from None return formats @@ -751,7 +777,7 @@ def cmd_check(ids: list[str]) -> int: if "--lint" in ids: # offline: the lints only, no sample is fetched return cmd_lint() recs = _records() - loaded, errs = load_answers({k: params_of(r) for k, r in recs.items()}) + loaded, errs = load_answers({k: params_of(r) for k, r in recs.items()}, include_drifted=True) for e in errs: print(f"INVALID {e}") todo = ids or sorted(loaded) diff --git a/sourcetool/build.py b/sourcetool/build.py index f9a5008..a61d022 100644 --- a/sourcetool/build.py +++ b/sourcetool/build.py @@ -159,10 +159,13 @@ def pack_policy(sub: dict) -> dict: def merge_answers(recs: list[dict], directory=None) -> int: """Put each answers/.json file's answers on its record; refuse orphans and malformed answers.""" - from .answers import ANSWERS, load_answers, params_of + from .answers import ANSWERS, held_back, load_answers, params_of loaded, errs = load_answers({r["id"]: params_of(r) for r in recs}, directory or ANSWERS) if errs: raise SystemExit("answers files refused:\n " + "\n ".join(errs)) + held = held_back(directory or ANSWERS) + if held: # drifted since their last pass: the app offers these sources as links until a recheck passes + print(f"{len(held)} answers files held back as drifted: {', '.join(held[:12])}{' …' if len(held) > 12 else ''}") for r in recs: if r["id"] in loaded: r["answers"] = loaded[r["id"]] diff --git a/sourcetool/recheck.py b/sourcetool/recheck.py new file mode 100644 index 0000000..e68bbd4 --- /dev/null +++ b/sourcetool/recheck.py @@ -0,0 +1,134 @@ +"""`sourcetool answers-recheck [--since DAYS] [ID ...]`: re-fetch every answered source's live sample and run +the same check as `answers-check`, then write the result INTO the answers file so `checked` means something. + +PASS → `checked` = today; `last_failure` and `status` are dropped (a recovered source is served again). +FAIL → the first time: `last_failure` = today. A second failure on a LATER day: `status` = "drifted" — `build` +holds the file back and the app offers the source as a link until a later recheck passes. +SKIP (a keyed source, a kind the check cannot parse) leaves the file alone. + +Each file keeps its own formatting (indent, final newline). `--since DAYS` rechecks only files whose `checked` +date is at least that many days old. Exit 1 when any file failed or drifted, so the weekly job turns red and +pushes the changed files for a PR. Phase 2a of the Round 19 plan (2026-10-06): the drift detection the +authoring loop was missing — before this, `checked` was never updated after a file was created. +""" +from __future__ import annotations + +import datetime as _dt +import json +import time +from pathlib import Path + +from .answers import ANSWERS, Failed, Skip, _records, check_sample, fetch_sample, load_answers, params_of + +MAX_PROBLEMS_SHOWN = 3 +NETWORK_RETRY_S = 2.0 # one retry for a connection-level failure: a transient is not drift evidence + + +def _today() -> str: + return _dt.date.today().isoformat() + + +def read_file(path: Path) -> tuple[dict, int, bool]: + """(data, indent, ends_with_newline) — the formatting is kept so a recheck diff is one or two lines.""" + text = path.read_text() + lines = text.splitlines() + indent = (len(lines[1]) - len(lines[1].lstrip())) if len(lines) > 1 else 1 + return json.loads(text), max(indent, 1), text.endswith("\n") + + +def write_file(path: Path, data: dict, indent: int, newline: bool) -> None: + assert isinstance(data, dict) and indent >= 1, "args required" + path.write_text(json.dumps(data, indent=indent, ensure_ascii=False) + ("\n" if newline else "")) + + +def apply_result(data: dict, ok: bool, today: str) -> str: + """Advance the file's recheck state; returns the verdict word: pass | recovered | fail | drifted.""" + assert isinstance(data, dict) and isinstance(today, str), "args required" + if ok: + verdict = "recovered" if data.get("status") == "drifted" else "pass" + data["checked"] = today + data.pop("last_failure", None) + data.pop("status", None) + return verdict + last = data.get("last_failure") + data["last_failure"] = today + if data.get("status") == "drifted" or (isinstance(last, str) and last < today): + data["status"] = "drifted" + return "drifted" + return "fail" + + +def _fetch_with_retry(fetch, rec: dict, *, pause: float = NETWORK_RETRY_S): + """One retry when the fetch itself failed (connection, timeout); an HTTP status, a robots refusal or a + parse failure is the source's answer and is not retried.""" + assert callable(fetch) and isinstance(rec, dict), "args required" + for attempt in range(2): # bounded: at most one retry + try: + return fetch(rec, cache_hours=0) + except Failed as e: + if attempt == 0 and str(e).startswith("fetch failed"): + time.sleep(pause) + continue + raise + raise AssertionError("unreachable: the loop returns or raises") + + +def _due(data: dict, since_days: int, today: str) -> bool: + if since_days <= 0: + return True + checked = _dt.date.fromisoformat(data["checked"]) + return (_dt.date.fromisoformat(today) - checked).days >= since_days + + +def run(ids: list[str], since_days: int = 0, *, directory: Path = ANSWERS, fetch=fetch_sample, + today: str | None = None) -> int: + """Recheck the given ids (all answered sources when empty). Prints one line per file and a summary.""" + assert isinstance(ids, list) and since_days >= 0, "args required" + today = today or _today() + recs = _records() + loaded, errs = load_answers({k: params_of(r) for k, r in recs.items()}, directory, include_drifted=True) + for e in errs: + print(f"INVALID {e}") + counts = {"pass": 0, "recovered": 0, "fail": 0, "drifted": 0, "skip": 0, "not_due": 0} + for sid in ids or sorted(loaded): + if sid not in loaded: + print(f"FAIL {sid}: no valid answers file") + counts["fail"] += 1 + continue + path = directory / f"{sid}.json" + data, indent, newline = read_file(path) + if not _due(data, since_days, today): + counts["not_due"] += 1 + continue + try: + _, sample = _fetch_with_retry(fetch, recs[sid]) + except Skip as e: + print(f"SKIP {sid}: {e}") + counts["skip"] += 1 + continue + except Failed as e: + problems = [str(e)] + else: + problems = check_sample(loaded[sid], sample, params_of(recs[sid])) + verdict = apply_result(data, not problems, today) + counts[verdict] += 1 + write_file(path, data, indent, newline) + detail = "; ".join(problems[:MAX_PROBLEMS_SHOWN]) if problems else f"{len(loaded[sid])} answers" + print(f"{verdict.upper()} {sid}: {detail}") + print(f"recheck {today}: " + ", ".join(f"{k} {v}" for k, v in counts.items() if v) + + f" | {len(errs)} invalid files") + return 1 if counts["fail"] or counts["drifted"] or errs else 0 + + +def main(args: list[str]) -> int: + since = 0 + ids: list[str] = [] + rest = list(args) + while rest: + a = rest.pop(0) + if a == "--since": + assert rest and rest[0].isdigit(), "usage: answers-recheck [--since DAYS] [ID ...]" + since = int(rest.pop(0)) + else: + ids.append(a) + return run(ids, since) diff --git a/tests/test_recheck.py b/tests/test_recheck.py new file mode 100644 index 0000000..e0c9b60 --- /dev/null +++ b/tests/test_recheck.py @@ -0,0 +1,137 @@ +"""answers-recheck: PASS stamps checked, a first failure is remembered, a second on a later day marks the file +drifted, a pass recovers it; drifted files are held back from build and the lints but still seen by the live +check; file formatting survives a rewrite. No network: the fetch is a fake.""" +from __future__ import annotations + +import json + +from sourcetool import recheck +from sourcetool.answers import Failed, Skip, load_answers +from sourcetool.build import merge_answers + + +def _value(**kw): + a = {"name": "temp", "label": "Temperature", "kind": "value", "primary": True, "type": "number", + "path": "current.temperature_2m", "words": ["temperature", "temp", "how hot"], "window": "now"} + a.update(kw) + return a + + +def _write(d, sid, indent=2, newline=True, **extra): + data = {"source_id": sid, "answers": [_value()], "sample_url": "https://ex.test/x", "checked": "2026-09-01", **extra} + (d / f"{sid}.json").write_text(json.dumps(data, indent=indent) + ("\n" if newline else "")) + + +def _records(*sids): + return {s: {"id": s, "tier": "curated", "access": {"kind": "http_json", "params": []}} for s in sids} + + +def test_apply_result_transitions(): + d = {"checked": "2026-09-01"} + assert recheck.apply_result(d, False, "2026-10-06") == "fail" and d["last_failure"] == "2026-10-06" + assert recheck.apply_result(d, False, "2026-10-06") == "fail" # same day again: not yet drift + assert recheck.apply_result(d, False, "2026-10-13") == "drifted" and d["status"] == "drifted" + assert recheck.apply_result(d, False, "2026-10-20") == "drifted" # stays drifted while failing + assert recheck.apply_result(d, True, "2026-10-27") == "recovered" + assert d == {"checked": "2026-10-27"} # keys dropped on recovery + assert recheck.apply_result(d, True, "2026-11-03") == "pass" + + +def test_run_writes_verdicts_and_keeps_formatting(tmp_path, monkeypatch, capsys): + _write(tmp_path, "ok-src", indent=1) + _write(tmp_path, "bad-src", indent=2, newline=False) + _write(tmp_path, "keyed-src") + monkeypatch.setattr(recheck, "_records", lambda: _records("ok-src", "bad-src", "keyed-src")) + + def fetch(rec, cache_hours): + assert cache_hours == 0, "a recheck never reads the sample cache" + if rec["id"] == "ok-src": + return "https://ex.test/x", {"current": {"temperature_2m": 71.3}} + if rec["id"] == "bad-src": + raise Failed("HTTP 500") + raise Skip("needs the user's key") + + rc = recheck.run([], 0, directory=tmp_path, fetch=fetch, today="2026-10-06") + out = capsys.readouterr().out + assert rc == 1 and "PASS ok-src" in out and "FAIL bad-src: HTTP 500" in out and "SKIP keyed-src" in out + ok = (tmp_path / "ok-src.json").read_text() + assert ok.startswith('{\n "source_id"') and ok.endswith("\n") # indent 1 + newline kept + assert json.loads(ok)["checked"] == "2026-10-06" + bad = (tmp_path / "bad-src.json").read_text() + assert bad.startswith('{\n "source_id"') and not bad.endswith("\n") # indent 2, no newline kept + assert json.loads(bad)["last_failure"] == "2026-10-06" and "status" not in json.loads(bad) + assert json.loads((tmp_path / "keyed-src.json").read_text())["checked"] == "2026-09-01" + + rc = recheck.run(["bad-src"], 0, directory=tmp_path, fetch=fetch, today="2026-10-13") + assert rc == 1 and json.loads((tmp_path / "bad-src.json").read_text())["status"] == "drifted" + assert "DRIFTED bad-src" in capsys.readouterr().out + + +def test_since_skips_recently_checked_files(tmp_path, monkeypatch, capsys): + _write(tmp_path, "ok-src", checked="2026-10-05") + monkeypatch.setattr(recheck, "_records", lambda: _records("ok-src")) + calls = [] + rc = recheck.run([], 6, directory=tmp_path, fetch=lambda rec, cache_hours: calls.append(rec) or ("u", {}), + today="2026-10-06") + assert rc == 0 and not calls and "not_due 1" in capsys.readouterr().out + + +def test_drifted_files_are_held_back_but_still_loadable(tmp_path): + _write(tmp_path, "src-a") + _write(tmp_path, "src-b", status="drifted", last_failure="2026-10-01") + params = {"src-a": {}, "src-b": {}} + loaded, errs = load_answers(params, tmp_path) + assert errs == [] and set(loaded) == {"src-a"} + loaded, _ = load_answers(params, tmp_path, include_drifted=True) + assert set(loaded) == {"src-a", "src-b"} + recs = [{"id": "src-a", "access": {}}, {"id": "src-b", "access": {}}] + assert merge_answers(recs, tmp_path) == 1 and "answers" not in recs[1] + + +def test_recheck_keys_are_validated(tmp_path): + _write(tmp_path, "src-a", status="broken") + _write(tmp_path, "src-b", last_failure="yesterday") + _write(tmp_path, "src-c", note="x") + _, errs = load_answers({"src-a": {}, "src-b": {}, "src-c": {}}, tmp_path) + assert any("status must be one of drifted" in e for e in errs) + assert any("last_failure must be YYYY-MM-DD" in e for e in errs) + assert any("src-c.json: must hold exactly" in e for e in errs) + + +def test_missing_app_parsers_skip_instead_of_failing(monkeypatch): + """No SmartBrain_3000 checkout beside the Library (CI, a bare machine): a csv/feed/xml/text sample is a SKIP, + never a FAIL that could drift a healthy source.""" + import sys + import types + + from sourcetool import answers as answers_mod + stub = types.ModuleType("smartbrain_3000") # a package without `formats`, like an older installed app + monkeypatch.setitem(sys.modules, "smartbrain_3000", stub) + monkeypatch.delitem(sys.modules, "smartbrain_3000.formats", raising=False) + try: + answers_mod._formats() + except Skip as e: + assert "parsers are missing" in str(e) + else: + raise AssertionError("expected Skip") + + +def test_connection_failures_are_retried_once(tmp_path, monkeypatch, capsys): + _write(tmp_path, "flaky-src") + _write(tmp_path, "dead-src") + monkeypatch.setattr(recheck, "_records", lambda: _records("flaky-src", "dead-src")) + monkeypatch.setattr(recheck, "NETWORK_RETRY_S", 0.0) + seen = {"flaky-src": 0, "dead-src": 0} + + def fetch(rec, cache_hours): + seen[rec["id"]] += 1 + if rec["id"] == "flaky-src" and seen[rec["id"]] == 1: + raise Failed("fetch failed: ConnectError") + if rec["id"] == "dead-src": + raise Failed("HTTP 404") # the source's own answer: no retry + return "https://ex.test/x", {"current": {"temperature_2m": 71.3}} + + rc = recheck.run([], 0, directory=tmp_path, fetch=fetch, today="2026-10-06") + out = capsys.readouterr().out + assert rc == 1 and "PASS flaky-src" in out and "FAIL dead-src: HTTP 404" in out + assert seen == {"flaky-src": 2, "dead-src": 1}