"""Playback viewer (pipeline step 7, read side). `load_episode` is the pure data layer — it reassembles a saved episode (manifest + turns + transcript + per-turn minimap PNG paths) into one dict, and is unit-tested. `render_streamlit` is a thin optional UI on top of it (mirrors the training repo's Streamlit pipeline viewer): pip install streamlit streamlit run scripts/view_playback.py -- Per turn it shows: the minimap, the user briefing, the model's reasoning + tool calls, the signal snapshot, and the goal tracker (win-condition leaf bars + the cumulative reward vector). """ from __future__ import annotations import json from pathlib import Path def load_episode(ep_dir: str | Path) -> dict: """Reassemble one ``seed`` episode folder. Tolerant of a still- running episode (missing files become empty). Accepts EITHER a legacy seed dir OR a FullPlayback audit JSONL file; a path ending in `.jsonl` dispatches to `load_audit_jsonl`.""" p = Path(ep_dir) if p.is_file() and p.suffix == ".jsonl": return load_audit_jsonl(p) d = p manifest = _read_json(d / "manifest.json", {}) messages = _read_json(d / "messages.json", []) turns = [] tj = d / "turns.jsonl" if tj.exists(): for line in tj.read_text().splitlines(): line = line.strip() if line: rec = json.loads(line) png = d / f"minimap_turn{rec.get('turn', 0):03d}.png" rec["minimap_png"] = str(png) if png.exists() else None turns.append(rec) return {"dir": str(d), "manifest": manifest, "turns": turns, "messages": messages} def find_episodes(root: str | Path) -> list[Path]: """All episode targets under a playback root. Returns a mix of two shapes the viewer transparently handles: * Legacy `.../seed/` dirs (manifest.json + turns.jsonl + …) * Audit-format `.../.jsonl` files written by FullPlayback — the loader detects a `.jsonl` path and translates it on the fly. """ root = Path(root) legacy = sorted( p.parent for p in root.glob("**/manifest.json") ) or sorted(root.glob("**/seed*")) # Audit-format cells: `//____seedN__fog.jsonl`. # Skip the `.partial` half-runs and any sidecar files (start with `_`). audit = sorted( p for p in root.glob("**/*.jsonl") if not p.name.startswith("_") and "__seed" in p.name and not p.name.endswith(".partial") ) return legacy + audit def load_audit_jsonl(jsonl_path: str | Path) -> dict: """Translate a FullPlayback audit JSONL into the same `{dir, manifest, turns, messages}` dict shape `load_episode` returns, so the same viewer code (`render_streamlit` / downstream loaders) can consume either format transparently. - `manifest` is reconstructed from the terminal record's `terminal.manifest` block (+ a few top-level fields), preserving compatibility with viewers that expect `outcome`, `model`, etc. - `turns` mirror the legacy per-turn shape (`turn`, `tick`, `commands`, `signals`, `goal`, `minimap_png` path). - `messages` is synthesized from the system_prompt + each turn's briefing/response, so the viewer's transcript pane still works. """ p = Path(jsonl_path) recs: list[dict] = [] for line in p.read_text().splitlines(): line = line.strip() if not line: continue try: recs.append(json.loads(line)) except json.JSONDecodeError: continue if not recs: return {"dir": str(p.parent), "manifest": {}, "turns": [], "messages": []} term_block = (recs[-1].get("terminal") or {}) if recs else {} manifest: dict = dict(term_block.get("manifest") or {}) # Surface the audit-only totals at the manifest level so the viewer's # "outcome / wall / tokens" header line has them. manifest.setdefault("outcome", term_block.get("outcome", "?")) manifest.setdefault("wall_clock_seconds", term_block.get("wall_clock_seconds")) manifest["total_tokens_in"] = term_block.get("total_tokens_in", 0) manifest["total_tokens_out"] = term_block.get("total_tokens_out", 0) turns = [] messages: list[dict] = [] # System prompt lives on the first turn only. sysp = recs[0].get("system_prompt") if sysp: messages.append({"role": "system", "content": sysp}) for r in recs: # Turn record — translate to legacy keys the viewer recognises. png_rel = r.get("minimap_png") png_abs = str(p.parent / png_rel) if png_rel else None sig = r.get("signals") or {} turns.append( { "turn": r.get("turn"), "tick": r.get("tick"), "interrupt": r.get("interrupt"), "commands": r.get("commands_issued") or [], "ascii_minimap": (r.get("obs") or {}).get("minimap", ""), "signals": sig, "units": (r.get("obs") or {}).get("units_summary", []), "enemies": (r.get("obs") or {}).get("enemy_summary", []), "goal": {}, # not captured by FullPlayback "minimap_png": png_abs, } ) if r.get("briefing"): messages.append({"role": "user", "content": r["briefing"]}) resp = r.get("model_response") or {} if resp.get("text") or resp.get("tool_calls"): messages.append( { "role": "assistant", "content": resp.get("text") or "", "tool_calls": [ { "id": f"c{i}", "type": "function", "function": { "name": (tc or {}).get("name", ""), "arguments": (tc or {}).get("arguments", {}), }, } for i, tc in enumerate(resp.get("tool_calls") or []) ], "reasoning": resp.get("reasoning", ""), } ) return { "dir": str(p.parent), "manifest": manifest, "turns": turns, "messages": messages, } def _read_json(p: Path, default): try: return json.loads(p.read_text()) except Exception: # noqa: BLE001 — partial/running episode return default def _assistant_turns(messages: list[dict]) -> list[dict]: return [m for m in messages if m.get("role") == "assistant"] def render_streamlit(root: str) -> None: # pragma: no cover - UI glue import streamlit as st st.set_page_config(page_title="OpenRA-Bench playback", layout="wide") eps = find_episodes(root) if not eps: st.error(f"no episodes under {root}") return pick = st.sidebar.selectbox( "episode", eps, format_func=lambda p: f"{p.parent.name}/{p.name}" ) ep = load_episode(pick) m = ep["manifest"] st.title(f"{m.get('scenario', pick)} — {m.get('outcome', '?')}") st.caption( f"{m.get('model','?')} · run {m.get('run_id','?')} · " f"turns {m.get('turns')}/{m.get('max_turns')} · " f"capability {m.get('capability')} · seed {m.get('seed')}" ) # System prompt once at top (the deterministic scenario knowledge # the model was given) — same as the training pipeline viewer. sysp = next( (x.get("content", "") for x in ep["messages"] if x.get("role") == "system"), "" ) if isinstance(sysp, list): sysp = " ".join( p.get("text", "") for p in sysp if isinstance(p, dict) ) if sysp: with st.expander(f"🧠 System prompt ({len(sysp)} chars)", expanded=False): st.code(sysp, language="text") users = [x for x in ep["messages"] if x.get("role") == "user"] asst = _assistant_turns(ep["messages"]) for i, t in enumerate(ep["turns"]): with st.expander( f"turn {t['turn']} · tick {t.get('tick')}" + (f" · ⚡{t['interrupt']}" if t.get("interrupt") else ""), expanded=(i == 0), ): left, right = st.columns([1, 1]) with left: if t.get("minimap_png"): st.image(t["minimap_png"], caption="minimap") elif t.get("ascii_minimap"): st.code(t["ascii_minimap"]) st.json(t.get("signals", {})) with right: a = asst[i] if i < len(asst) else {} if a.get("reasoning"): st.markdown("**reasoning**") st.write(a["reasoning"]) st.markdown("**commands**") st.code("\n".join(t.get("commands", [])) or "(none)") g = t.get("goal") or {} if g: st.markdown( f"**objective progress: " f"{g.get('objective_progress', 0):.0%}**" + (" ✅ won" if g.get("won") else "") ) for leaf in g.get("leaves", []): st.progress( min(1.0, float(leaf.get("ratio", 0.0))), text=f"{leaf['name']} " f"{leaf.get('current')}/{leaf.get('target')}", ) rv = g.get("reward_vector", {}) st.caption("reward vector: " + " ".join( f"{k}={v:.2f}" for k, v in rv.items() ))