From c3a3cb8da5df1cdd57ea89319e06ff68df89ec00 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 5 Jul 2026 18:24:26 +0200 Subject: [PATCH] feat(replay): standalone reviewer-gate replay server on :5333 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the real reviewer UI for any recorded session, with no VPN/Pi/ Python/DWH. The server (node:http, zero deps) serves the built SPA and a tiny SSE/REST shim that re-emits the reviewer gates captured in a Pi transcript, in their original order, with the reviewer's real 3-Jul choices shown as comparison badges. - tools/replay/extract.mjs: extracts gates from one or many transcripts (session-id, file, or directory). Handles sessions split across resume re-entries by sorting on message timestamp and dropping unanswered gates. Reads the question from session_manifest.yaml, resolving the sessions dir from any workspace yaml (no hardcoded paths). - tools/replay/server.mjs: same-origin :5333. SSE streams gates; POST /response advances the cursor and pushes info badges (scelta reale). POST /resume and the final "Ripeti/Esci" widget close the SSE so the browser EventSource reconnects (cursor resets, gate 1 re-emitted) — the replay is re-runnable any number of times. GET /sessions/:id/documents reads the real session files so GateArtifactBody resolves file-reference artifacts. Exit emits system_event {event:"session_exit"} to return to the landing. - scripts/replay.sh: launcher (extract / build / run / all). - tools/replay/README.md: data flow, commands, fidelity notes. - .gitignore: ignore tools/replay/web/ (built artifact, like dist/). --- .gitignore | 3 + scripts/replay.sh | 70 +++ tools/replay/README.md | 115 ++++ tools/replay/extract.mjs | 362 ++++++++++++ tools/replay/replay.json | 1117 ++++++++++++++++++++++++++++++++++++++ tools/replay/server.mjs | 480 ++++++++++++++++ 6 files changed, 2147 insertions(+) create mode 100644 scripts/replay.sh create mode 100644 tools/replay/README.md create mode 100644 tools/replay/extract.mjs create mode 100644 tools/replay/replay.json create mode 100644 tools/replay/server.mjs diff --git a/.gitignore b/.gitignore index c7049dfa..833faa8d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ build/ # === Node === node_modules/ +# === Replay bundle (built artifact, like dist/) === +tools/replay/web/ + # === Secrets — NEVER commit === .env *.pem diff --git a/scripts/replay.sh b/scripts/replay.sh new file mode 100644 index 00000000..9966a2d0 --- /dev/null +++ b/scripts/replay.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Replay a recorded ThothII reviewer session on http://localhost:5333, with no +# VPN/Pi/Python/DWH. The real frontend UI is served against a tiny SSE/REST shim +# that re-emits the reviewer gates captured in Pi transcripts. +# +# Usage: +# scripts/replay.sh # extract(default) + build(if missing) + run +# scripts/replay.sh extract [] # (re)build replay.json for a session +# scripts/replay.sh build # (re)build the SPA bundle only +# scripts/replay.sh run # run only (assumes build + fixture present) +# +# a session id (e.g. 2026-07-03-170732-fammi-…) +# a transcript file path +# a directory of transcripts (picks the dominant session) +# default: the 3-Jul 17:07 full session +# +# Examples: +# scripts/replay.sh extract 2026-07-03-132933-fammi-la-lista-dei-pazienti-che-negli-ul +# scripts/replay.sh run +# +# Env: +# PORT default 5333 +# +# See tools/replay/README.md for the data flow. + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" +PORT="${PORT:-5333}" +WEB_DIR="$HERE/tools/replay/web" +FIXTURE="$HERE/tools/replay/replay.json" + +action="${1:-all}" +# Positional target for extract: shifts past the action word. +target="${2:-}" + +do_extract() { + echo "→ extracting replay.json for ${target:-default session (3-Jul 17:07)}…" + node "$HERE/tools/replay/extract.mjs" "$target" +} + +do_build() { + if [[ -d "$WEB_DIR" ]]; then + echo "→ $WEB_DIR exists, skipping build (use 'rm -rf $WEB_DIR' to force)" + return + fi + echo "→ building SPA bundle into $WEB_DIR (VITE_BACKEND_URL=http://localhost:$PORT)…" + ( + cd "$HERE/frontend" + VITE_BACKEND_URL="http://localhost:$PORT" npx vite build --outDir ../tools/replay/web + ) +} + +do_run() { + [[ -f "$FIXTURE" ]] || { echo "✗ missing $FIXTURE — run: scripts/replay.sh extract"; exit 1; } + [[ -d "$WEB_DIR" ]] || { echo "✗ missing $WEB_DIR — run: scripts/replay.sh build"; exit 1; } + echo "→ starting replay server on :$PORT (Ctrl-C to stop)…" + PORT="$PORT" node "$HERE/tools/replay/server.mjs" +} + +case "$action" in + extract) do_extract ;; + build) do_build ;; + run) do_run ;; + all) + [[ -f "$FIXTURE" ]] || do_extract + do_build + do_run + ;; + *) echo "unknown action: $action"; echo "usage: $0 [extract|build|run|all]"; exit 2 ;; +esac diff --git a/tools/replay/README.md b/tools/replay/README.md new file mode 100644 index 00000000..2f6a8c4c --- /dev/null +++ b/tools/replay/README.md @@ -0,0 +1,115 @@ +# ThothII reviewer replay + +Riproduce su `http://localhost:5333` la **vera UI** dei widget reviewer di una +sessione registrata, **senza VPN, Pi, Python, né DWH**. Si usano i descriptor +dei gate così come furono proposti al revisore (catturati nel transcript Pi), +nello stesso ordine, con le scelte realmente fatte mostrate come badge di +confronto. + +## Schema del flusso + +``` +browser (SPA reale, :5333) + │ REST + SSE, same-origin + ▼ +server.mjs (node:http, zero dipendenze) + ├─ static: serve il bundle SPA da tools/replay/web/ + └─ replay SSE: GET /sessions/:id/events → event: ui_request (gate N) + POST /sessions/:id/response → badge "scelta reale" + gate N+1 + │ + legge + ▼ +replay.json (fixture estratta dal transcript) + │ prodotta da + ▼ +extract.mjs (legge ~/.pi/agent/sessions/…/*.jsonl + question.md) +``` + +Niente backend Fastify, niente `pi --mode rpc`, niente `tht`, niente DB: il +server gira direttamente sui descriptor registrati. + +## Comandi + +```bash +# Estrai la fixture per una sessione (id, file, o directory di transcript): +scripts/replay.sh extract 2026-07-03-132933-fammi-la-lista-dei-pazienti-che-negli-ul +scripts/replay.sh extract ~/.pi/agent/sessions/--Users-mp-projects-ThothII-harness--/ + +# (una tantum, o dopo modifiche al frontend) costruisci il bundle SPA: +scripts/replay.sh build + +# Avvia il server su :5333: +scripts/replay.sh run + +# Scorciatoia: extract (default) + build (se manca) + run: +scripts/replay.sh +``` + +**Cosa può estrarre `extract`:** + +| Input | Comportamento | +|---|---| +| *(nessuno)* | La sessione di default (3 lug 17:07, 20 gate). | +| `` | Scansiona **tutti** i transcript nella directory Pi per quel session-id. Gestisce automaticamente sessioni spezzate su più file (resume): raccoglie i gate da tutti i transcript, li ordina cronologicamente, e scarta i gate senza risposta (quelli riproposti al resume). | +| `` | Un singolo transcript. | +| `` | Tutti i `*.jsonl` nella directory; se contengono sessioni diverse, tiene quella dominante (con più gate). | + +La domanda della sessione è letta da `session_manifest.yaml` (campo `question`), +cercando la directory sessions in ogni workspace di `harness/workspaces/` (via +`paths.sessions`, assoluto o relativo). Niente più path hardcoded. + +## Cosa vedi nel browser + +1. Apri `http://localhost:5333`. La lista sessioni mostra **una** sessione + (quella riprodotta) con la domanda reale. +2. Cliccala: appare il **gate 1** — un `reviewer_select` (widget select) con il + titolo, l'intro e le opzioni esatte che il modello propose il 3 luglio, + incluso il badge "consigliato" sull'opzione raccomandata. +3. Clicca un'opzione. Sotto la tua scelta compare un `info` con la **scelta + reale del 3 luglio** (`📌 SCELTA REALE (3 lug 2026): …`), e — se coincide — un + `✓ Hai scelto come il 3 luglio.` +4. Avanzi attraverso tutti i gate nell'ordine loggato: `select` (F1 chiarimenti, + F6 conferma piano), `multiselect` (F3 riscrittura, F4 schema linking, F6 + piano CTE), `artifact-gate` (F2/F3/F4/F5 phase advance, F6 approvazione CTE + con SQL visibile nell'`ArtifactView`). +5. Dopo l'ultimo gate: `✓ Replay completato — 20/20 gate`. + +I 20 gate della sessione 17:07: 4 `select` + 11 `confirm` + 5 `decide`. + +## Limiti e fedeltà al log + +- **Sessioni multi-file (resume)**: gestite. L'estrattore raccoglie i gate da + tutti i transcript della sessione, li ordina per timestamp del messaggio, e + scarta i gate senza `toolResult` (quelli dove il revisore chiuse Pi senza + rispondere e che furono riproposti al resume). Risultato: la sequenza + esatta dei gate che il revisore ha effettivamente visto e deciso. +- **Workspace discovery**: la domanda è letta da `session_manifest.yaml`, + cercando `paths.sessions` nei workspace di `harness/workspaces/`. Funziona + per qualunque cliente (non solo `psd`), purché il workspace yaml sia + presente e `paths.sessions` sia un path assoluto o relativo (non espanso + con `${VAR}` — in quel caso il path non è risolvibile offline e la domanda + resta vuota). +- **Phase compute disattivata**: la fase corrente (colorazione `F1`…`F8`) non è + ricalcolata dal ledger (richiederebbe `tht phase`); i gate usano un phase-tag + neutro `replay`. L'ordine e i descriptor sono quelli reali. +- **Gate falliti/saltati**: alcuni gate non hanno una "scelta reale" perché + nella sessione live furono saltati (memorie vuote → avanzamento auto) o + falliti (es. `cte_plan` con tipo invalido). Il badge + `⚠ Gate fallito nella sessione reale` lo segnala fedelmente — riflette il + log, non un bug del replay. +- **`text_delta`/ragionamenti del modello**: omessi (scelta "solo gate"). Per + vederli, estendi `extract.mjs` per raccogliere anche i `text_delta`. +- **Reserved controls** (`back`/`exit`/`other`): nel replay sono ammessi come + qualunque altra risposta, ma non hanno semantica (non c'è stato di + navigazione da ripristinare); trattali come "procedi". +- **Single-user, single-session**: il server tiene un solo cursore globale; non + è pensato per più client concorrenti (un refresh del browser riprende dal + gate corrente). + +## File + +- `extract.mjs` — estrattore fixture dal transcript Pi JSONL. +- `server.mjs` — server HTTP+SSE standalone (node:http, zero deps). +- `replay.json` — fixture generata (committata per comodità). +- `web/` — bundle SPA costruito da `frontend/` con + `VITE_BACKEND_URL=http://localhost:5333` (gitignorato; prodotto da `build`). diff --git a/tools/replay/extract.mjs b/tools/replay/extract.mjs new file mode 100644 index 00000000..cc673f2e --- /dev/null +++ b/tools/replay/extract.mjs @@ -0,0 +1,362 @@ +// Extract a replay fixture from one or more Pi agent transcripts. +// +// A single ThothII session can span multiple transcript files (Pi re-enters at +// resume, re-emitting any gate the reviewer left unanswered). To reconstruct +// the "what the reviewer actually saw and decided" sequence we: +// 1. Collect reviewer_* toolCalls across ALL transcripts for the session, +// tagged with the message timestamp (NOT the filename — a transcript's +// own timestamp is the resume moment, not the gate moment). +// 2. Sort chronologically by message timestamp. +// 3. Skip any gate without a toolResult (the reviewer closed Pi without +// answering; the gate was re-presented later in a fresher form). What +// remains is exactly the gates the reviewer acted on, in order. +// +// Usage: +// node tools/replay/extract.mjs [] +// +// e.g. 2026-07-03-170732-fammi-... +// Scans every *.jsonl in the Pi agent session dir for that +// session id. Default = the 17:07 full session. +// a transcript file OR a directory of transcripts. +// +// Output: tools/replay/replay.json (descriptor per gate + the reviewer's real +// choice recovered from the toolResult text). + +import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +const RESERVED = ["back", "exit", "other"]; +const SCHEMA_VERSION = 1; +const PI_SESSIONS_DIR = join( + homedir(), + ".pi/agent/sessions/--Users-mp-projects-ThothII-harness--", +); + +// --- resolve which transcript files to read -------------------------------- +function resolveTranscriptFiles(arg) { + if (!arg) { + // default: the 17:07 full session + return readdirSync(PI_SESSIONS_DIR) + .filter((f) => f.endsWith(".jsonl") && f.startsWith("2026-07-03T17-07-32")) + .map((f) => join(PI_SESSIONS_DIR, f)) + .sort(); + } + if (existsSync(arg)) { + const s = statSync(arg); + if (s.isDirectory()) { + return readdirSync(arg) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(arg, f)) + .sort(); + } + return [arg]; + } + // Treat as a session id: scan the Pi session dir for files mentioning it. + // (Cheap prefilter by substring to avoid parsing every historical transcript.) + const hits = []; + for (const f of readdirSync(PI_SESSIONS_DIR)) { + if (!f.endsWith(".jsonl")) continue; + const p = join(PI_SESSIONS_DIR, f); + // Quick text scan; the session id appears in toolCall arguments. + const head = readFileSync(p, "utf8"); + if (head.includes(arg)) hits.push(p); + } + if (hits.length === 0) { + throw new Error( + `No transcripts found for "${arg}". Pass a session id, a transcript file, or a directory.`, + ); + } + return hits.sort(); +} + +const files = resolveTranscriptFiles(process.argv[2]); + +// --- first pass: gather reviewer calls (with msg timestamp) + toolResults -- +let sessionId = null; +let createdAt = null; +const calls = []; // {callId, name, args, ts, file} +const resultsByCallId = new Map(); // callId -> toolResult text + +for (const fn of files) { + const raw = readFileSync(fn, "utf8").split("\n").filter(Boolean); + for (const line of raw) { + let m; + try { + m = JSON.parse(line); + } catch { + continue; + } + if (m.type === "session" && !createdAt) createdAt = m.timestamp; + if (m.type !== "message") continue; + const msg = m.message; + if (!msg || !Array.isArray(msg.content)) continue; + const ts = m.timestamp ?? null; + + for (const p of msg.content) { + if (p.type !== "toolCall") continue; + if (typeof p.name !== "string" || !p.name.startsWith("reviewer_")) continue; + calls.push({ callId: p.id, name: p.name, args: p.arguments ?? {}, ts, file: fn }); + } + if (msg.role === "toolResult" && typeof msg.toolCallId === "string") { + if (typeof msg.toolName === "string" && msg.toolName.startsWith("reviewer_")) { + const c = Array.isArray(msg.content) ? msg.content[0] : null; + const text = c && typeof c.text === "string" ? c.text : ""; + // First writer wins: a toolResult is unique per callId across files. + if (!resultsByCallId.has(msg.toolCallId)) { + resultsByCallId.set(msg.toolCallId, text); + } + } + } + } +} + +// --- chronological order, then drop gates the reviewer never answered ------- +// A gate without a toolResult = the reviewer closed Pi at that gate; it was +// re-presented (possibly reworded) at the next resume. Keeping only answered +// gates yields the sequence the reviewer actually experienced end-to-end. +calls.sort((a, b) => (a.ts ?? "").localeCompare(b.ts ?? "")); + +// When reading a whole directory, calls may span multiple sessions. Pick the +// session with the most reviewer calls (the "subject" of that directory) and +// keep only its gates — otherwise replay.json would interleave two sessions. +const sessionCounts = new Map(); +for (const c of calls) { + const s = c.args.session ?? "(none)"; + sessionCounts.set(s, (sessionCounts.get(s) ?? 0) + 1); +} +if (sessionCounts.size > 1) { + let best = null; + let bestN = -1; + for (const [s, n] of sessionCounts) if (n > bestN) { best = s; bestN = n; } + if (best) { + const filtered = calls.filter((c) => (c.args.session ?? "(none)") === best); + console.error(` directory mode: ${sessionCounts.size} sessions found, keeping "${best}" (${filtered.length}/${calls.length} calls)`); + calls.splice(0, calls.length, ...filtered); + if (sessionId && sessionId !== best) sessionId = best; + } +} +const answered = calls.filter((c) => resultsByCallId.has(c.callId)); + +// --- build descriptors (mirrors harness/.pi/extensions/gate/builders.js) ---- +function buildDescriptor(name, args, idx) { + const id = `u${idx}`; + const phase = "replay"; + if (name === "reviewer_select") { + const opts = parseOptions(args.options).filter((o) => !isReserved(o.label)); + const recommendedId = parseOptions(args.options).find((o) => o.recommended)?.id ?? null; + return { + type: "ui_request", + id, + phase, + schema_version: SCHEMA_VERSION, + widget: "select", + title: args.title, + intro: args.intro ?? null, + recommended: recommendedId, + options: opts.map((o) => ({ id: o.id, label: o.label, recommended: !!o.recommended })), + reserved: RESERVED, + }; + } + if (name === "reviewer_decide") { + const opts = parseOptions(args.options).filter((o) => !isReserved(o.label)); + return { + type: "ui_request", + id, + phase, + schema_version: SCHEMA_VERSION, + widget: "multiselect", + title: args.title, + allow_empty: args.allow_empty ?? false, + options: opts.map((o) => ({ + id: o.id, + label: o.label, + recommended: !!o.recommended, + decision: o.decision, + })), + selected: [], + content: null, + reserved: RESERVED, + }; + } + if (name === "reviewer_confirm") { + return { + type: "ui_request", + id, + phase, + schema_version: SCHEMA_VERSION, + widget: "artifact-gate", + title: args.title, + artifact: args.artifact, + action: { kind: "approve_reject", prompt: "Approvi o rifiuti?" }, + options: [ + { id: "approve", label: "Salva e procedi", recommended: true }, + { id: "reject", label: "Rifiuta" }, + ], + reserved: RESERVED, + }; + } + return null; +} + +function isReserved(label) { + if (typeof label !== "string") return false; + const l = label.toLowerCase(); + return RESERVED.some((r) => l === r || l.startsWith(r + ":") || l.startsWith(r + " —")); +} + +// Some models serialize arrays as JSON strings; the gate's prepareReviewerArguments +// coerces them back to arrays pre-validation. Mirror that here so a stringified +// `options` (or `names`) doesn't blow up the descriptor builder. +function parseOptions(v) { + if (Array.isArray(v)) return v; + if (typeof v === "string") { + try { + const p = JSON.parse(v); + if (Array.isArray(p)) return p; + } catch { /* not JSON */ } + } + return []; +} + +// --- recover the real reviewer choice from the toolResult text ------------- +function parseRealChoice(name, descriptor, resultText) { + if (!resultText) return { labels: [], note: "no toolResult captured" }; + const t = resultText.trim(); + const askOnly = t.match(/^Scelta del reviewer:\s*(.+?)\s*\.\s*$/); + if (name === "reviewer_select") { + let label = null; + const m = t.match(/\):\s*(.+?)\s*\.\s*$/); + if (m) label = m[1]; + else if (askOnly) label = askOnly[1]; + return { labels: label ? [label] : [], note: t }; + } + if (name === "reviewer_decide") { + const m = t.match(/Registrate\s+(\d+)\s+decisioni:\s*(.+?)\.\s*$/); + if (m) return { labels: [], decisionTypes: m[2].split(/,\s*/), note: t }; + if (/Nessuna decisione registrata/.test(t)) return { labels: [], decisionTypes: [], note: t }; + return { labels: [], note: t }; + } + if (name === "reviewer_confirm") { + if (/ERRORE/i.test(t)) return { labels: [], note: t, failed: true }; + if (/Nessun CTE in attesa/i.test(t)) return { labels: [], note: t, failed: true }; + if (/approvato|approvata/i.test(t)) return { labels: ["Salva e procedi"], note: t }; + if (/Rifiutato/i.test(t)) return { labels: ["Rifiuta"], note: t }; + return { labels: [], note: t }; + } + return { labels: [], note: t }; +} + +// --- assemble the gates array ---------------------------------------------- +// sessionId = the dominant session across the surviving calls (post-filter). +sessionId = sessionId ?? null; +{ + const sc = new Map(); + for (const c of answered) sc.set(c.args.session, (sc.get(c.args.session) ?? 0) + 1); + let best = null, bn = -1; + for (const [s, n] of sc) if (n > bn) { best = s; bn = n; } + if (best) sessionId = best; +} +const gates = answered.map((c, idx) => { + const descriptor = buildDescriptor(c.name, c.args, idx); + const real = parseRealChoice(c.name, descriptor, resultsByCallId.get(c.callId)); + return { callId: c.callId, toolName: c.name, ts: c.ts, descriptor, real }; +}); + +// --- session question: prefer the manifest, fall back to question.md ------- +function readSessionQuestion(sid) { + if (!sid) return null; + const sessionDir = findSessionDir(sid); + if (!sessionDir) return null; + // session_manifest.yaml has a top-level `question:` field with the original + // NL question. Cheaper and more authoritative than parsing question.md. + const manifestPath = join(sessionDir, "session_manifest.yaml"); + if (existsSync(manifestPath)) { + const m = readFileSync(manifestPath, "utf8"); + const q = m.match(/^question:\s*(.+?)\s*$/m); + if (q) return stripYaml(q[1]); + } + const qmd = join(sessionDir, "question.md"); + if (existsSync(qmd)) { + const md = readFileSync(qmd, "utf8").replace(/^#.*\n+/, "").trim(); + return md.split(/\n\s*\n/)[0].trim(); + } + return null; +} + +// Resolve a session id to its on-disk directory. We check every workspace yaml +// in harness/workspaces/ for a `paths.sessions` entry (relative OR absolute), +// then look for // in each. Workspaces are symlinks to +// per-client repos (uncommitted), so this works for any client without env. +function findSessionDir(sid) { + const wsDir = join(process.cwd(), "harness/workspaces"); + const candidates = []; + if (existsSync(wsDir)) { + for (const f of readdirSync(wsDir)) { + if (!/\.(ya?ml)$/i.test(f)) continue; + const ypath = join(wsDir, f); + let y; + try { y = readFileSync(ypath, "utf8"); } catch { continue; } + const m = y.match(/^paths:\s*\n(?:[ \t]+.*\n)*?[ \t]+sessions:\s*(\S+)/m); + if (!m) continue; + let p = stripYaml(m[1]); + if (!p) continue; + // Strip ${VAR} placeholders (unresolvable here) — those workspaces can't + // be located offline and are skipped. + if (p.includes("${")) continue; + candidates.push(p); + } + } + // Plus the legacy hardcoded PSD location as a last resort. + candidates.push(join(homedir(), "projects", "tht-workspace-psd", "sessions")); + for (const base of candidates) { + const abs = join(base, sid); + if (existsSync(abs)) return abs; + } + return null; +} + +function stripYaml(s) { + // drop surrounding quotes + trailing comment + let v = s.trim().replace(/^['"]|['"]$/g, ""); + v = v.replace(/\s+#.*$/, ""); + return v; +} + +const replay = { + source: { + transcripts: files, + extractedAt: new Date().toISOString(), + note: "gates without a reviewer answer (resume re-entries) are dropped", + }, + session: { + id: sessionId, + question: readSessionQuestion(sessionId), + created_at: createdAt, + }, + stats: { + transcriptFiles: files.length, + totalReviewerCalls: calls.length, + answeredGates: answered.length, + droppedUnanswered: calls.length - answered.length, + }, + gates, +}; + +const outPath = new URL("./replay.json", import.meta.url); +writeFileSync(outPath, JSON.stringify(replay, null, 2) + "\n", "utf8"); + +// --- coverage summary ------------------------------------------------------ +const byTool = {}; +let withChoice = 0; +for (const g of gates) { + byTool[g.toolName] = (byTool[g.toolName] ?? 0) + 1; + if (g.real.labels.length || (g.real.decisionTypes && g.real.decisionTypes.length)) withChoice++; +} +console.error(`scanned ${files.length} transcript file(s)`); +console.error(` reviewer calls: ${calls.length} | answered: ${answered.length} | dropped (no answer): ${calls.length - answered.length}`); +console.error(` by tool (answered):`, byTool); +console.error(` with a real choice recovered: ${withChoice}/${gates.length}`); +console.error(` session: ${sessionId ?? "(unknown)"}`); +console.error(` question: ${(replay.session.question ?? "(not found)").slice(0, 90)}`); +console.error(`wrote ${outPath.pathname}`); diff --git a/tools/replay/replay.json b/tools/replay/replay.json new file mode 100644 index 00000000..e89bc354 --- /dev/null +++ b/tools/replay/replay.json @@ -0,0 +1,1117 @@ +{ + "source": { + "transcripts": [ + "/Users/mp/.pi/agent/sessions/--Users-mp-projects-ThothII-harness--/2026-07-03T17-07-32-977Z_019f28f3-2bf1-770f-83e0-87d5e95e45f9.jsonl" + ], + "extractedAt": "2026-07-05T13:36:15.002Z", + "note": "gates without a reviewer answer (resume re-entries) are dropped" + }, + "session": { + "id": "2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul", + "question": "fammi la lista dei pazienti che negli ultimi 15 anni hanno avuto un'ablazione seguita dall'impianto di un defibrillatore", + "created_at": "2026-07-03T17:07:32.977Z" + }, + "stats": { + "transcriptFiles": 1, + "totalReviewerCalls": 20, + "answeredGates": 20, + "droppedUnanswered": 0 + }, + "gates": [ + { + "callId": "call_c5572b38f6e346429db2ef44", + "toolName": "reviewer_select", + "ts": "2026-07-03T17:12:33.930Z", + "descriptor": { + "type": "ui_request", + "id": "u0", + "phase": "replay", + "schema_version": 1, + "widget": "select", + "title": "F1 — Chiarimento 1/3: cosa intendiamo per \"impianto di un defibrillatore\"?", + "intro": "Domanda: «pazienti che negli ultimi 15 anni hanno avuto un'ablazione seguita dall'impianto di un defibrillatore entro 10 anni dall'ablazione».\n\nDal dominio ICD (evidence «Dominio ICD») il defibrillatore ha 3 famiglie di eventi DISTINTE in fact separate:\n• fact_impianto_defibrillatore_impiantabile → IMPIANTO INIZIALE (prima messa a dimora del dispositivo)\n• fact_sostituzione_impianto_defibrillatore_impiantabile → SOSTITUZIONE/REVISIONE (cambio generatore/elettrocateteri)\n• fact_controllo_defibrillatore_impiantabile → CONTROLLO (visita ambulatoriale di follow-up)\n\nL'espressione «impianto di un defibrillatore» letteralmente indica l'impianto iniziale. Va confermato se contiamo solo quello oppure anche le sostituzioni (un paziente che ha ricevuto il dispositivo in sostituzione). I controlli di follow-up NON sono \"impianto\".", + "recommended": "icd-initial-only", + "options": [ + { + "id": "icd-initial-only", + "label": "Solo impianto iniziale (consigliato)", + "recommended": true + }, + { + "id": "icd-initial-plus-replacement", + "label": "Impianto iniziale + sostituzioni", + "recommended": false + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Solo impianto iniziale (consigliato)" + ], + "note": "Decisione registrata (concept_clarified): Solo impianto iniziale (consigliato)." + } + }, + { + "callId": "call_b26592cf49074d878fdce303", + "toolName": "reviewer_select", + "ts": "2026-07-03T17:20:48.101Z", + "descriptor": { + "type": "ui_request", + "id": "u1", + "phase": "replay", + "schema_version": 1, + "widget": "select", + "title": "F1 — Chiarimento 2/3: a cosa si applica la finestra «ultimi 15 anni»?", + "intro": "Domanda: «pazienti che negli ULTIMI 15 ANNI hanno avuto un'ablazione seguita dall'impianto di un defibrillatore ENTO 10 ANNI dall'ablazione».\n\nDue vincoli temporali, da ancorare:\n• «ultimi 15 anni» → serve un riferimento (oggi 2026-07-03) e serve sapere se vincola solo l'ablazione oppure entrambi gli eventi.\n• «entro 10 anni dall'ablazione» → gap tra ablazione e impianto ≤ 10 anni, con impianto DOPO l'ablazione (questo è chiaro).\n\nData di riferimento = oggi (2026-07-03). 15 anni indietro = 2011-07-03.", + "recommended": "win-ablation-only-rolling", + "options": [ + { + "id": "win-ablation-only-rolling", + "label": "15 anni solo sull'ablazione, mobile da oggi (consigliato)", + "recommended": true + }, + { + "id": "win-both-events-rolling", + "label": "Entrambi gli eventi nei 15 anni", + "recommended": false + }, + { + "id": "win-ablation-calendar-year", + "label": "Solo ablazione, su anni solari", + "recommended": false + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "15 anni solo sull'ablazione, mobile da oggi (consigliato)" + ], + "note": "Decisione registrata (concept_clarified): 15 anni solo sull'ablazione, mobile da oggi (consigliato)." + } + }, + { + "callId": "call_1e25e9635c424730bd26e6dd", + "toolName": "reviewer_select", + "ts": "2026-07-03T17:23:52.348Z", + "descriptor": { + "type": "ui_request", + "id": "u2", + "phase": "replay", + "schema_version": 1, + "widget": "select", + "title": "F1 — Chiarimento 3/3: cosa deve contenere la «lista dei pazienti»?", + "intro": "Domanda: «fammi la LISTA dei pazienti …».\n\nDevo definire la forma dell'output. Ogni paziente può avere più ablazioni e più impianti: vanno gestite le coppie qualificanti (ablazione nei 15 anni + un impianto successivo entro 10 anni).\n\nAnagrafica disponibile in dim_patient (cod_paz + dati identificativi/demografici).", + "recommended": "out-one-row-with-dates", + "options": [ + { + "id": "out-one-row-with-dates", + "label": "Una riga per paziente + date chiave (consigliato)", + "recommended": true + }, + { + "id": "out-one-row-per-pair", + "label": "Una riga per ogni coppia qualificante", + "recommended": false + }, + { + "id": "out-minimal-codpaz", + "label": "Solo cod_paz (lista minima)", + "recommended": false + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Una riga per paziente + date chiave (consigliato)" + ], + "note": "Decisione registrata (concept_clarified): Una riga per paziente + date chiave (consigliato)." + } + }, + { + "callId": "call_d224643f60554bfc963033b8", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:24:46.939Z", + "descriptor": { + "type": "ui_request", + "id": "u3", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "Fase 1 completata — passo alla Fase 2 (Memorie)?", + "artifact": { + "kind": "phase1_clarifications_recap", + "data": { + "domanda": "Lista dei pazienti che negli ultimi 15 anni hanno avuto un'ablazione seguita dall'impianto di un defibrillatore entro 10 anni dall'ablazione.", + "ancora_ablazione": "fact_studio_elettrofisiologico_endocavitario_ablazione con flag ablazione_transcatetere IS TRUE (ogni tipo di aritmia, nessun tipo specificato)", + "clarifications": [ + { + "concetto": "impianto_defibrillatore", + "decisione": "Solo impianto iniziale -> fact_impianto_defibrillatore_impiantabile (escluse sostituzioni e controlli)" + }, + { + "concetto": "finestra_temporale", + "decisione": "15 anni solo sull'ablazione, mobile da oggi 2026-07-03: ablazione in [2011-07-03, 2026-07-03]; impianto dopo l'ablazione e <= ablazione + 10 anni" + }, + { + "concetto": "output_lista_pazienti", + "decisione": "Una riga per paziente (DISTINCT cod_paz) + anagrafica dim_patient + data_ablazione + data_impianto + gap in anni; se piu' coppie qualificanti, prima coppia" + } + ] + } + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "Fase approvata (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_d20e3abfb6454834996cc206", + "toolName": "reviewer_decide", + "ts": "2026-07-03T17:25:32.871Z", + "descriptor": { + "type": "ui_request", + "id": "u4", + "phase": "replay", + "schema_version": 1, + "widget": "multiselect", + "title": "F2 — Memorie: nessuna memory riutilizzabile", + "allow_empty": true, + "options": [], + "selected": [], + "content": null, + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "note": "Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva." + } + }, + { + "callId": "call_34821e6a11374c72bfb7d896", + "toolName": "reviewer_decide", + "ts": "2026-07-03T17:26:08.567Z", + "descriptor": { + "type": "ui_request", + "id": "u5", + "phase": "replay", + "schema_version": 1, + "widget": "multiselect", + "title": "F3 — Riscrittura della domanda", + "allow_empty": false, + "options": [ + { + "id": "confirm-rewrite", + "label": "Conferma riscrittura (consigliato)", + "recommended": true, + "decision": { + "detail": "Elencare i pazienti che hanno avuto un'ablazione transcatetere di substrato aritmogeno e, successivamente, l'impianto iniziale di un defibrillatore (ICD) entro 10 anni dall'ablazione, dove l'ablazione e' avvenuta negli ultimi 15 anni.\n\nCondizioni:\n1) Popolazione: pazienti distinti in dim_patient (cod_paz).\n2) Ablazione (evento di partenza): riga di fact_studio_elettrofisiologico_endocavitario_ablazione con ablazione_transcatetere IS TRUE (qualsiasi tipo di aritmia; evidence «Dominio Ablazione»), con data_ablazione nella finestra mobile di 15 anni da oggi 2026-07-03, cioe' data_ablazione in [2011-07-03, 2026-07-03].\n3) Impianto ICD (evento successivo): riga di fact_impianto_defibrillatore_impiantabile (solo impianto iniziale; escluse sostituzioni fact_sostituzione_... e controlli fact_controllo_...; evidence «Dominio ICD»).\n4) Sequenza temporale: data_impianto > data_ablazione E data_impianto <= data_ablazione + 10 anni.\n5) Cardinalita' output: una riga per paziente (DISTINCT cod_paz); in caso di piu' coppie qualificanti (ablazione, impianto), la prima coppia qualificante.\n\nOutput atteso: lista con cod_paz + dati anagrafici (dim_patient) + data_ablazione + data_impianto + gap in anni tra i due eventi.", + "rationale": "Riscrittura conforme alle 3 decisioni di chiarimento F1 (impianto = solo iniziale; finestra 15 anni solo sull'ablazione mobile da oggi; output = una riga per paziente con date e gap).", + "subject": "domanda", + "type": "question_rewritten" + } + } + ], + "selected": [], + "content": null, + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "decisionTypes": [ + "question_rewritten" + ], + "note": "Registrate 1 decisioni: question_rewritten." + } + }, + { + "callId": "call_59b07fd003d24f97b25476af", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:27:16.529Z", + "descriptor": { + "type": "ui_request", + "id": "u6", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "Fase 3 completata — passo alla Fase 4 (Schema linking)?", + "artifact": { + "data": { + "file": "question.md" + }, + "kind": "question_md" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "Fase approvata (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_1acab58384cb4ed9b642ac13", + "toolName": "reviewer_decide", + "ts": "2026-07-03T17:33:47.338Z", + "descriptor": { + "type": "ui_request", + "id": "u7", + "phase": "replay", + "schema_version": 1, + "widget": "multiselect", + "title": "F4 — Schema linking: tabelle/colonne/esclusioni", + "allow_empty": true, + "options": [ + { + "id": "t-ablazione", + "label": "PROMUOVI fact_studio_elettrofisiologico_endocavitario_ablazione", + "recommended": true, + "decision": { + "detail": "Evento di partenza. PK cod_paz+num; data_time_key (YYYYMMDD); flag ablazione_transcatetere BOOLEAN (anchor: IS TRUE). Granularita' una riga per cod_paz+num (gestisce i redo).", + "rationale": "Ancora dell'ablazione dall'evidence «Dominio Ablazione».", + "subject": "fact_studio_elettrofisiologico_endocavitario_ablazione", + "type": "table_promoted" + } + }, + { + "id": "t-impianto", + "label": "PROMUOVI fact_impianto_defibrillatore_impiantabile", + "recommended": true, + "decision": { + "detail": "Evento successivo (impianto iniziale ICD). PK cod_paz+num; data_time_key (YYYYMMDD) = data impianto. Una riga per cod_paz+num.", + "rationale": "«impianto di un defibrillatore» = solo impianto iniziale (decisione F1).", + "subject": "fact_impianto_defibrillatore_impiantabile", + "type": "table_promoted" + } + }, + { + "id": "t-patient", + "label": "PROMUOVI dim_patient (anagrafica)", + "recommended": true, + "decision": { + "detail": "Anagrafica per la lista dei pazienti. Colonne: cod_paz, cognome, nome, data_nascita_time_key, sesso.", + "rationale": "Output = cod_paz + dati anagrafici (decisione F1).", + "subject": "dim_patient", + "type": "table_promoted" + } + }, + { + "id": "t-time", + "label": "PROMUOVI dim_time (due alias dt_abl / dt_imp)", + "recommended": true, + "decision": { + "detail": "Dimensione temporale, usata con due alias (dt_abl per l'ablazione, dt_imp per l'impianto). Colonne: day_key, full_date (DATE), year. full_date serve per i calcoli di finestra (>= '2011-07-03') e di gap (impianto entro ablazione+10 anni).", + "rationale": "data_time_key e' la FK a dim_time.day_key (non dichiarata, da aggiungere a mano nel join).", + "subject": "dim_time", + "type": "table_promoted" + } + }, + { + "id": "x-sostituzione", + "label": "ESCLUDI fact_sostituzione_impianto_defibrillatore_impiantabile", + "recommended": true, + "decision": { + "detail": "Esclusa: sostituzione/revisione ICD. Non e' un impianto iniziale.", + "rationale": "Decisione F1: «impianto» = solo impianto iniziale.", + "subject": "fact_sostituzione_impianto_defibrillatore_impiantabile", + "type": "table_excluded" + } + }, + { + "id": "x-controllo", + "label": "ESCLUDI fact_controllo_defibrillatore_impiantabile", + "recommended": true, + "decision": { + "detail": "Esclusa: controllo ambulatoriale di follow-up ICD (device follow-up). Non e' un impianto.", + "rationale": "Decisione F1: controlli non sono «impianto».", + "subject": "fact_controllo_defibrillatore_impiantabile", + "type": "table_excluded" + } + }, + { + "id": "c-ablazione", + "label": "COLONNE ablazione: cod_paz, num, data_time_key, ablazione_transcatetere", + "recommended": true, + "decision": { + "detail": "Colonne utilizzate: cod_paz, num, data_time_key, ablazione_transcatetere (IS TRUE).", + "rationale": "Colonne chiave dell'evento ablazione.", + "subject": "ablazione.cod_paz, .num, .data_time_key, .ablazione_transcatetere", + "type": "column_corrected" + } + }, + { + "id": "c-impianto", + "label": "COLONNE impianto: cod_paz, num, data_time_key", + "recommended": true, + "decision": { + "detail": "Colonne utilizzate: cod_paz, num, data_time_key (data impianto).", + "rationale": "Colonne chiave dell'evento impianto ICD.", + "subject": "impianto.cod_paz, .num, .data_time_key", + "type": "column_corrected" + } + } + ], + "selected": [], + "content": null, + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "decisionTypes": [ + "table_promoted", + "table_promoted", + "table_promoted", + "table_promoted", + "table_excluded", + "table_excluded", + "column_corrected", + "column_corrected" + ], + "note": "Registrate 8 decisioni: table_promoted, table_promoted, table_promoted, table_promoted, table_excluded, table_excluded, column_corrected, column_corrected." + } + }, + { + "callId": "call_0c6a563792f543f486bde30b", + "toolName": "reviewer_decide", + "ts": "2026-07-03T17:34:42.520Z", + "descriptor": { + "type": "ui_request", + "id": "u8", + "phase": "replay", + "schema_version": 1, + "widget": "multiselect", + "title": "F4 — Schema linking: join proposte", + "allow_empty": true, + "options": [ + { + "id": "j-abl-time", + "label": "JOIN ablazione → dim_time (dt_abl)", + "recommended": true, + "decision": { + "detail": "JOIN dim_time dt_abl ON dt_abl.day_key = ablazione.data_time_key — per la data dell'ablazione (finestra 15 anni e sequenza temporale). FK non dichiarata nello schema, aggiunta a mano.", + "rationale": "Ogni fact temporale si appoggia a dim_time tramite data_time_key (convenzione DWH).", + "subject": "join ablazione.data_time_key = dt_abl.day_key", + "type": "join_modified" + } + }, + { + "id": "j-imp-time", + "label": "JOIN impianto → dim_time (dt_imp)", + "recommended": true, + "decision": { + "detail": "JOIN dim_time dt_imp ON dt_imp.day_key = impianto.data_time_key — per la data dell'impianto.", + "rationale": "Idem, FK manuale.", + "subject": "join impianto.data_time_key = dt_imp.day_key", + "type": "join_modified" + } + }, + { + "id": "j-crossfact", + "label": "JOIN cross-fact ablazione.cod_paz = impianto.cod_paz", + "recommended": true, + "decision": { + "detail": "JOIN impianto ON impianto.cod_paz = ablazione.cod_paz — collega i due eventi sul paziente. E' la join centrale per la sequenza ablazione→impianto; il vincolo temporale (impianto dopo ablazione e entro 10 anni) si esprime sulle date tramite dt_abl/dt_imp.", + "rationale": "I due fact condividono cod_paz (FK a dim_patient); non esiste FK diretta tra i due fact.", + "subject": "join impianto.cod_paz = ablazione.cod_paz (cross-fact)", + "type": "join_modified" + } + }, + { + "id": "j-patient", + "label": "JOIN dim_patient (anagrafica)", + "recommended": true, + "decision": { + "detail": "JOIN dim_patient p ON p.cod_paz = ablazione.cod_paz — per i dati anagrafici in output.", + "rationale": "Anagrafica del paziente (dim_patient).", + "subject": "join dim_patient.cod_paz = ablazione.cod_paz", + "type": "join_modified" + } + } + ], + "selected": [], + "content": null, + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "decisionTypes": [ + "join_modified", + "join_modified", + "join_modified", + "join_modified" + ], + "note": "Registrate 4 decisioni: join_modified, join_modified, join_modified, join_modified." + } + }, + { + "callId": "call_987e3022f77340b0ba0ff3e8", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:36:01.889Z", + "descriptor": { + "type": "ui_request", + "id": "u9", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "Fase 4 completata — passo alla Fase 5 (Sintesi)?", + "artifact": { + "data": { + "file": "schema_linking.json", + "promoted": [ + "fact_studio_elettrofisiologico_endocavitario_ablazione", + "fact_impianto_defibrillatore_impiantabile", + "dim_patient", + "dim_time" + ], + "excluded": [ + "fact_sostituzione_impianto_defibrillatore_impiantabile", + "fact_controllo_defibrillatore_impiantabile" + ] + }, + "kind": "schema_linking_json" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "Fase approvata (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_a50d394d2c754dfd8ecd1a80", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:36:42.445Z", + "descriptor": { + "type": "ui_request", + "id": "u10", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "Fase 5 completata — passo alla Fase 6 (Piano CTE)?", + "artifact": { + "data": { + "check": "OK — completa per il Blocco 3", + "riepilogo": "Ablazione: fact_studio_elettrofisiologico_endocavitario_ablazione (ablazione_transcatetere IS TRUE). Impianto ICD: fact_impianto_defibrillatore_impiantabile. Cross-fact su cod_paz (entrambe FK a dim_patient). Escluse sostituzioni e controlli. Finestre temporali: ablazione in [2011-07-03, 2026-07-03]; impianto dopo e <= ablazione+10 anni." + }, + "kind": "synthesis_recap" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "Fase approvata (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_50df4a4b460447ee96a4627a", + "toolName": "reviewer_decide", + "ts": "2026-07-03T17:38:48.818Z", + "descriptor": { + "type": "ui_request", + "id": "u11", + "phase": "replay", + "schema_version": 1, + "widget": "multiselect", + "title": "F6 — Piano CTE (4 CTE in catena)", + "allow_empty": true, + "options": [ + { + "id": "cte-1-ablazioni", + "label": "CTE 1: ablazioni_recenti", + "recommended": true, + "decision": { + "detail": "Tutte le ablazioni transcatetere (flag ablazione_transcatetere IS TRUE) con data nel range [2011-07-03, 2026-07-03]. Mantiene chiavi cod_paz, num e data_ablazione (dt_abl.full_date). Tabelle: fact_studio_elettrofisiologico_endocavitario_ablazione JOIN dim_time dt_abl.", + "subject": "cte: ablazioni_recenti", + "type": "cte_plan" + } + }, + { + "id": "cte-2-impianti", + "label": "CTE 2: impianti_icd", + "recommended": true, + "decision": { + "detail": "Tutti gli impianti iniziali ICD con data. Mantiene chiavi cod_paz, num e data_impianto (dt_imp.full_date). Tabelle: fact_impianto_defibrillatore_impiantabile JOIN dim_time dt_imp.", + "subject": "cte: impianti_icd", + "type": "cte_plan" + } + }, + { + "id": "cte-3-sequenze", + "label": "CTE 3: sequenze_abl_imp (coppie qualificanti)", + "recommended": true, + "decision": { + "detail": "Join tra ablazioni_recenti e impianti_icd su cod_paz; mantiene solo le coppie con data_impianto > data_ablazione E data_impianto <= data_ablazione + 10 anni. Mantiene entrambe le chiavi e le date.", + "subject": "cte: sequenze_abl_imp", + "type": "cte_plan" + } + }, + { + "id": "cte-4-pazienti", + "label": "CTE 4: pazienti_qualificati (una riga/paziente + gap)", + "recommended": true, + "decision": { + "detail": "Una riga per paziente (DISTINCT cod_paz): dalla sequenze_abl_imp sceglie la prima coppia qualificante (ablazione piu' recente nel range) e calcola il gap in anni. Pronto per l'arricchimento anagrafico in F7.", + "subject": "cte: pazienti_qualificati", + "type": "cte_plan" + } + } + ], + "selected": [], + "content": null, + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "note": "ERRORE: tipo 'cte_plan' non valido. Tipi: concept_clarified, question_rewritten, table_promoted, table_excluded, column_corrected, join_modified, evidence_accepted, evidence_rejected, ambiguity_open, memory_rejected, cte_approved, cte_corrected, cte_rejected, sql_revised, sql_approved, sql_rejected, phase_approved, phase_auto_approved, phase_reopened, phase_skipped, datamart_requested, datamart_declined, decision_retracted, value_grounded, concept_formula_approved, concept_formula_rejected" + } + }, + { + "callId": "call_fedc3508e0b0472f8126882e", + "toolName": "reviewer_select", + "ts": "2026-07-03T17:40:02.196Z", + "descriptor": { + "type": "ui_request", + "id": "u12", + "phase": "replay", + "schema_version": 1, + "widget": "select", + "title": "F6 — Piano CTE: conferma il piano prima dei test", + "intro": "Piano CTE (4 CTE in catena, ognuno cattura un sottoinsieme informativo). L'approvazione vera e propria sara' per-CTE nei gate kind:\"cte_result\" dopo tht cte test. Qui chiedo solo il via libera al piano prima di iniziare a testare.\n\nCTE 1 — ablazioni_recenti: ablazioni transcatetere (ablazione_transcatetere IS TRUE) con data ∈ [2011-07-03, 2026-07-03]; chiavi cod_paz, num, data_ablazione.\nCTE 2 — impianti_icd: impianti ICD iniziali con data; chiavi cod_paz, num, data_impianto.\nCTE 3 — sequenze_abl_imp: join su cod_paz, mantiene coppie con data_impianto > data_ablazione E <= data_ablazione + 10 anni.\nCTE 4 — pazienti_qualificati: una riga per paziente (prima coppia qualificante: ablazione piu' recente) + gap in anni.\n\nTutte le tabelle usate sono nel perimetro promosso (F4). I nomi di tabelle/colonne sono copiati esattamente dal contesto di schema.", + "recommended": "plan-ok", + "options": [ + { + "id": "plan-ok", + "label": "Approva il piano, procedi con i test CTE (consigliato)", + "recommended": true + }, + { + "id": "plan-revise", + "label": "Rivedi il piano (specifico con Altro)", + "recommended": false + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "note": "Scelta del reviewer: Approva il piano, procedi con i test CTE (consigliato)" + } + }, + { + "callId": "call_da9d097835fc4a7da5123199", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:41:40.163Z", + "descriptor": { + "type": "ui_request", + "id": "u13", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — CTE 1 approvato: ablazioni_recenti?", + "artifact": { + "data": { + "colonne": [ + "cod_paz", + "num", + "data_ablazione" + ], + "file": "ctes/ablazioni_recenti.sql", + "latenza_ms": 184, + "note": "Tutte le data_ablazione ricadono nel range [2011-07-03, 2026-07-03]; preview troncata a 10 righe. Mantiene le chiavi cod_paz e num.", + "preview": "cod_paz | num | data_ablazione\n3758 | 5 | 2018-09-24\n8175 | 473 | 2025-05-19\n152185 | 3 | 2026-05-28\n20446 | 61 | 2018-05-14\n25492 | 3 | 2013-11-28\n4664 | 94 | 2016-05-31\n39149 | 2 | 2018-04-05\n7243 | 38 | 2012-12-12\n22310 | 9 | 2013-07-15\n4848 | 408 | 2011-10-05", + "scopo": "Ablazioni transcatetere (ablazione_transcatetere IS TRUE) con data ∈ [2011-07-03, 2026-07-03]", + "warning": "righe = limit: risultato troncato dalla preview" + }, + "kind": "cte" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [], + "note": "Nessun CTE in attesa di approvazione (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul).", + "failed": true + } + }, + { + "callId": "call_689b08ba5bc7457a8f831211", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T17:47:16.945Z", + "descriptor": { + "type": "ui_request", + "id": "u14", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — Piano CTE (persisti cte_plan.json)", + "artifact": { + "data": { + "cte_plan": [ + { + "name": "ablazioni_recenti", + "purpose": "ablazioni transcatetere (flag IS TRUE) con data ∈ [2011-07-03, 2026-07-03]; chiavi cod_paz, num, data_ablazione" + }, + { + "name": "impianti_icd", + "purpose": "impianti ICD iniziali con data; chiavi cod_paz, num, data_impianto" + }, + { + "name": "sequenze_abl_imp", + "purpose": "join su cod_paz, coppie con impianto > ablazione e <= ablazione+10 anni" + }, + { + "name": "pazienti_qualificati", + "purpose": "una riga per paziente (prima coppia qualificante) + gap in anni" + } + ] + }, + "kind": "cte_plan" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "CTE plan approvato (4 CTE, sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_757bf476b6814668a36834fe", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T18:06:22.894Z", + "descriptor": { + "type": "ui_request", + "id": "u15", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — CTE 1 approvato: ablazioni_recenti?", + "artifact": { + "data": { + "colonne": [ + "cod_paz", + "num", + "data_ablazione" + ], + "file": "ctes/ablazioni_recenti.sql", + "latenza_ms": 184, + "note": "Tutte le data_ablazione ricadono nel range [2011-07-03, 2026-07-03]. Mantiene le chiavi cod_paz e num.", + "preview": "cod_paz | num | data_ablazione\n3758 | 5 | 2018-09-24\n8175 | 473 | 2025-05-19\n152185 | 3 | 2026-05-28\n20446 | 61 | 2018-05-14\n25492 | 3 | 2013-11-28\n4664 | 94 | 2016-05-31\n39149 | 2 | 2018-04-05\n7243 | 38 | 2012-12-12\n22310 | 9 | 2013-07-15\n4848 | 408 | 2011-10-05", + "scopo": "Ablazioni transcatetere (ablazione_transcatetere IS TRUE) con data ∈ [2011-07-03, 2026-07-03]", + "warning": "preview troncata a 10 righe" + }, + "kind": "cte" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "CTE 'ablazioni_recenti' approvato (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_25fb74e9370442f99381bd6c", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T18:08:26.667Z", + "descriptor": { + "type": "ui_request", + "id": "u16", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — CTE 2 approvato: impianti_icd?", + "artifact": { + "data": { + "colonne": [ + "cod_paz", + "num", + "data_impianto" + ], + "file": "ctes/impianti_icd.sql", + "latenza_ms": 150, + "note": "Tutti gli impianti ICD iniziali (fact_impianto_defibrillatore_impiantabile) con data. Nessun filtro temporale qui: la finestra si applica all'ablazione (CTE3 applichera' i vincoli di sequenza).", + "preview": "cod_paz | num | data_impianto\n20930 | 3 | 2011-11-16\n20999 | 12 | 2012-08-31\n21245 | 2 | 2011-11-14\n21336 | 3 | 2011-11-23\n547500 | 1 | 2026-06-03\n21350 | 1 | 2011-11-21\n21356 | 2 | 2011-11-25\n21920 | 65 | 2023-07-12\n21980 | 2 | 2012-04-03\n22135 | 2 | 2012-04-23", + "scopo": "Impianti ICD iniziali con data", + "warning": "preview troncata a 10 righe" + }, + "kind": "cte" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "CTE 'impianti_icd' approvato (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_63bceb9c3a274d90a3de9b6c", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T18:09:45.451Z", + "descriptor": { + "type": "ui_request", + "id": "u17", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — CTE 3 approvato: sequenze_abl_imp?", + "artifact": { + "data": { + "colonne": [ + "cod_paz", + "num_ablazione", + "data_ablazione", + "num_impianto", + "data_impianto" + ], + "file": "ctes/sequenze_abl_imp.sql", + "latenza_ms": 119, + "note": "Tutte le coppie soddisfano impianto > ablazione e <= ablazione+10 anni; tutte le data_ablazione nel range. Il paziente 17445 compare due volte (2 ablazioni 670/675 -> stesso impianto 681): la deduplicazione e' demandata al CTE 4.", + "preview": "cod_paz | num_abl | data_ablazione | num_imp | data_impianto\n9234 | 340 | 2014-02-19 | 396 | 2014-04-16\n10775 | 6 | 2014-01-21 | 47 | 2014-07-08\n1423 | 2 | 2013-10-30 | 15 | 2015-04-29\n177191 | 3 | 2020-08-04 | 5 | 2020-09-23\n384737 | 1 | 2022-02-22 | 2 | 2022-02-23\n17445 | 675 | 2014-10-14 | 681 | 2017-01-26\n21060 | 3 | 2011-10-10 | 8 | 2012-03-22\n48477 | 2 | 2018-10-30 | 7 | 2019-04-03\n25198 | 2 | 2013-09-30 | 12 | 2014-03-08\n17445 | 670 | 2013-11-18 | 681 | 2017-01-26", + "scopo": "Coppie qualificanti ablazione -> impianto (su cod_paz, impianto dopo e <= 10 anni)", + "warning": "preview troncata a 10 righe" + }, + "kind": "cte" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "CTE 'sequenze_abl_imp' approvato (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_8ca58e2bd26a42b9bd46ff95", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T18:13:28.565Z", + "descriptor": { + "type": "ui_request", + "id": "u18", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "F6 — CTE 4 approvato: pazienti_qualificati?", + "artifact": { + "data": { + "colonne": [ + "cod_paz", + "num_ablazione", + "data_ablazione", + "num_impianto", + "data_impianto", + "gap_anni" + ], + "file": "ctes/pazienti_qualificati.sql", + "latenza_ms": 137, + "note": "Verifica aggiuntiva con COUNT: 352 pazienti qualificati = 352 cod_paz DISTINCT (deduplicazione OK, una riga per paziente). gap_anni = DATE_PART('year', AGE(data_impianto, data_ablazione)); tutte le coppie con gap <= 10 anni e ablazione nel range.", + "preview": "cod_paz | num_abl | data_ablazione | num_imp | data_impianto | gap_anni\n1423 | 17 | 2015-04-23 | 15 | 2015-04-29 | 0\n1465 | 4 | 2012-03-05 | 162 | 2013-07-29 | 1\n1900 | 8 | 2013-06-19 | 333 | 2018-01-25 | 4\n2054 | 5 | 2011-12-06 | 47 | 2017-12-18 | 6\n5185 | 41 | 2016-04-05 | 58 | 2022-10-13 | 6\n5325 | 16 | 2013-05-21 | 36 | 2017-03-01 | 3\n5556 | 5 | 2011-07-04 | 8 | 2011-10-27 | 0\n5659 | 139 | 2017-12-04 | 148 | 2023-10-05 | 5\n8104 | 22 | 2011-11-21 | 25 | 2012-02-27 | 0\n9234 | 340 | 2014-02-19 | 396 | 2014-04-16 | 0", + "scopo": "Una riga per paziente (DISTINCT cod_paz, ablazione piu' recente) + gap in anni", + "totale_pazienti": 352, + "warning": "preview troncata a 10 righe su 352" + }, + "kind": "cte" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "CTE 'pazienti_qualificati' approvato (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + }, + { + "callId": "call_914b32b53159407db91f8f27", + "toolName": "reviewer_confirm", + "ts": "2026-07-03T18:14:05.032Z", + "descriptor": { + "type": "ui_request", + "id": "u19", + "phase": "replay", + "schema_version": 1, + "widget": "artifact-gate", + "title": "Fase 6 completata — passo alla Fase 7 (SQL finale)?", + "artifact": { + "data": { + "ctes_approvati": [ + "ablazioni_recenti", + "impianti_icd", + "sequenze_abl_imp", + "pazienti_qualificati" + ], + "totale_pazienti_qualificati": 352 + }, + "kind": "cte_plan_done" + }, + "action": { + "kind": "approve_reject", + "prompt": "Approvi o rifiuti?" + }, + "options": [ + { + "id": "approve", + "label": "Salva e procedi", + "recommended": true + }, + { + "id": "reject", + "label": "Rifiuta" + } + ], + "reserved": [ + "back", + "exit", + "other" + ] + }, + "real": { + "labels": [ + "Salva e procedi" + ], + "note": "Fase approvata (sessione 2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul)." + } + } + ] +} diff --git a/tools/replay/server.mjs b/tools/replay/server.mjs new file mode 100644 index 00000000..9b7148dd --- /dev/null +++ b/tools/replay/server.mjs @@ -0,0 +1,480 @@ +// Standalone replay server: serves the built SPA + a tiny SSE/REST shim that +// replays the recorded reviewer gates of a logged session, no VPN/Pi/DWH needed. +// +// Same-origin on :5333. The SPA bundle is built once with +// VITE_BACKEND_URL=http://localhost:5333 (see scripts/replay.sh), so all REST +// + SSE calls land on this server and we control the entire interaction loop. +// +// Run: node tools/replay/server.mjs (after `scripts/replay.sh build`) + +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, extname, normalize } from "node:path"; +import { homedir } from "node:os"; +import { createRequire } from "node:module"; + +const PORT = Number(process.env.PORT ?? 5333); +const HERE = dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = join(HERE, "web"); +const REPLAY_PATH = join(HERE, "replay.json"); + +const require = createRequire(import.meta.url); +const replay = JSON.parse(await readFile(REPLAY_PATH, "utf8")); +const REPLAY_SESSION_ID = replay.session.id; +const GATES = replay.gates; + +if (!existsSync(WEB_DIR)) { + console.error(`Missing ${WEB_DIR}. Run: scripts/replay.sh build`); + process.exit(1); +} + +// --- replay state (single-user, single-session) -------------------------- +// One cursor advances across the gate sequence. The SSE connection streams the +// gate at the cursor; a POST /response advances the cursor and the next gate is +// pushed on the same SSE stream. A fresh SSE connection (browser refresh, or +// clicking the session again after completion) resets the cursor to 0, so the +// replay can be walked any number of times. +let cursor = 0; +let sseClient = null; // the active SSE response, if any + +function sendSse(res, eventName, data) { + res.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function currentGate() { + return { index: cursor, gate: GATES[cursor] ?? null }; +} + +// Replay-completion marker id prefix. The final ui_request carries this id so +// the response handler can recognise "the user wants to restart" and close the +// SSE (browser EventSource reconnects → cursor resets → gate 1 re-emitted). +const RESTART_ID_PREFIX = "replay-restart-"; + +function emitCurrentGate(res) { + const { index, gate } = currentGate(); + if (!gate) { + // End of replay: emit a final ui_request as a one-button select. This + // matters because the reducer sets pendingWidget on ANY ui_request, and + // pendingWidget!=null is exactly what stops the working spinner. Using a + // select (rather than widget=info) gives the user an in-place "Ripeti il + // replay" button: clicking it POSTs a response, which we recognise via the + // RESTART_ID_PREFIX and handle by closing the SSE — the browser EventSource + // then auto-reconnects, the cursor resets to 0, and gate 1 is re-emitted. + // We deliberately do NOT mark the session "finalized": that status makes + // the real backend refuse resume with 409, and we want the replay to be + // re-runnable any number of times. + const id = `${RESTART_ID_PREFIX}${Date.now()}`; + sendSse(res, "ui_request", { + type: "ui_request", + ui_request: { + id, + widget: "select", + title: `✓ Replay completato — ${GATES.length}/${GATES.length} gate riprodotti`, + intro: `Hai attraversato tutti i gate reviewer della sessione. Scegli cosa fare:`, + // Two real options so SelectWidget renders them as equal-weight buttons + // (a reserved "exit" would render as a small muted link instead). The + // response handler tells them apart by ui_response.choices[0]. + options: [ + { id: "restart", label: "↻ Ripeti il replay", recommended: true }, + { id: "exit", label: "■ Esci" }, + ], + }, + }); + console.error(`[done] replay completato (${GATES.length} gate)`); + return; + } + sendSse(res, "ui_request", { + type: "ui_request", + ui_request: gate.descriptor, + }); + console.error( + `[${index + 1}/${GATES.length}] -> ${gate.toolName}: ${gate.descriptor.title}`, + ); +} + +// --- helpers -------------------------------------------------------------- +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".map": "application/json; charset=utf-8", +}; + +async function serveStatic(req, res, urlPath) { + let p = normalize(join(WEB_DIR, urlPath)); + if (!p.startsWith(WEB_DIR)) { + res.writeHead(403); + res.end("forbidden"); + return; + } + // Directory requests (incl. "/") and missing files fall back to index.html + // (SPA: client-side routing handles all paths under /). + let isDir = false; + try { isDir = (await stat(p)).isDirectory(); } catch { /* missing */ } + if (isDir || !existsSync(p)) { + p = join(WEB_DIR, "index.html"); + } + try { + const data = await readFile(p); + res.writeHead(200, { "Content-Type": MIME[extname(p)] ?? "application/octet-stream" }); + res.end(data); + } catch { + res.writeHead(404); + res.end("not found"); + } +} + +function sendJson(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +function sendNoContent(res) { + res.writeHead(204); + res.end(); +} + +// Describe what the reviewer actually picked on 3 Jul, for the info badge. +function describeRealChoice(gate) { + const r = gate.real; + if (r.failed) return { level: "warning", text: `⚠ Gate fallito nella sessione reale: ${r.note}` }; + if (r.note && /Fase memoria vuota|avanzamento automatico/i.test(r.note)) { + return { level: "info", text: `ℹ Nella sessione reale questo gate fu saltato (memorie vuote, avanzamento automatico).` }; + } + const labels = r.labels ?? []; + const types = r.decisionTypes ?? []; + if (labels.length === 0 && types.length === 0) { + return { level: "info", text: `ℹ Scelta reale non campionata dal log: ${r.note ?? "—"}` }; + } + const parts = []; + if (labels.length) parts.push(labels.join(", ")); + if (types.length) parts.push(`decisioni: ${types.join(", ")}`); + return { level: "warning", text: `📌 SCELTA REALE (3 lug 2026): ${parts.join(" — ")}` }; +} + +// Compare the user's click to the real choice. Approve/Reject and select options +// use labels; decide uses decisionTypes (loose match — see compareChoices). +function compareUserChoice(gate, uiResponse) { + const r = gate.real; + const realLabels = new Set((r.labels ?? []).map((s) => s.toLowerCase())); + const realTypes = new Set((r.decisionTypes ?? [])); + const choiceIds = new Set(uiResponse.choices ?? []); + const opts = gate.descriptor.options ?? []; + + // select: choice id -> option label + if (gate.descriptor.widget === "select") { + const picked = opts.find((o) => choiceIds.has(o.id)); + if (!picked || realLabels.size === 0) return null; + const match = [...realLabels].some((rl) => picked.label.toLowerCase().includes(rl.split(" (")[0].toLowerCase())); + return match ? "✓ Hai scelto come il 3 luglio." : null; + } + // artifact-gate: approve/reject + if (gate.descriptor.widget === "artifact-gate") { + if (choiceIds.has("approve") && realLabels.has("salva e procedi")) { + return "✓ Hai scelto come il 3 luglio (Salva e procedi)."; + } + if (choiceIds.has("reject") && realLabels.has("rifiuta")) { + return "✓ Hai scelto come il 3 luglio (Rifiuta)."; + } + return null; + } + // multiselect: count of picked options vs number of real decision types + if (gate.descriptor.widget === "multiselect") { + if (realTypes.size === 0) return null; + const pickedCount = opts.filter((o) => choiceIds.has(o.id)).length; + return pickedCount === realTypes.size + ? `✓ Stesso numero di scelte del 3 luglio (${pickedCount}).` + : null; + } + return null; +} + +// --- HTTP routing --------------------------------------------------------- +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const path = url.pathname; + const method = req.method; + + // CORS preflight (not needed same-origin, but harmless). + res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); + if (method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + // --- SSE stream (the heart of the replay) ------------------------------- + if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}/events`) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + sseClient = res; + // A fresh SSE connection restarts the replay from the beginning: this is + // what makes the session re-runnable after completion, and what makes a + // browser refresh re-walk the gates. (Single-user, single-session.) + cursor = 0; + req.on("close", () => { + if (sseClient === res) sseClient = null; + }); + emitCurrentGate(res); + return; + } + + // --- reviewer response: advance the cursor + push next gate on SSE ------ + if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/response`) { + const body = await readBody(req); + const uiResponse = (body?.ui_response ?? {}); + const isRestartId = typeof uiResponse.id === "string" && uiResponse.id.startsWith(RESTART_ID_PREFIX); + if (isRestartId) { + // The final "completed" select carries a RESTART_ID_PREFIX id with two + // options: "restart" (re-run the loop) and "exit" (park the session). + // Restart closes the SSE so the browser EventSource reconnects → cursor + // resets → gate 1. Exit keeps the SSE open and emits a parked info widget + // (no spinner, no replay); the user resumes via the sidebar menu. + const choice = (uiResponse.choices ?? [])[0]; + if (choice === "exit") { + // Emit a system_event the frontend interprets as "leave the live session + // view and return to the landing". The AppShell has an effect that calls + // stopSession() on system_event { event: "session_exit" }, which resets + // the active session and shows the landing again. The session stays in + // the sidebar list with status "open", so the user can click it to open + // the documents panel and press Resume there to restart the replay. + console.error("[exit] replay fermato, ritorno alla landing"); + if (sseClient) { + sendSse(sseClient, "system_event", { + type: "system_event", + event: "session_exit", + }); + } + return sendNoContent(res); + } + // Restart (default): close SSE to reconnect. + if (sseClient) { + try { sseClient.end(); } catch { /* already closed */ } + sseClient = null; + } + console.error("[restart] replay richiesto di nuovo dall'utente"); + return sendNoContent(res); + } + const { index, gate } = currentGate(); + const isLast = index === GATES.length - 1; + // Ack the POST first. Then push info badges + the next gate. The delay lets + // the browser clear its `stepMessages` (reset by WidgetHost's setLastUserEntry + // on POST completion) BEFORE our info badges arrive, so the "scelta reale" + // badge survives. On the LAST gate we skip the delay: the final "completed" + // ui_request (widget=select) must land ASAP to set pendingWidget and stop + // the spinner; setLastUserEntry does NOT reset pendingWidget, so order is safe. + const delay = isLast ? 0 : 150; + setTimeout(() => { + if (!gate) return; + logChoice(gate, uiResponse); + if (sseClient) { + const realDesc = describeRealChoice(gate); + sendSse(sseClient, "info", { type: "info", level: realDesc.level, text: realDesc.text }); + const match = compareUserChoice(gate, uiResponse); + if (match) sendSse(sseClient, "info", { type: "info", level: "info", text: match }); + } + cursor += 1; + if (sseClient) emitCurrentGate(sseClient); + }, delay); + return sendNoContent(res); + } + + // --- REST stubs the SPA needs to boot ----------------------------------- + if (method === "GET" && path === "/health") { + return sendJson(res, 200, { ok: true, replay: true, gates: GATES.length }); + } + if (method === "GET" && path === "/workspaces") { + return sendJson(res, 200, [{ name: "psd (replay)", file: "psd.yaml" }]); + } + if (method === "GET" && path === "/models") { + return sendJson(res, 200, { models: [{ id: "glm-5.2", provider: "zai", label: "GLM 5.2 (replay)" }] }); + } + if (method === "GET" && path === "/settings") { + return sendJson(res, 200, { + workspace: "psd", + provider: "zai", + model: "glm-5.2", + thinking: "medium", + }); + } + if (method === "GET" && path === "/sessions") { + // Status stays "open" forever: the replay must remain re-runnable, and the + // real backend refuses resume with 409 when status is "finalized". + return sendJson(res, 200, [ + { + id: REPLAY_SESSION_ID, + status: "open", + question: replay.session.question ?? "(replay)", + summary: "Replay 3 lug — 20 gate reviewer", + created_at: replay.session.created_at ?? "2026-07-03T17:07:32Z", + updated_at: null, + author: "replay", + name: "Replay 170732", + group: null, + archived: false, + }, + ]); + } + if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}`) { + return sendJson(res, 200, { + id: REPLAY_SESSION_ID, + status: "open", + phase: 1, + question: replay.session.question ?? "", + }); + } + if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}/documents`) { + return sendJson(res, 200, buildSessionDocuments(REPLAY_SESSION_ID)); + } + + // Resume: the frontend's doResume reuses the SAME SSE connection when the + // session is already active (the useSessionStream effect only re-runs on + // sessionId change), so simply resetting the cursor wouldn't reach the + // client. We forcibly CLOSE the current SSE connection: the browser's + // EventSource auto-reconnects, opening a fresh stream that resets the cursor + // to 0 and re-emits gate 1. This makes "resume immediately after the last + // gate" work without a hard refresh. + if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/resume`) { + if (sseClient) { + try { sseClient.end(); } catch { /* already closed */ } + sseClient = null; + } + return sendNoContent(res); + } + if (method === "POST" && /^\/sessions\/[^/]+\/(close|steer|rename|group|archive|unarchive)$/.test(path)) { + return sendNoContent(res); + } + if (method === "POST" && path === "/sessions") { + // New-session form: redirect into the replay instead. + return sendJson(res, 200, { id: REPLAY_SESSION_ID }); + } + if (method === "DELETE" && path === `/sessions/${REPLAY_SESSION_ID}`) { + return sendNoContent(res); + } + if (method === "PUT" && path === "/settings") { + return sendNoContent(res); + } + + // --- static SPA fallback ------------------------------------------------ + if (method === "GET") { + return serveStatic(req, res, path); + } + sendNoContent(res); +}); + +function logChoice(gate, uiResponse) { + const opts = gate.descriptor.options ?? []; + const choiceIds = uiResponse.choices ?? []; + const picked = opts.filter((o) => choiceIds.includes(o.id)).map((o) => o.label); + const real = gate.real; + const realDesc = real.labels?.length + ? real.labels.join(", ") + : real.decisionTypes?.length + ? `${real.decisionTypes.length} decisioni: ${real.decisionTypes.join(", ")}` + : real.failed + ? "FALLITO" + : "—"; + console.error( + ` <- user picked: ${picked.join(" | ") || uiResponse.control || "(empty)"} | reale: ${realDesc}`, + ); +} + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch { + resolve({}); + } + }); + }); +} + +// --- session documents ---------------------------------------------------- +// Mirror of harness/tht/session/store.py build_documents, so the frontend's +// GateArtifactBody can resolve file-reference artifacts (e.g. {file:"question.md"}) +// to their full content via GET /sessions/:id/documents. +const DOC_SPEC = [ + ["question.md", "F3", "revised_question", "Revised question", "markdown"], + ["schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"], + ["sql_final.sql", "F7", "sql", "Final SQL", "sql"], + ["validation_report.md", "finalize", "validation_report", "Validation report", "markdown"], + ["review_decisions.jsonl", "—", "decisions", "Decisions", "decisions"], +]; + +function buildSessionDocuments(sid) { + const dir = findSessionDir(sid); + const docs = [{ + phase: "—", + key: "question", + title: "Original question", + format: "text", + content: replay.session.question ?? "", + }]; + if (!dir) return docs; + for (const [filename, phase, key, title, fmt] of DOC_SPEC) { + const p = join(dir, filename); + if (existsSync(p)) { + docs.push({ phase, key, title, format: fmt, content: readFileSync(p, "utf8") }); + } + } + return docs; +} + +// Resolve a session id to its on-disk directory: check every workspace yaml in +// harness/workspaces/ for paths.sessions (absolute or relative), then the legacy +// PSD location. Same logic as extract.mjs findSessionDir. +function findSessionDir(sid) { + const repoRoot = join(HERE, "..", ".."); + const wsDir = join(repoRoot, "harness/workspaces"); + const candidates = []; + if (existsSync(wsDir)) { + for (const f of readdirSync(wsDir)) { + if (!/\.(ya?ml)$/i.test(f)) continue; + const ypath = join(wsDir, f); + let y; + try { y = readFileSync(ypath, "utf8"); } catch { continue; } + const m = y.match(/^paths:\s*\n(?:[ \t]+.*\n)*?[ \t]+sessions:\s*(\S+)/m); + if (!m) continue; + let p = m[1].replace(/^['"]|['"]$/g, "").replace(/\s+#.*$/, ""); + if (!p || p.includes("${")) continue; + candidates.push(p); + } + } + candidates.push(join(homedir(), "projects", "tht-workspace-psd", "sessions")); + for (const base of candidates) { + const abs = join(base, sid); + if (existsSync(abs)) return abs; + } + return null; +} + +server.listen(PORT, "127.0.0.1", () => { + console.error(`\n▶ ThothII replay: http://localhost:${PORT}`); + console.error(` sessione: ${REPLAY_SESSION_ID}`); + console.error(` domanda: ${(replay.session.question ?? "").slice(0, 100)}…`); + console.error(` ${GATES.length} gate reviewer da attraversare.`); + console.error(` Ctrl-C per uscire.\n`); +});