smoke_record

smoke_record — one marola --summarize run, as the JSON the map shows (MIP-0008 §5.4/§5.5).

scripts/smoke_record.py record --stdout out.txt --exit-code 0 --out-dir site-data/smoke         --run-id 123 --run-url https://github.com/…/actions/runs/123         --model llama3.2:1b --image ghcr.io/<owner>/marola:jvm
scripts/smoke_record.py --self-test        # parses scripts/fixtures/smoke-stdout-*.txt (in `just quality`)

Reads the CLI's own output — the origin -> line, the ranked list (Report.line), the Draft summary: and Reviewer (score N/100, verdict: v): lines — and writes three files under --out-dir: runs/<run-id>.json (everything, stdout included), latest.json (the same, what app.js renders) and history.json (the last 50 runs, newest first, one compact entry each). The deterministic list is the source of truth; the summary is model text and is labelled so in the panel. A run is ok only when the pipeline found beaches, the model produced a draft and the reviewer answered — anything else is recorded with its errors, so a broken run shows up as broken instead of as an old panel. Standard library only.

  1#!/usr/bin/env python3
  2"""smoke_record — one `marola --summarize` run, as the JSON the map shows (MIP-0008 §5.4/§5.5).
  3
  4    scripts/smoke_record.py record --stdout out.txt --exit-code 0 --out-dir site-data/smoke \
  5        --run-id 123 --run-url https://github.com/…/actions/runs/123 \
  6        --model llama3.2:1b --image ghcr.io/<owner>/marola:jvm
  7    scripts/smoke_record.py --self-test        # parses scripts/fixtures/smoke-stdout-*.txt (in `just quality`)
  8
  9Reads the CLI's own output — the `origin ->` line, the ranked list (`Report.line`), the
 10`Draft summary:` and `Reviewer (score N/100, verdict: v):` lines — and writes three files under
 11`--out-dir`: `runs/<run-id>.json` (everything, stdout included), `latest.json` (the same, what
 12app.js renders) and `history.json` (the last 50 runs, newest first, one compact entry each).
 13The deterministic list is the source of truth; the summary is model text and is labelled so in
 14the panel. A run is `ok` only when the pipeline found beaches, the model produced a draft and
 15the reviewer answered — anything else is recorded with its `errors`, so a broken run shows up
 16as broken instead of as an old panel. Standard library only.
 17"""
 18
 19import argparse
 20import datetime as dt
 21import json
 22import re
 23import sys
 24import tempfile
 25from pathlib import Path
 26
 27SCHEMA = 1
 28HISTORY_CAP = 50
 29STDOUT_CAP = 20_000
 30
 31ORIGIN = re.compile(r"^origin -> lat=(-?[\d.]+), lon=(-?[\d.]+) \(radius (\d+)km, source: (.*)\)$")
 32RANKED = re.compile(r"^\s*(\d+)\. \[\s*(\d+)/100\] (.+?)\s+\((\d+\.\d)km\)\s+(.+?)\s+\|")
 33DRAFT = re.compile(r"^Draft summary: (.*)$")
 34REVIEW = re.compile(r"^Reviewer \(score (\d+)/100, verdict: (\w+)\): (.*)$")
 35WATER = re.compile(r"^water quality -> (.*)$")
 36ERRORS = (
 37    "(LLM call failed",
 38    "(reviewer call failed",
 39    "(--summarize needs",
 40    "(nothing to summarize",
 41    "No beaches found nearby",
 42)
 43
 44
 45def parse(stdout: str) -> dict:
 46    """The facts in one run's stdout; `None` where a line never appeared."""
 47    origin = None
 48    water = None
 49    ranked = []
 50    draft = None
 51    review = None
 52    errors = []
 53    for raw in stdout.splitlines():
 54        line = raw.rstrip()
 55        if m := ORIGIN.match(line):
 56            origin = {
 57                "lat": float(m.group(1)),
 58                "lon": float(m.group(2)),
 59                "radius_km": int(m.group(3)),
 60                "source": m.group(4),
 61            }
 62        elif m := WATER.match(line):
 63            water = m.group(1)
 64        elif m := RANKED.match(line):
 65            ranked.append(
 66                {
 67                    "rank": int(m.group(1)),
 68                    "score": int(m.group(2)),
 69                    "name": m.group(3),
 70                    "distance_km": float(m.group(4)),
 71                    "when": m.group(5),
 72                }
 73            )
 74        elif m := DRAFT.match(line):
 75            draft = m.group(1)
 76        elif m := REVIEW.match(line):
 77            review = {"score": int(m.group(1)), "verdict": m.group(2), "summary": m.group(3)}
 78        elif line.startswith(ERRORS):
 79            errors.append(line)
 80    return {
 81        "origin": origin,
 82        "water": water,
 83        "ranked": ranked[:10],
 84        "top_pick": ranked[0] if ranked else None,
 85        "draft": draft,
 86        "review": review,
 87        "errors": errors,
 88    }
 89
 90
 91def record(stdout: str, exit_code: int, meta: dict) -> dict:
 92    facts = parse(stdout)
 93    ok = exit_code == 0 and facts["top_pick"] is not None and facts["review"] is not None
 94    origin = facts.pop("origin") or {}
 95    return {
 96        "schema": SCHEMA,
 97        **meta,
 98        "lat": origin.get("lat"),
 99        "lon": origin.get("lon"),
100        "radius_km": origin.get("radius_km"),
101        "origin_source": origin.get("source"),
102        "exit_code": exit_code,
103        "ok": ok,
104        **facts,
105        "stdout": stdout[-STDOUT_CAP:],
106    }
107
108
109def compact(run: dict) -> dict:
110    top = run.get("top_pick") or {}
111    review = run.get("review") or {}
112    return {
113        "run_id": run["run_id"],
114        "run_url": run["run_url"],
115        "when": run["when"],
116        "lat": run["lat"],
117        "lon": run["lon"],
118        "model": run["model"],
119        "ok": run["ok"],
120        "top_pick": {"name": top.get("name"), "score": top.get("score")} if top else None,
121        "review": {"score": review.get("score"), "verdict": review.get("verdict")}
122        if review
123        else None,
124    }
125
126
127def write(out_dir: Path, run: dict) -> list[Path]:
128    runs = out_dir / "runs"
129    runs.mkdir(parents=True, exist_ok=True)
130    history_path = out_dir / "history.json"
131    history = {"schema": SCHEMA, "runs": []}
132    if history_path.exists():
133        try:
134            history = json.loads(history_path.read_text())
135        except json.JSONDecodeError:
136            pass  # a corrupt history is rebuilt from this run on
137    entries = [e for e in history.get("runs", []) if e.get("run_id") != run["run_id"]]
138    history = {"schema": SCHEMA, "runs": ([compact(run)] + entries)[:HISTORY_CAP]}
139    written = []
140    for path, payload in (
141        (runs / f"{run['run_id']}.json", run),
142        (out_dir / "latest.json", run),
143        (history_path, history),
144    ):
145        path.write_text(json.dumps(payload, ensure_ascii=False, indent=1) + "\n")
146        written.append(path)
147    return written
148
149
150def self_test() -> int:
151    fixtures = sorted(Path(__file__).parent.glob("fixtures/smoke-stdout-*.txt"))
152    assert fixtures, "no scripts/fixtures/smoke-stdout-*.txt"
153    good = fixtures[-1].read_text()
154    meta = {
155        "run_id": "1",
156        "run_url": "https://example.test/runs/1",
157        "when": "2026-09-05T17:40:00Z",
158        "model": "llama3.2:1b",
159        "image": "ghcr.io/marola-dev/marola:jvm",
160    }
161    r = record(good, 0, meta)
162    assert r["ok"], r["errors"]
163    assert r["lat"] == -27.6733 and r["lon"] == -48.47 and r["radius_km"] == 15, (
164        r["lat"],
165        r["lon"],
166    )
167    assert r["origin_source"] == "--lat/--lon flags"
168    assert r["water"] == "IMA/SC"
169    assert r["top_pick"]["name"] == "Praia da Joaquina", r["top_pick"]
170    assert r["top_pick"]["score"] == 55 and r["top_pick"]["distance_km"] == 4.6
171    assert r["top_pick"]["when"] == "Sun 6 Sep, 10:00"
172    assert len(r["ranked"]) == 6 and r["ranked"][5]["name"] == "Praia do Campeche"
173    assert r["draft"].startswith("Still looks good for a swim")
174    assert r["review"] == {
175        "score": 60,
176        "verdict": "revise",
177        "summary": "Still looks good for a swim in calm conditions and lower wind speeds.",
178    }
179    assert r["errors"] == []
180
181    # The pipeline ran but the model call failed: numbers, no summary, not ok.
182    llm_down = (
183        good.split("Draft summary:")[0] + "(LLM call failed, showing numbers above only: x)\n"
184    )
185    r2 = record(llm_down, 0, meta)
186    assert not r2["ok"] and r2["draft"] is None and r2["review"] is None
187    assert r2["top_pick"]["name"] == "Praia da Joaquina" and len(r2["errors"]) == 1
188
189    # Nothing nearby (or a crash): no top pick, not ok, the exit code kept.
190    r3 = record(
191        "marola :: best hour\nNo beaches found nearby, or no forecast data for tomorrow yet.\n",
192        1,
193        meta,
194    )
195    assert not r3["ok"] and r3["top_pick"] is None and r3["exit_code"] == 1 and r3["errors"]
196
197    with tempfile.TemporaryDirectory() as tmp:
198        out = Path(tmp)
199        for i in range(HISTORY_CAP + 2):
200            write(
201                out,
202                record(
203                    good, 0, {**meta, "run_id": str(i), "when": f"2026-09-05T{i % 24:02d}:00:00Z"}
204                ),
205            )
206        history = json.loads((out / "history.json").read_text())
207        assert len(history["runs"]) == HISTORY_CAP, len(history["runs"])
208        assert history["runs"][0]["run_id"] == str(HISTORY_CAP + 1), "newest first"
209        latest = json.loads((out / "latest.json").read_text())
210        assert latest["run_id"] == str(HISTORY_CAP + 1) and latest["ok"]
211        assert (out / "runs" / "0.json").exists()
212        # Re-recording the same run id replaces its history entry instead of duplicating it.
213        write(out, record(good, 0, {**meta, "run_id": str(HISTORY_CAP + 1)}))
214        history = json.loads((out / "history.json").read_text())
215        assert [e["run_id"] for e in history["runs"]].count(str(HISTORY_CAP + 1)) == 1
216    print(f"smoke_record self-test: ok ({fixtures[-1].name})")
217    return 0
218
219
220def main(argv: list[str]) -> int:
221    ap = argparse.ArgumentParser(
222        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
223    )
224    ap.add_argument("--self-test", action="store_true")
225    sub = ap.add_subparsers(dest="cmd")
226    rec = sub.add_parser("record")
227    rec.add_argument("--stdout", required=True, type=Path)
228    rec.add_argument("--exit-code", required=True, type=int)
229    rec.add_argument("--out-dir", required=True, type=Path)
230    rec.add_argument("--run-id", required=True)
231    rec.add_argument("--run-url", required=True)
232    rec.add_argument("--model", required=True)
233    rec.add_argument("--image", required=True)
234    rec.add_argument("--when", default=dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"))
235    args = ap.parse_args(argv)
236    if args.self_test:
237        return self_test()
238    if args.cmd != "record":
239        ap.print_help()
240        return 2
241    meta = {
242        "run_id": args.run_id,
243        "run_url": args.run_url,
244        "when": args.when,
245        "model": args.model,
246        "image": args.image,
247    }
248    run = record(args.stdout.read_text(errors="replace"), args.exit_code, meta)
249    for path in write(args.out_dir, run):
250        print(path)
251    top = run["top_pick"] or {}
252    review = run["review"] or {}
253    print(
254        f"ok={run['ok']} top={top.get('name')} ({top.get('score')}) "
255        f"review={review.get('score')}/{review.get('verdict')} errors={run['errors']}"
256    )
257    return 0
258
259
260if __name__ == "__main__":
261    sys.exit(main(sys.argv[1:]))
SCHEMA = 1
HISTORY_CAP = 50
STDOUT_CAP = 20000
ORIGIN = re.compile('^origin -> lat=(-?[\\d.]+), lon=(-?[\\d.]+) \\(radius (\\d+)km, source: (.*)\\)$')
RANKED = re.compile('^\\s*(\\d+)\\. \\[\\s*(\\d+)/100\\] (.+?)\\s+\\((\\d+\\.\\d)km\\)\\s+(.+?)\\s+\\|')
DRAFT = re.compile('^Draft summary: (.*)$')
REVIEW = re.compile('^Reviewer \\(score (\\d+)/100, verdict: (\\w+)\\): (.*)$')
WATER = re.compile('^water quality -> (.*)$')
ERRORS = ('(LLM call failed', '(reviewer call failed', '(--summarize needs', '(nothing to summarize', 'No beaches found nearby')
def parse(stdout: str) -> dict:
46def parse(stdout: str) -> dict:
47    """The facts in one run's stdout; `None` where a line never appeared."""
48    origin = None
49    water = None
50    ranked = []
51    draft = None
52    review = None
53    errors = []
54    for raw in stdout.splitlines():
55        line = raw.rstrip()
56        if m := ORIGIN.match(line):
57            origin = {
58                "lat": float(m.group(1)),
59                "lon": float(m.group(2)),
60                "radius_km": int(m.group(3)),
61                "source": m.group(4),
62            }
63        elif m := WATER.match(line):
64            water = m.group(1)
65        elif m := RANKED.match(line):
66            ranked.append(
67                {
68                    "rank": int(m.group(1)),
69                    "score": int(m.group(2)),
70                    "name": m.group(3),
71                    "distance_km": float(m.group(4)),
72                    "when": m.group(5),
73                }
74            )
75        elif m := DRAFT.match(line):
76            draft = m.group(1)
77        elif m := REVIEW.match(line):
78            review = {"score": int(m.group(1)), "verdict": m.group(2), "summary": m.group(3)}
79        elif line.startswith(ERRORS):
80            errors.append(line)
81    return {
82        "origin": origin,
83        "water": water,
84        "ranked": ranked[:10],
85        "top_pick": ranked[0] if ranked else None,
86        "draft": draft,
87        "review": review,
88        "errors": errors,
89    }

The facts in one run's stdout; None where a line never appeared.

def record(stdout: str, exit_code: int, meta: dict) -> dict:
 92def record(stdout: str, exit_code: int, meta: dict) -> dict:
 93    facts = parse(stdout)
 94    ok = exit_code == 0 and facts["top_pick"] is not None and facts["review"] is not None
 95    origin = facts.pop("origin") or {}
 96    return {
 97        "schema": SCHEMA,
 98        **meta,
 99        "lat": origin.get("lat"),
100        "lon": origin.get("lon"),
101        "radius_km": origin.get("radius_km"),
102        "origin_source": origin.get("source"),
103        "exit_code": exit_code,
104        "ok": ok,
105        **facts,
106        "stdout": stdout[-STDOUT_CAP:],
107    }
def compact(run: dict) -> dict:
110def compact(run: dict) -> dict:
111    top = run.get("top_pick") or {}
112    review = run.get("review") or {}
113    return {
114        "run_id": run["run_id"],
115        "run_url": run["run_url"],
116        "when": run["when"],
117        "lat": run["lat"],
118        "lon": run["lon"],
119        "model": run["model"],
120        "ok": run["ok"],
121        "top_pick": {"name": top.get("name"), "score": top.get("score")} if top else None,
122        "review": {"score": review.get("score"), "verdict": review.get("verdict")}
123        if review
124        else None,
125    }
def write(out_dir: pathlib.Path, run: dict) -> list[pathlib.Path]:
128def write(out_dir: Path, run: dict) -> list[Path]:
129    runs = out_dir / "runs"
130    runs.mkdir(parents=True, exist_ok=True)
131    history_path = out_dir / "history.json"
132    history = {"schema": SCHEMA, "runs": []}
133    if history_path.exists():
134        try:
135            history = json.loads(history_path.read_text())
136        except json.JSONDecodeError:
137            pass  # a corrupt history is rebuilt from this run on
138    entries = [e for e in history.get("runs", []) if e.get("run_id") != run["run_id"]]
139    history = {"schema": SCHEMA, "runs": ([compact(run)] + entries)[:HISTORY_CAP]}
140    written = []
141    for path, payload in (
142        (runs / f"{run['run_id']}.json", run),
143        (out_dir / "latest.json", run),
144        (history_path, history),
145    ):
146        path.write_text(json.dumps(payload, ensure_ascii=False, indent=1) + "\n")
147        written.append(path)
148    return written
def self_test() -> int:
151def self_test() -> int:
152    fixtures = sorted(Path(__file__).parent.glob("fixtures/smoke-stdout-*.txt"))
153    assert fixtures, "no scripts/fixtures/smoke-stdout-*.txt"
154    good = fixtures[-1].read_text()
155    meta = {
156        "run_id": "1",
157        "run_url": "https://example.test/runs/1",
158        "when": "2026-09-05T17:40:00Z",
159        "model": "llama3.2:1b",
160        "image": "ghcr.io/marola-dev/marola:jvm",
161    }
162    r = record(good, 0, meta)
163    assert r["ok"], r["errors"]
164    assert r["lat"] == -27.6733 and r["lon"] == -48.47 and r["radius_km"] == 15, (
165        r["lat"],
166        r["lon"],
167    )
168    assert r["origin_source"] == "--lat/--lon flags"
169    assert r["water"] == "IMA/SC"
170    assert r["top_pick"]["name"] == "Praia da Joaquina", r["top_pick"]
171    assert r["top_pick"]["score"] == 55 and r["top_pick"]["distance_km"] == 4.6
172    assert r["top_pick"]["when"] == "Sun 6 Sep, 10:00"
173    assert len(r["ranked"]) == 6 and r["ranked"][5]["name"] == "Praia do Campeche"
174    assert r["draft"].startswith("Still looks good for a swim")
175    assert r["review"] == {
176        "score": 60,
177        "verdict": "revise",
178        "summary": "Still looks good for a swim in calm conditions and lower wind speeds.",
179    }
180    assert r["errors"] == []
181
182    # The pipeline ran but the model call failed: numbers, no summary, not ok.
183    llm_down = (
184        good.split("Draft summary:")[0] + "(LLM call failed, showing numbers above only: x)\n"
185    )
186    r2 = record(llm_down, 0, meta)
187    assert not r2["ok"] and r2["draft"] is None and r2["review"] is None
188    assert r2["top_pick"]["name"] == "Praia da Joaquina" and len(r2["errors"]) == 1
189
190    # Nothing nearby (or a crash): no top pick, not ok, the exit code kept.
191    r3 = record(
192        "marola :: best hour\nNo beaches found nearby, or no forecast data for tomorrow yet.\n",
193        1,
194        meta,
195    )
196    assert not r3["ok"] and r3["top_pick"] is None and r3["exit_code"] == 1 and r3["errors"]
197
198    with tempfile.TemporaryDirectory() as tmp:
199        out = Path(tmp)
200        for i in range(HISTORY_CAP + 2):
201            write(
202                out,
203                record(
204                    good, 0, {**meta, "run_id": str(i), "when": f"2026-09-05T{i % 24:02d}:00:00Z"}
205                ),
206            )
207        history = json.loads((out / "history.json").read_text())
208        assert len(history["runs"]) == HISTORY_CAP, len(history["runs"])
209        assert history["runs"][0]["run_id"] == str(HISTORY_CAP + 1), "newest first"
210        latest = json.loads((out / "latest.json").read_text())
211        assert latest["run_id"] == str(HISTORY_CAP + 1) and latest["ok"]
212        assert (out / "runs" / "0.json").exists()
213        # Re-recording the same run id replaces its history entry instead of duplicating it.
214        write(out, record(good, 0, {**meta, "run_id": str(HISTORY_CAP + 1)}))
215        history = json.loads((out / "history.json").read_text())
216        assert [e["run_id"] for e in history["runs"]].count(str(HISTORY_CAP + 1)) == 1
217    print(f"smoke_record self-test: ok ({fixtures[-1].name})")
218    return 0
def main(argv: list[str]) -> int:
221def main(argv: list[str]) -> int:
222    ap = argparse.ArgumentParser(
223        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
224    )
225    ap.add_argument("--self-test", action="store_true")
226    sub = ap.add_subparsers(dest="cmd")
227    rec = sub.add_parser("record")
228    rec.add_argument("--stdout", required=True, type=Path)
229    rec.add_argument("--exit-code", required=True, type=int)
230    rec.add_argument("--out-dir", required=True, type=Path)
231    rec.add_argument("--run-id", required=True)
232    rec.add_argument("--run-url", required=True)
233    rec.add_argument("--model", required=True)
234    rec.add_argument("--image", required=True)
235    rec.add_argument("--when", default=dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"))
236    args = ap.parse_args(argv)
237    if args.self_test:
238        return self_test()
239    if args.cmd != "record":
240        ap.print_help()
241        return 2
242    meta = {
243        "run_id": args.run_id,
244        "run_url": args.run_url,
245        "when": args.when,
246        "model": args.model,
247        "image": args.image,
248    }
249    run = record(args.stdout.read_text(errors="replace"), args.exit_code, meta)
250    for path in write(args.out_dir, run):
251        print(path)
252    top = run["top_pick"] or {}
253    review = run["review"] or {}
254    print(
255        f"ok={run['ok']} top={top.get('name')} ({top.get('score')}) "
256        f"review={review.get('score')}/{review.get('verdict')} errors={run['errors']}"
257    )
258    return 0