commit d595d07752aa7e4a8fcd6155afa7bb52e21a8e65
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 14 20:52:50 2026 +0900

    推論の記録に成功/失敗を付ける判定の画面を作る (依頼 183). pi05/verdict_viewer/ を足す. 判定は KIOXIA の runs/pi05_policy_verdicts_v1.csv (MASTER の保存先テーブルに行を足した) に追記だけで残し, 同じ run_stem はいちばん新しい行が正・labeler=ai は未確定. verdicts.py (列・追記・読み. 書く前に KIOXIA のマウントを確かめ, なければ止める. 追記の前に今のファイルを確かめる (改行・見出し・列の数・閉じていない引用符). シンボリックリンク・元の記録と datasets の中には書かない. 記録の外へ出るリンクは読まない), seed_verdicts.py (--run-input で {run_stem}.json の outcome を labeler=run_input として写す. --ai で印のない回を vlm_judge と同じモデルで判定. 素材は top.mp4 の方策が動いていた区間の後ろ寄り 6 コマ, なければ frames/ の最後を含む 6 枚. 2 枚未満は not_judgeable. API が使えない回は飛ばす. 人の行がある回には足さない (ロックの中で確かめる)), server.py + index.html (python3 一発で localhost:8765. 左に一覧 (既定は人の確定がまだの回・全部・失敗だけ・AI と人が違う回), 右に top.mp4 か frames/ のコマ送り・関節 6 本と爪のグラフ・AI の判定と理由・今の判定・この回の行. y/n/u/x で 1 行追記して次へ, ←→ で前後, pick/place はボタン. 読み込み中・押しっぱなしは書かない. 操作ごとの op_id で応答が届かず押し直しても 2 回書かない. ダイアログは使わない). テスト test_verdicts 31 (依頼 183. codex 7 回)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 pi05/verdict_viewer/index.html       | 329 ++++++++++++++++++++++++++++++
 pi05/verdict_viewer/seed_verdicts.py | 300 +++++++++++++++++++++++++++
 pi05/verdict_viewer/server.py        | 322 +++++++++++++++++++++++++++++
 pi05/verdict_viewer/test_verdicts.py | 378 +++++++++++++++++++++++++++++++++++
 pi05/verdict_viewer/verdicts.py      | 251 +++++++++++++++++++++++
 5 files changed, 1580 insertions(+)

diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html
new file mode 100644
index 0000000..461bab8
--- /dev/null
+++ b/pi05/verdict_viewer/index.html
@@ -0,0 +1,329 @@
+<!doctype html>
+<html lang="ja">
+<head>
+<meta charset="utf-8">
+<title>推論の記録の判定</title>
+<style>
+  :root {
+    --surface-1: #fcfcfb; --surface-2: #f3f2ee; --border: #dddcd5;
+    --text-primary: #1f1f1d; --text-secondary: #55554f; --text-muted: #8a8a82;
+    --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --s6: #008300;
+    --grip: #55554f;
+    --ok: #006300; --bad: #b42318; --warn: #8a5a00; --accent: #2a78d6;
+  }
+  * { box-sizing: border-box; }
+  body { margin: 0; font: 13px/1.5 -apple-system, "Hiragino Sans", sans-serif; color: var(--text-primary);
+         background: var(--surface-1); height: 100vh; display: flex; }
+  #side { width: 300px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface-2); }
+  #side header { padding: 10px; border-bottom: 1px solid var(--border); }
+  #side h1 { font-size: 14px; margin: 0 0 6px; }
+  #filter { width: 100%; font-size: 13px; padding: 3px; }
+  #count { color: var(--text-secondary); margin-top: 4px; font-size: 12px; }
+  #list { overflow-y: auto; flex: 1; }
+  .item { padding: 5px 10px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; justify-content: space-between; gap: 6px; }
+  .item:hover { background: #ebeae4; }
+  .item.sel { background: #dbe8f8; }
+  .item .stem { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
+  .item .sub { color: var(--text-muted); font-size: 11px; }
+  .tag { font-size: 11px; padding: 0 5px; border-radius: 3px; border: 1px solid var(--border); white-space: nowrap; align-self: center; }
+  .tag.success { color: var(--ok); border-color: var(--ok); }
+  .tag.failure { color: var(--bad); border-color: var(--bad); }
+  .tag.unknown, .tag.not_judgeable { color: var(--warn); border-color: var(--warn); }
+  .tag.ai { border-style: dashed; }
+  #main { flex: 1; overflow-y: auto; padding: 12px 16px; }
+  #title { font-family: ui-monospace, Menlo, monospace; font-size: 15px; font-weight: 600; }
+  #meta { color: var(--text-secondary); font-size: 12px; margin-bottom: 8px; }
+  #top { display: flex; gap: 16px; align-items: flex-start; }
+  #media { width: 640px; flex: none; }
+  #media video, #media img { width: 640px; background: #000; display: block; }
+  #framebar { display: flex; gap: 8px; align-items: center; margin-top: 4px; }
+  #framebar input { flex: 1; }
+  #panel { flex: 1; min-width: 300px; }
+  .box { border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin-bottom: 10px; background: #fff; }
+  .box h3 { margin: 0 0 4px; font-size: 12px; color: var(--text-secondary); font-weight: 600; }
+  .big { font-size: 16px; font-weight: 600; }
+  .row { display: flex; gap: 6px; align-items: center; margin: 4px 0; flex-wrap: wrap; }
+  .row .lbl { width: 46px; color: var(--text-secondary); }
+  button { font: inherit; padding: 3px 9px; border: 1px solid var(--border); border-radius: 4px; background: #fff; cursor: pointer; }
+  button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
+  button.key { min-width: 110px; }
+  kbd { font: 11px ui-monospace, Menlo, monospace; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 3px; padding: 0 4px; background: var(--surface-2); }
+  #note { width: 100%; padding: 4px; font: inherit; }
+  #status { min-height: 20px; margin: 4px 0; font-size: 12px; }
+  #status.err { color: var(--bad); font-weight: 600; }
+  #status.ok { color: var(--ok); }
+  #chart { margin-top: 10px; position: relative; }
+  #chart svg text { font-size: 11px; fill: var(--text-secondary); }
+  #legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin: 4px 0; }
+  #legend span::before { content: ""; display: inline-block; width: 14px; height: 2px; vertical-align: middle; margin-right: 4px; background: var(--c); }
+  #tip { position: absolute; pointer-events: none; background: #fff; border: 1px solid var(--border); border-radius: 4px;
+         padding: 4px 6px; font-size: 11px; display: none; box-shadow: 0 1px 3px rgba(0,0,0,.12); white-space: nowrap; }
+  #tip i { display: inline-block; width: 10px; height: 2px; vertical-align: middle; margin-right: 4px; }
+  table.hist { border-collapse: collapse; font-size: 11px; width: 100%; }
+  table.hist td, table.hist th { border-bottom: 1px solid var(--border); padding: 2px 4px; text-align: left; vertical-align: top; }
+  .muted { color: var(--text-muted); }
+</style>
+</head>
+<body>
+<div id="side">
+  <header>
+    <h1>推論の記録の判定</h1>
+    <select id="filter">
+      <option value="todo">人の確定がまだの回</option>
+      <option value="all">全部</option>
+      <option value="failure">失敗だけ (今の判定)</option>
+      <option value="diff">AI と人が違う回</option>
+    </select>
+    <div id="count"></div>
+  </header>
+  <div id="list"></div>
+</div>
+<div id="main">
+  <div id="title">—</div>
+  <div id="meta"></div>
+  <div id="top">
+    <div id="media"></div>
+    <div id="panel">
+      <div class="box">
+        <h3>判定する (キー)</h3>
+        <div class="row">
+          <button class="key" data-oc="success"><kbd>y</kbd> 成功</button>
+          <button class="key" data-oc="failure"><kbd>n</kbd> 失敗</button>
+          <button class="key" data-oc="unknown"><kbd>u</kbd> 分からない</button>
+          <button class="key" data-oc="not_judgeable"><kbd>x</kbd> 判定できない</button>
+        </div>
+        <div class="row"><span class="lbl">pick</span>
+          <button data-pp="pick" data-v="yes">yes</button><button data-pp="pick" data-v="no">no</button><button data-pp="pick" data-v="unknown">unknown</button>
+          <span class="muted">選ばなければ 成功→yes / ほか→unknown</span></div>
+        <div class="row"><span class="lbl">place</span>
+          <button data-pp="place" data-v="yes">yes</button><button data-pp="place" data-v="no">no</button><button data-pp="place" data-v="unknown">unknown</button></div>
+        <div class="row"><input id="note" placeholder="ひとこと (空でよい・入力中はキーで判定しない)"></div>
+        <div id="status"></div>
+        <div class="muted"><kbd>←</kbd> <kbd>→</kbd> 前後の回 ・ <kbd>,</kbd> <kbd>.</kbd> コマ送り / 動画は <kbd>space</kbd> で再生</div>
+      </div>
+      <div class="box"><h3>AI の判定 (未確定)</h3><div id="ai"></div></div>
+      <div class="box"><h3>今の判定 (いちばん新しい行)</h3><div id="cur"></div></div>
+      <div class="box"><h3>この回の行 (古い順)</h3><div id="hist"></div></div>
+    </div>
+  </div>
+  <div id="chart"></div>
+</div>
+<script>
+const OC_JA = {success: "成功", failure: "失敗", unknown: "分からない", not_judgeable: "判定できない", "": "なし"};
+const LB_JA = {run_input: "記録時の入力", ai: "AI", human: "人 (AI と同じ)", human_fixed_ai: "人 (AI を直した)", human_only: "人 (AI なし)", "": ""};
+const COLORS = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6"].map(v => getComputedStyle(document.documentElement).getPropertyValue(v).trim());
+const pendingOps = {};  // run_stem → 書けたか分からない操作 (回ごとに残す. codex 6 回目 R1)
+let selSeq = 0;  // 応答が届かなかった操作. 同じ中身で押し直したら同じ op_id で送る (codex 2 回目 R2)
+let runs = [], view = [], cur = null, detail = null, pp = {pick: null, place: null}, frameIdx = 0, busy = false, loading = false;
+
+const $ = s => document.querySelector(s);
+const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}[c]));
+function status(msg, kind) { const el = $("#status"); el.textContent = msg; el.className = kind || ""; }
+function tag(oc, ai) { return oc ? `<span class="tag ${oc} ${ai ? "ai" : ""}">${ai ? "AI " : ""}${OC_JA[oc]}</span>` : `<span class="tag">印なし</span>`; }
+
+function applyFilter() {
+  const f = $("#filter").value;
+  view = runs.filter(r => f === "all" ? true : f === "todo" ? !r.confirmed : f === "failure" ? r.outcome === "failure"
+                          : (r.ai_outcome && r.human_outcome && r.ai_outcome !== r.human_outcome));
+  $("#count").textContent = `${view.length} 回 / 全 ${runs.length} 回 (人の確定済み ${runs.filter(r => r.confirmed).length})`;
+  $("#list").innerHTML = view.map(r => `<div class="item ${cur === r.run_stem ? "sel" : ""}" data-stem="${r.run_stem}">
+      <div><div class="stem">${r.run_stem.replace("pi05_", "")}</div>
+      <div class="sub">${r.has_mp4 ? "top.mp4" : r.n_frames ? "frames " + r.n_frames : "素材なし"} ・ ${LB_JA[r.labeler] || "—"}</div></div>
+      ${tag(r.outcome, r.labeler === "ai")}</div>`).join("");
+  document.querySelectorAll(".item").forEach(el => el.onclick = () => select(el.dataset.stem));
+}
+
+async function loadRuns() {
+  const res = await fetch("/api/runs"); const j = await res.json();
+  if (!res.ok || !Array.isArray(j.runs)) throw new Error(j.error || "一覧を読めません");  // 今の一覧は残す (codex 4 回目 R1)
+  runs = j.runs;
+  applyFilter();
+}
+
+async function select(stem) {
+  const seq = ++selSeq;  // いちばん新しい選択の応答だけ使う (codex 5 回目 R2・6 回目 R3)
+  if (!stem) {  // 空の一覧: 前の回の表示と状態を残さない (codex 7 回目 R1)
+    cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; markPP(); $("#note").value = "";
+    $("#title").textContent = "この絞り込みに残っている回はありません"; $("#meta").textContent = "";
+    $("#media").innerHTML = $("#chart").innerHTML = $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
+    return true;
+  }
+  // 読み込みが終わるまでは判定を受け付けない (前の回を見ながら次の回に書かない. codex R1)
+  cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; frameIdx = 0; $("#note").value = "";
+  const po = pendingOps[stem];
+  if (po) {
+    // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じキーで同じ op_id になる. codex 5 回目 R1)
+    pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note;
+  }
+  $("#title").textContent = stem + " (読み込み中)"; $("#media").innerHTML = ""; $("#chart").innerHTML = "";
+  $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
+  applyFilter(); markPP();
+  document.querySelector(`.item[data-stem="${stem}"]`)?.scrollIntoView({block: "nearest"});
+  let d;
+  try { const res = await fetch("/api/run/" + stem); d = await res.json(); if (!res.ok) throw new Error(d.error); }
+  catch (e) { if (seq === selSeq) { $("#title").textContent = stem; status("この回を読めませんでした: " + e.message + " ・ 一覧でもう一度選んでください", "err"); } return false; }
+  if (seq !== selSeq || d.run_stem !== cur) return false;
+  detail = d; loading = false;
+  $("#title").textContent = stem;
+  $("#meta").textContent = `送ったチャンク ${detail.executed_chunks} ・ 終わり方 ${detail.end_reason || "記録なし"} ・ ${detail.model || ""}`;
+  renderMedia(); renderInfo(); renderChart();
+  return true;
+}
+
+function renderMedia() {
+  const m = $("#media");
+  if (detail.has_mp4) {
+    m.innerHTML = `<video id="vid" controls muted preload="metadata" src="/media/${detail.run_stem}/top.mp4"></video><div class="muted">recording/top.mp4</div>`;
+    const v = $("#vid");
+    v.onerror = () => { if (detail.frames.length) { status("top.mp4 を再生できないので frames/ を出します", "err"); detail.has_mp4 = false; renderMedia(); } else status("top.mp4 を再生できません (frames/ もなし)", "err"); };
+    v.ontimeupdate = () => cursor(v.currentTime);
+  } else if (detail.frames.length) {
+    const n = detail.frames.length;
+    m.innerHTML = `<img id="frm"><div id="framebar"><button id="fp">‹</button><input id="fr" type="range" min="0" max="${n - 1}" value="${frameIdx}"><button id="fn">›</button><span id="fl"></span></div><div class="muted">frames/ (各チャンクの前に撮った入力画像)</div>`;
+    $("#fp").onclick = () => stepFrame(-1); $("#fn").onclick = () => stepFrame(1);
+    $("#fr").oninput = e => { frameIdx = +e.target.value; showFrame(); };
+    showFrame();
+  } else {
+    m.innerHTML = `<div class="box">映像がありません (top.mp4 も frames/ もない)</div>`;
+  }
+}
+function showFrame() {
+  const n = detail.frames.length; frameIdx = Math.max(0, Math.min(n - 1, frameIdx));
+  $("#frm").src = `/media/${detail.run_stem}/frames/${detail.frames[frameIdx]}`;
+  $("#fr").value = frameIdx; $("#fl").textContent = `${frameIdx + 1} / ${n}`;
+}
+function stepFrame(d) { if (detail && !detail.has_mp4 && detail.frames.length && $("#frm")) { frameIdx += d; showFrame(); } }
+
+function renderInfo() {
+  const a = detail.ai, l = detail.latest;
+  $("#ai").innerHTML = a ? `<div class="big">${OC_JA[a.outcome]}</div><div>pick ${a.pick} / place ${a.place} ・ 素材 ${a.evidence || "なし"}</div><div class="muted">${esc(a.ai_reason)}</div>` : `<span class="muted">AI の判定はありません</span>`;
+  $("#cur").innerHTML = l ? `<div class="big">${OC_JA[l.outcome]} <span class="muted" style="font-size:12px">${LB_JA[l.labeler]}</span></div><div>pick ${l.pick} / place ${l.place} ・ ${esc(l.labeled_at)}</div>${l.note ? `<div class="muted">${esc(l.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
+  $("#hist").innerHTML = detail.history.length ? `<table class="hist"><tr><th>時刻</th><th>判定</th><th>pick/place</th><th>付けた</th></tr>${detail.history.map(r => `<tr><td>${esc(r.labeled_at)}</td><td>${OC_JA[r.outcome]}</td><td>${r.pick}/${r.place}</td><td>${LB_JA[r.labeler]}</td></tr>`).join("")}</table>` : `<span class="muted">なし</span>`;
+}
+
+// ---- 関節と爪のグラフ (関節は 1 本の軸・爪は下に別の帯. 2 つの縦軸は使わない) ----
+let chartGeom = null;
+function renderChart() {
+  const s = detail.states, el = $("#chart");
+  if (!s.t.length) { el.innerHTML = `<div class="muted">関節の記録がありません (recording/states.csv も log.jsonl の state もない)</div>`; chartGeom = null; return; }
+  const W = Math.max(700, el.clientWidth || 900), H = 240, GH = 44, L = 44, R = 12, T = 8, gap = 22;
+  const tmax = Math.max(s.t[s.t.length - 1], ...(s.grip.map(g => g[0]))) || 1;
+  let lo = Infinity, hi = -Infinity;
+  s.joints.forEach(js => js.forEach(v => { lo = Math.min(lo, v); hi = Math.max(hi, v); }));
+  const pad = (hi - lo) * 0.06 || 5; lo -= pad; hi += pad;
+  const x = t => L + (W - L - R) * t / tmax, y = v => T + (H - T - 18) * (hi - v) / (hi - lo);
+  const gy0 = H + gap, gy = v => gy0 + GH - 6 - (GH - 12) * Math.max(0, Math.min(1, v));
+  const ticks = []; const step = niceStep((hi - lo) / 5);
+  for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) ticks.push(v);
+  const tstep = niceStep(tmax / 8); const tt = []; for (let v = 0; v <= tmax; v += tstep) tt.push(v);
+  let svg = `<svg width="${W}" height="${gy0 + GH + 20}" id="csvg">`;
+  ticks.forEach(v => svg += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="#ecebe6"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${+v.toFixed(1)}°</text>`);
+  tt.forEach(v => svg += `<text x="${x(v)}" y="${gy0 + GH + 14}" text-anchor="middle">${+v.toFixed(0)}s</text>`);
+  s.stages.forEach(([t, st]) => svg += `<line x1="${x(t)}" x2="${x(t)}" y1="${T}" y2="${gy0 + GH}" stroke="#d6d5ce" stroke-dasharray="2,3"/>`);
+  for (let j = 0; j < 6; j++) {
+    const d = s.t.map((t, i) => `${i ? "L" : "M"}${x(t).toFixed(1)},${y(s.joints[i][j]).toFixed(1)}`).join("");
+    svg += `<path d="${d}" fill="none" stroke="${COLORS[j]}" stroke-width="2" stroke-linejoin="round"/>`;
+  }
+  svg += `<rect x="${L}" y="${gy0}" width="${W - L - R}" height="${GH}" fill="none" stroke="#ecebe6"/>`;
+  svg += `<text x="${L - 6}" y="${gy(1) + 4}" text-anchor="end">閉</text><text x="${L - 6}" y="${gy(0) + 4}" text-anchor="end">開</text>`;
+  if (s.grip.length) {
+    let d = `M${x(0)},${gy(s.grip[0][1])}`, pv = s.grip[0][1];
+    s.grip.forEach(([t, v]) => { d += `L${x(t).toFixed(1)},${gy(pv)}L${x(t).toFixed(1)},${gy(v)}`; pv = v; });
+    d += `L${x(tmax)},${gy(pv)}`;
+    svg += `<path d="${d}" fill="none" stroke="var(--grip)" stroke-width="2"/>`;
+  }
+  svg += `<line id="cx" x1="0" x2="0" y1="${T}" y2="${gy0 + GH}" stroke="#1f1f1d" stroke-width="1" style="display:none"/>`;
+  svg += `<line id="vx" x1="0" x2="0" y1="${T}" y2="${gy0 + GH}" stroke="var(--s1)" stroke-width="1" style="display:none"/>`;
+  svg += `<rect id="hit" x="${L}" y="0" width="${W - L - R}" height="${gy0 + GH}" fill="transparent"/></svg>`;
+  el.innerHTML = `<div id="legend">${COLORS.map((c, j) => `<span style="--c:${c}">j${j}</span>`).join("")}<span style="--c:var(--grip)">爪 (下の帯・${esc(s.grip_label)})</span><span class="muted">${esc(s.source)}</span></div>${svg}<div id="tip"></div>`;
+  chartGeom = {x, tmax, L, W, R};
+  const hit = $("#hit"), tip = $("#tip"), cx = $("#cx");
+  hit.onmousemove = e => {
+    const r = hit.getBoundingClientRect(), t = (e.clientX - r.left) / r.width * tmax;
+    let i = 0; for (let k = 0; k < s.t.length; k++) { if (s.t[k] <= t) i = k; else break; }
+    let g = null; s.grip.forEach(([gt, v]) => { if (gt <= t) g = v; });
+    cx.setAttribute("x1", x(s.t[i])); cx.setAttribute("x2", x(s.t[i])); cx.style.display = "";
+    tip.innerHTML = `<b>${s.t[i].toFixed(1)} 秒</b><br>` + s.joints[i].map((v, j) => `<i style="background:${COLORS[j]}"></i>j${j} ${v.toFixed(1)}°`).join("<br>") + (g === null ? "" : `<br>爪 ${g}`);
+    tip.style.display = "block";
+    const left = e.clientX - el.getBoundingClientRect().left + 14;
+    tip.style.left = Math.min(left, W - 120) + "px"; tip.style.top = "30px";
+  };
+  hit.onmouseleave = () => { tip.style.display = "none"; cx.style.display = "none"; };
+}
+function niceStep(raw) { const p = Math.pow(10, Math.floor(Math.log10(raw || 1))); const n = raw / p; return (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * p; }
+function cursor(vt) {
+  if (!chartGeom || !detail.video_t || !detail.video_t.length) return;
+  const v = $("#vid"); const n = detail.video_t.length;
+  const i = Math.max(0, Math.min(n - 1, Math.round(vt / (v.duration || 1) * (n - 1))));
+  const xx = chartGeom.x(detail.video_t[i]); const l = $("#vx"); if (!l) return;
+  l.setAttribute("x1", xx); l.setAttribute("x2", xx); l.style.display = "";
+}
+
+function markPP() {
+  document.querySelectorAll("[data-pp]").forEach(b => b.classList.toggle("on", pp[b.dataset.pp] === b.dataset.v));
+}
+document.querySelectorAll("[data-pp]").forEach(b => b.onclick = () => {
+  const k = b.dataset.pp; pp[k] = pp[k] === b.dataset.v ? null : b.dataset.v; markPP(); b.blur();
+});
+document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); save(b.dataset.oc); });
+
+async function save(outcome) {
+  if (!cur || busy || loading || !detail || detail.run_stem !== cur) return;
+  busy = true; const stem = cur;
+  const payload = {run_stem: stem, outcome, pick: pp.pick, place: pp.place, note: $("#note").value};
+  const key = JSON.stringify(payload);
+  const prev = pendingOps[stem];
+  const opId = prev && prev.key === key ? prev.opId
+             : stem + ":" + Date.now() + ":" + Math.random().toString(36).slice(2);
+  pendingOps[stem] = {key, opId, payload};
+  const seqAtSave = selSeq;
+  let saved = false;
+  const posBefore = view.findIndex(r => r.run_stem === stem);
+  try {
+    const res = await fetch("/api/verdict", {method: "POST", headers: {"Content-Type": "application/json"},
+      body: JSON.stringify(Object.assign({op_id: opId}, payload))});
+    const j = await res.json();
+    if (!res.ok) { delete pendingOps[stem]; status("書けませんでした: " + j.error, "err"); return; }
+    saved = true;
+    status(`${stem} を「${OC_JA[outcome]}」で追記しました (${LB_JA[j.row.labeler]}・pick ${j.row.pick} / place ${j.row.place})`, "ok");
+    await loadRuns();
+    // 同じ位置 (確定で一覧から消えたらそこに来た次の回) へ進む
+    let next;
+    const still = view.findIndex(r => r.run_stem === stem);
+    if (still >= 0) next = view[still + 1] || view[still];
+    else next = view[Math.min(posBefore, view.length - 1)];
+    // 画面を進め終えてから消す (読み直しや次の回の読み込みに失敗して押し直しても同じ op_id. codex 3 回目 R1・4 回目 R2)
+    if (selSeq !== seqAtSave) { delete pendingOps[stem]; return; }  // 保存中に人が別の回を選んでいたら動かさない (codex 6 回目 R2)
+    if (await select(next ? next.run_stem : null)) delete pendingOps[stem];
+  } catch (e) {
+    if (saved) { status(`${stem} は書けています. 一覧の読み直しに失敗しました: ${e} ・ 押し直しても 2 回は書きません`, "err"); return; }
+    // 書けたのに応答が届かなかったこともある. 同じ回・同じ判定で押し直すと同じ op_id で送るので,
+    // サーバーは 2 回書かない (codex R7. 別の回を判定してから戻っても pendingOps に残る)
+    status("書けたか確かめられませんでした: " + e + " ・ 同じキーで押し直すと 2 回は書きません", "err");
+  }
+  finally { busy = false; }
+}
+
+function move(d) {
+  const i = view.findIndex(r => r.run_stem === cur);
+  const n = view[i < 0 ? 0 : Math.max(0, Math.min(view.length - 1, i + d))];
+  if (n) { status(""); select(n.run_stem); }
+}
+
+document.addEventListener("keydown", e => {
+  if (e.target.tagName === "INPUT" && e.target.type !== "range") { if (e.key === "Escape") e.target.blur(); return; }
+  if (e.metaKey || e.ctrlKey || e.altKey) return;
+  if (e.repeat && "ynux".includes(e.key)) return;  // 押しっぱなしで次の回まで書かない (codex R5)
+  const k = e.key;
+  if (k === "y") save("success"); else if (k === "n") save("failure");
+  else if (k === "u") save("unknown"); else if (k === "x") save("not_judgeable");
+  else if (k === "ArrowRight") { e.preventDefault(); move(1); } else if (k === "ArrowLeft") { e.preventDefault(); move(-1); }
+  else if (k === ".") stepFrame(1); else if (k === ",") stepFrame(-1);
+  else if (k === " " && $("#vid")) { e.preventDefault(); const v = $("#vid"); v.paused ? v.play() : v.pause(); }
+});
+$("#filter").onchange = () => { applyFilter(); if (!view.find(r => r.run_stem === cur)) select(view[0]?.run_stem); };
+window.onresize = () => detail && renderChart();
+loadRuns().then(() => select(view[0]?.run_stem)).catch(e => status("一覧を読めませんでした: " + e.message + " ・ KIOXIA をつないで読み直してください", "err"));
+</script>
+</body>
+</html>
diff --git a/pi05/verdict_viewer/seed_verdicts.py b/pi05/verdict_viewer/seed_verdicts.py
new file mode 100644
index 0000000..9deec01
--- /dev/null
+++ b/pi05/verdict_viewer/seed_verdicts.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""推論の記録の判定ファイルに, 今ある判定の写しと AI の先回り判定を足す (依頼 183).
+
+  python3 pi05/verdict_viewer/seed_verdicts.py --run-input      # {run_stem}.json の outcome を写す (labeler=run_input)
+  python3 pi05/verdict_viewer/seed_verdicts.py --ai             # 印のない回を AI が判定 (labeler=ai)
+  python3 pi05/verdict_viewer/seed_verdicts.py --ai --dry-run   # API を呼ばずに素材だけ数える
+
+- 元の記録は読むだけ. 判定ファイルには追記だけ (verdicts.append_rows)
+- すでに同じ種類の行がある回は足さない (何度流しても同じ行が増えない)
+- AI の素材: recording/top.mp4 があれば, 推論が動いていた区間 (top_frame_log.csv の mode が wait / home
+  でないコマ) の終わりまでから 6 コマ (後ろ寄り). なければ frames/ (各チャンクの入力画像) から最後を含む 6 枚.
+  2 枚に満たなければ API を呼ばずに not_judgeable
+  (終わりの wait / home では人がブロックを箱から出していることがあるので, そこは使わない)
+- API は今の vlm_judge (dynamic_tracking_limits/place/place_modules/vlm_judge.py) と同じモデル・キーの読み方.
+  使えない (キーがない・通信やクレジットのエラー) 回は行を書かずに飛ばし, 最後に数を出す
+"""
+import argparse
+import base64
+import csv
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+sys.path.insert(0, str(HERE.parent.parent / "dynamic_tracking_limits" / "place" / "place_modules"))
+import verdicts as V  # noqa: E402
+import vlm_judge  # noqa: E402
+
+N_IMAGES = 6
+MIN_IMAGES = 2
+MP4_FRACTIONS = (0.3, 0.5, 0.65, 0.8, 0.9, 1.0)
+MP4_WIDTH = 960
+API_TIMEOUT_SEC = 90.0
+API_MAX_TOKENS = 600
+
+PROMPT = """画像は mechArm 270 (ロボットアーム) の作業台を真上から見たもので, 時間の順に並んでいる ({n} 枚・{source}).
+課題は「赤いブロックをつかみ, 白い箱の中に置く」(put the red block in the white box). AI の方策が動かしている.
+{source_note}
+この回について, 次を判定してください.
+- pick: 赤いブロックを爪でつかんで持ち上げられたか (yes / no / unknown)
+- place: 最後に赤いブロックが白い箱の中にあるか (yes / no / unknown)
+- outcome: success (最後に赤いブロックが白い箱の中にある) / failure (箱の中にない・つかめていない・落とした) /
+  unknown (画像はあるが判別できない) / not_judgeable (画像が終わりまで写っておらず判定できない)
+以下の JSON だけで返答してください. 前置きや後置きの説明は一切含めないこと.
+{{"pick": "yes|no|unknown", "place": "yes|no|unknown", "outcome": "success|failure|unknown|not_judgeable", "reason": "日本語で 1〜2 文の短い理由"}}"""
+
+NOTE_MP4 = ("上カメラの録画から, 方策が動いていた区間の後ろ寄りのコマを選んだ. 最後の 1 枚は方策が止まる直前. "
+            "人の手が写ることがある (補正や置き直し).")
+NOTE_FRAMES = ("各画像は方策に渡した入力画像で, チャンク (動きのひとまとまり) を送る前に撮ったもの. "
+               "最後の 1 枚は最後のチャンクを送る前なので, その後の動きは写っていない. "
+               "赤いブロックが写っていない画像があってもよい (爪の中や箱の中にあることがある).")
+
+
+def mp4_active_end(run_dir, n_frames):
+    """top_frame_log.csv で mode が wait / home でない最後のコマ. 読めなければ最後のコマ."""
+    p = V.safe_file(run_dir, "recording", "top_frame_log.csv")
+    last = None
+    try:
+        if p is None:
+            raise FileNotFoundError
+        with open(p, newline="") as f:
+            for r in csv.DictReader(f):
+                if r.get("mode") not in ("wait", "home"):
+                    last = int(r["frame_idx"])
+    except Exception:
+        last = None
+    if last is None:
+        return n_frames - 1
+    return max(0, min(last, n_frames - 1))
+
+
+def mp4_images(run_dir):
+    import cv2
+    cap = cv2.VideoCapture(str(Path(run_dir) / "recording" / "top.mp4"))
+    try:
+        n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+        if n <= 0:
+            return [], {}
+        end = mp4_active_end(run_dir, n)
+        idxs = sorted(set(int(round(end * fr)) for fr in MP4_FRACTIONS))
+        out = []
+        for i in idxs:
+            cap.set(cv2.CAP_PROP_POS_FRAMES, i)
+            ok, img = cap.read()
+            if not ok or img is None:
+                continue
+            h, w = img.shape[:2]
+            if w > MP4_WIDTH:
+                img = cv2.resize(img, (MP4_WIDTH, int(h * MP4_WIDTH / w)))
+            ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
+            if ok:
+                out.append(buf.tobytes())
+        return out, {"mp4_frames": n, "active_end": end, "picked": idxs}
+    finally:
+        cap.release()
+
+
+def frames_images(run_dir):
+    names = V.frame_files(run_dir)
+    if not names:
+        return [], {}
+    if len(names) <= N_IMAGES:
+        pick = names
+    else:
+        step = (len(names) - 1) / (N_IMAGES - 1)
+        pick = [names[int(round(i * step))] for i in range(N_IMAGES)]
+    out = [(Path(run_dir) / "frames" / nm).read_bytes() for nm in pick]
+    return out, {"frames": len(names), "picked": pick}
+
+
+def material(run_dir):
+    """(evidence, 画像の bytes のリスト, メモ). 素材が足りなければ evidence='' で画像は空."""
+    if V.has_top_mp4(run_dir):
+        imgs, info = mp4_images(run_dir)
+        if len(imgs) >= MIN_IMAGES:
+            return "top.mp4", imgs, info
+    imgs, info = frames_images(run_dir)
+    if len(imgs) >= MIN_IMAGES:
+        return "frames", imgs, info
+    return "", [], info
+
+
+def api_key():
+    k = os.environ.get("ANTHROPIC_API_KEY")
+    if not k:
+        p = os.path.expanduser("~/.anthropic_key")
+        if os.path.isfile(p):
+            k = open(p).read().strip()
+            # 「export ANTHROPIC_API_KEY=...」の形で置かれていることがある (2026-11-14 に確かめた)
+            if k.startswith("export "):
+                k = k[len("export "):].strip()
+            if k.startswith("ANTHROPIC_API_KEY="):
+                k = k[len("ANTHROPIC_API_KEY="):].strip()
+            k = k.strip("'\"")
+    return k or None
+
+
+class ApiUnavailable(RuntimeError):
+    pass
+
+
+def call_api(key, imgs, evidence):
+    source = "上カメラの録画 top.mp4 のコマ" if evidence == "top.mp4" else "frames/ の入力画像"
+    note = NOTE_MP4 if evidence == "top.mp4" else NOTE_FRAMES
+    content = [{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg",
+                                            "data": base64.b64encode(b).decode("ascii")}} for b in imgs]
+    content.append({"type": "text", "text": PROMPT.format(n=len(imgs), source=source, source_note=note)})
+    payload = {"model": vlm_judge.VLM_MODEL, "max_tokens": API_MAX_TOKENS,
+               "messages": [{"role": "user", "content": content}]}
+    req = urllib.request.Request(vlm_judge.VLM_API_URL, data=json.dumps(payload).encode("utf-8"),
+                                 headers={"x-api-key": key, "anthropic-version": vlm_judge.VLM_ANTHROPIC_VERSION,
+                                          "content-type": "application/json"}, method="POST")
+    last_err = ""
+    for attempt in range(3):
+        try:
+            with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp:
+                body = json.loads(resp.read().decode("utf-8"))
+            text = "".join(c.get("text", "") for c in body.get("content", []) if c.get("type") == "text")
+            return vlm_judge._parse_json_from_text(text), text
+        except urllib.error.HTTPError as e:
+            last_err = f"http_{e.code}"
+            if e.code in (429, 500, 502, 503, 504, 529):
+                time.sleep(5 * (attempt + 1))
+                continue
+            raise ApiUnavailable(last_err)
+        except Exception as e:
+            last_err = type(e).__name__
+            time.sleep(3)
+    raise ApiUnavailable(last_err)
+
+
+def ai_row(run_stem, parsed, evidence, n_exec):
+    def pv(v):
+        return v if v in V.PICK_PLACE else "unknown"
+    oc = parsed.get("outcome") if isinstance(parsed, dict) else None
+    oc = oc if oc in V.OUTCOMES else "unknown"
+    reason = str(parsed.get("reason", "")).replace("\n", " ").strip() if isinstance(parsed, dict) else ""
+    return {"labeled_at": V.now_iso(), "run_stem": run_stem,
+            "pick": pv(parsed.get("pick")), "place": pv(parsed.get("place")), "outcome": oc, "labeler": "ai",
+            "ai_outcome": oc, "ai_reason": f"[送ったチャンク {n_exec}] {reason}", "evidence": evidence, "note": ""}
+
+
+def do_run_input(csv_path, runs_dir, dry_run):
+    have = V.latest_by_stem(V.read_rows(csv_path), labelers=("run_input",) + V.HUMAN_LABELERS)
+    rows = []
+    for stem in V.list_run_stems(runs_dir):
+        if stem in have:
+            continue
+        r = V.run_input_row(stem, V.read_run_json(Path(runs_dir) / stem))
+        if r:
+            rows.append(r)
+    c = Counter(r["outcome"] for r in rows)
+    print(f"[COPY ] 写す行 {len(rows)} (success {c['success']} / failure {c['failure']} / unknown {c['unknown']})"
+          + (" ※ dry-run なので書いていません" if dry_run else ""))
+    if not dry_run:
+        written = V.append_rows(rows, csv_path, skip_if_labeled_by=("run_input",) + V.HUMAN_LABELERS,
+                                 forbid_dirs=(runs_dir,))
+        if len(written) != len(rows):
+            print(f"[COPY ] 書くまでの間に人か写しの行が足されていた {len(rows) - len(written)} 回は足さなかった")
+    return c
+
+
+def do_ai(csv_path, runs_dir, dry_run, limit, only):
+    rows = V.read_rows(csv_path)
+    marked = set(V.latest_by_stem(rows, labelers=("run_input",)))
+    have_ai = set(V.latest_by_stem(rows, labelers=("ai",)))
+    human = set(V.latest_by_stem(rows, labelers=V.HUMAN_LABELERS))  # 人が確定した回には AI の行を足さない (codex R2)
+    targets = []
+    for stem in V.list_run_stems(runs_dir):
+        j = V.read_run_json(Path(runs_dir) / stem)
+        if j and j.get("outcome") not in (None, ""):
+            continue  # 印がある回は写しの対象
+        if stem in marked or stem in have_ai or stem in human:
+            continue
+        if only and stem not in only:
+            continue
+        targets.append(stem)
+    if limit:
+        targets = targets[:limit]
+    key = None if dry_run else api_key()
+    counts = Counter()
+    print(f"[AI   ] 対象 {len(targets)} 回 (モデル {vlm_judge.VLM_MODEL})")
+    for i, stem in enumerate(targets, 1):
+        rd = Path(runs_dir) / stem
+        n_exec = V.executed_chunks(V.read_log(rd))
+        evidence, imgs, info = material(rd)
+        if not imgs:
+            row = {"labeled_at": V.now_iso(), "run_stem": stem, "pick": "unknown", "place": "unknown",
+                   "outcome": "not_judgeable", "labeler": "ai", "ai_outcome": "not_judgeable",
+                   "ai_reason": f"[送ったチャンク {n_exec}] 素材が足りない (top.mp4 なし・frames/ {len(V.frame_files(rd))} 枚). API は呼んでいない",
+                   "evidence": "", "note": ""}
+        elif dry_run:
+            print(f"  {i:3d} {stem} {evidence} {len(imgs)} 枚 送ったチャンク {n_exec}")
+            counts["dry_run"] += 1
+            continue
+        elif not key:
+            counts["skipped"] += 1
+            print(f"  {i:3d} {stem} 飛ばした (API キーがない)")
+            continue
+        else:
+            try:
+                parsed, text = call_api(key, imgs, evidence)
+            except ApiUnavailable as e:
+                counts["skipped"] += 1
+                print(f"  {i:3d} {stem} 飛ばした (API: {e})")
+                continue
+            if not isinstance(parsed, dict):
+                counts["skipped"] += 1
+                print(f"  {i:3d} {stem} 飛ばした (答えを読めない: {text[:80]!r})")
+                continue
+            row = ai_row(stem, parsed, evidence, n_exec)
+        if dry_run:
+            print(f"  {i:3d} {stem} not_judgeable (素材なし) 送ったチャンク {n_exec}")
+            counts["not_judgeable"] += 1
+            continue
+        # 1 回ずつ書く (途中で止まっても済んだ回は残る). API を待つ間に人が確定していたら足さない
+        if not V.append_rows([row], csv_path, skip_if_labeled_by=("ai", "run_input") + V.HUMAN_LABELERS,
+                            forbid_dirs=(runs_dir,)):
+            counts["skipped_human"] += 1
+            print(f"  {i:3d} {stem} 足さなかった (待つ間に人か写しの行が足されていた)")
+            continue
+        counts[row["outcome"]] += 1
+        print(f"  {i:3d} {stem} {row['outcome']:13s} pick={row['pick']} place={row['place']} {evidence or '-'}  {row['ai_reason'][:70]}")
+    print("[AI   ] " + " / ".join(f"{k} {counts[k]}" for k in
+                                  ("success", "failure", "unknown", "not_judgeable", "skipped", "skipped_human", "dry_run") if counts[k] or k not in ("dry_run", "skipped_human")))
+    return counts
+
+
+def main():
+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--run-input", action="store_true", help="{run_stem}.json の outcome を写す")
+    ap.add_argument("--ai", action="store_true", help="印のない回を AI が判定する")
+    ap.add_argument("--dry-run", action="store_true", help="書かない・API を呼ばない")
+    ap.add_argument("--limit", type=int, default=0, help="AI の対象を先頭から N 回まで")
+    ap.add_argument("--stems", nargs="*", default=None, help="AI の対象をこの run_stem だけにする")
+    ap.add_argument("--runs-dir", default=str(V.RUNS_DIR))
+    a = ap.parse_args()
+    if not (a.run_input or a.ai):
+        ap.error("--run-input か --ai を付けてください")
+    try:
+        csv_path = V.VERDICTS_CSV  # 書き先は保存先テーブルの 1 か所だけ (codex R3. テストは関数に渡す)
+        V.ensure_ssd_mounted(csv_path)
+        if a.run_input:
+            do_run_input(csv_path, a.runs_dir, a.dry_run)
+        if a.ai:
+            do_ai(csv_path, a.runs_dir, a.dry_run, a.limit, set(a.stems) if a.stems else None)
+    except (V.NotMounted, V.BrokenCsv) as e:
+        print(f"[ABORT] {e}")
+        return 2
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/pi05/verdict_viewer/server.py b/pi05/verdict_viewer/server.py
new file mode 100644
index 0000000..7e67f1e
--- /dev/null
+++ b/pi05/verdict_viewer/server.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+"""推論の記録に成功/失敗を付ける判定の画面 (依頼 183).
+
+  python3 pi05/verdict_viewer/server.py            # http://localhost:8765/ をブラウザで開く
+  python3 pi05/verdict_viewer/server.py --no-open  # ブラウザを開かない
+
+- 元の記録 (runs/pi05_policy/) は読むだけ. 判定は pi05_policy_verdicts_v1.csv に 1 行ずつ追記するだけ
+- 書く前に KIOXIA のマウントを確かめ, なければ書かずに画面へエラーを返す
+- localhost だけで待ち受ける
+"""
+import argparse
+import csv
+import json
+import mimetypes
+import re
+import sys
+import threading
+import webbrowser
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+import verdicts as V  # noqa: E402
+
+STEM_RE = re.compile(r"^pi05_[0-9_]+$")
+FRAME_RE = re.compile(r"^[0-9A-Za-z_]+\.jpg$")
+MAX_STATE_POINTS = 1500
+
+
+def state_series(run_dir):
+    """関節 6 本と爪の時系列. recording/states.csv があればそれ, なければ log.jsonl の state."""
+    rd = Path(run_dir)
+    p = V.safe_file(rd, "recording", "states.csv")
+    t, joints, grip = [], [], []
+    stages = []
+    if p is not None:
+        t0 = None
+        with open(p, newline="") as f:
+            for r in csv.DictReader(f):
+                try:
+                    ts = float(r["ts"])
+                except (TypeError, ValueError):
+                    continue
+                t0 = ts if t0 is None else t0
+                ev = r.get("event_type")
+                if ev == "actual":
+                    try:
+                        joints.append([float(r[f"j{i}"]) for i in range(6)])
+                    except (TypeError, ValueError):
+                        continue
+                    t.append(round(ts - t0, 3))
+                elif ev == "gripper_cmd" and r.get("gripper_state") not in (None, ""):
+                    try:
+                        grip.append([round(ts - t0, 3), float(r["gripper_state"])])
+                    except ValueError:
+                        pass
+                elif ev == "stage":
+                    stages.append([round(ts - t0, 3), r.get("stage", "")])
+        if len(t) > MAX_STATE_POINTS:
+            k = len(t) / MAX_STATE_POINTS
+            idx = sorted(set(int(i * k) for i in range(MAX_STATE_POINTS)) | {len(t) - 1})
+            t = [t[i] for i in idx]
+            joints = [joints[i] for i in idx]
+        return {"source": "recording/states.csv", "t": t, "joints": joints, "grip": grip,
+                "grip_label": "爪の命令 (0 開 / 1 閉)", "stages": stages, "t0": t0}
+    t0 = None
+    for x in V.read_log(rd):
+        if "chunk" not in x or "state" not in x:
+            continue
+        try:
+            st = json.loads(x["state"]) if isinstance(x["state"], str) else x["state"]
+            ts = float(x["t"])
+        except Exception:
+            continue
+        if not isinstance(st, list) or len(st) < 6:
+            continue
+        t0 = ts if t0 is None else t0
+        t.append(round(ts - t0, 3))
+        joints.append([float(v) for v in st[:6]])
+        if len(st) >= 7:
+            grip.append([round(ts - t0, 3), float(st[6])])
+    return {"source": "log.jsonl の state (推論の前に読んだ値)", "t": t, "joints": joints, "grip": grip,
+            "grip_label": "state の爪 (7 番目の値)", "stages": [], "t0": t0}
+
+
+def video_times(run_dir, t0):
+    """top.mp4 の各コマの時刻 (states の始まりからの秒). 再生位置のしるしに使う."""
+    p = V.safe_file(run_dir, "recording", "top_frame_log.csv")
+    if t0 is None or p is None:
+        return None
+    out = []
+    try:
+        with open(p, newline="") as f:
+            for r in csv.DictReader(f):
+                out.append(round(float(r["capture_ts"]) - t0, 3))
+    except Exception:
+        return None
+    return out
+
+
+class App:
+    def __init__(self, runs_dir, csv_path):
+        self.runs_dir = Path(runs_dir)
+        self.csv_path = Path(csv_path)
+        self.lock = threading.Lock()
+        self.done_ops = {}  # 画面の 1 回の操作 (op_id) → 書いた行. 応答が届かず押し直しても 2 回書かない (codex R7)
+
+    def runs_summary(self):
+        rows = V.read_rows(self.csv_path)
+        latest = V.latest_by_stem(rows)
+        ai = V.latest_by_stem(rows, labelers=("ai",))
+        human = V.latest_by_stem(rows, labelers=V.HUMAN_LABELERS)
+        out = []
+        for stem in V.list_run_stems(self.runs_dir):
+            rd = self.runs_dir / stem
+            lt, a, h = latest.get(stem), ai.get(stem), human.get(stem)
+            out.append({
+                "run_stem": stem,
+                "has_mp4": V.has_top_mp4(rd),
+                "n_frames": len(V.frame_files(rd)),
+                "outcome": lt["outcome"] if lt else "",
+                "labeler": lt["labeler"] if lt else "",
+                "ai_outcome": a["outcome"] if a else "",
+                "human_outcome": h["outcome"] if h else "",
+                "confirmed": bool(lt and lt["labeler"] in V.HUMAN_LABELERS),
+            })
+        return out
+
+    def run_detail(self, stem):
+        rd = self.runs_dir / stem
+        rows = [r for r in V.read_rows(self.csv_path) if r["run_stem"] == stem]
+        ai = [r for r in rows if r["labeler"] == "ai"]
+        j = V.read_run_json(rd)
+        log = V.read_log(rd)
+        st = state_series(rd)
+        return {
+            "run_stem": stem,
+            "has_mp4": V.has_top_mp4(rd),
+            "frames": V.frame_files(rd),
+            "states": st,
+            "video_t": video_times(rd, st.get("t0")) if V.has_top_mp4(rd) else None,
+            "ai": ai[-1] if ai else None,
+            "latest": rows[-1] if rows else None,
+            "history": rows,
+            "executed_chunks": V.executed_chunks(log),
+            "end_reason": (j or {}).get("end_reason", ""),
+            "model": next((x.get("model") for x in log if x.get("type") == "model"), "")
+                     or ((j or {}).get("args") or {}).get("model", ""),
+        }
+
+    def add_verdict(self, body):
+        stem = body.get("run_stem", "")
+        if (not STEM_RE.match(stem) or not (self.runs_dir / stem).is_dir()
+                or not V.within(self.runs_dir, self.runs_dir / stem)):
+            raise ValueError(f"run_stem がありません: {stem!r}")
+        outcome = body.get("outcome")
+        if outcome not in V.OUTCOMES:
+            raise ValueError(f"outcome が正しくありません: {outcome!r}")
+        rd = self.runs_dir / stem
+        op_id = str(body.get("op_id") or "")
+        with self.lock:
+            if op_id and op_id in self.done_ops:
+                print(f"[SAVE ] {stem} 同じ操作の送り直し. 書いていない (op_id {op_id})")
+                return self.done_ops[op_id]
+            ai = V.latest_by_stem(V.read_rows(self.csv_path), labelers=("ai",)).get(stem)
+            default = "yes" if outcome == "success" else "unknown"
+            pick = body.get("pick") or default
+            place = body.get("place") or default
+            row = {"labeled_at": V.now_iso(), "run_stem": stem, "pick": pick, "place": place,
+                   "outcome": outcome, "labeler": V.human_labeler_for(outcome, ai),
+                   "ai_outcome": ai["outcome"] if ai else "", "ai_reason": ai["ai_reason"] if ai else "",
+                   "evidence": "top.mp4" if V.has_top_mp4(rd) else ("frames" if V.frame_files(rd) else ""),
+                   "note": str(body.get("note", "")).replace("\n", " ").strip()}
+            written = V.append_rows([row], self.csv_path, forbid_dirs=(self.runs_dir,))[0]
+            if op_id:
+                self.done_ops[op_id] = written
+            print(f"[SAVE ] {written['run_stem']} {written['outcome']} pick={written['pick']} "
+                  f"place={written['place']} labeler={written['labeler']}")
+            return written
+
+
+def make_handler(app):
+    class H(BaseHTTPRequestHandler):
+        def log_message(self, fmt, *args):
+            pass
+
+        def _send(self, code, data, ctype="application/json; charset=utf-8", extra=None):
+            if not isinstance(data, (bytes, bytearray)):
+                data = json.dumps(data, ensure_ascii=False).encode("utf-8")
+            self.send_response(code)
+            self.send_header("Content-Type", ctype)
+            self.send_header("Content-Length", str(len(data)))
+            self.send_header("Cache-Control", "no-store")
+            for k, v in (extra or {}).items():
+                self.send_header(k, v)
+            self.end_headers()
+            if self.command != "HEAD":
+                self.wfile.write(data)
+
+        def _file(self, stem, *rel):
+            # リンクでその回の記録の外へ出ない (codex R6・3 回目 R2)
+            path = V.safe_file(app.runs_dir / stem, *rel) if V.within(app.runs_dir, app.runs_dir / stem) else None
+            if path is None:
+                return self._send(404, {"error": "not found"})
+            size = path.stat().st_size
+            ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
+            rng = self.headers.get("Range")
+            start, end = 0, size - 1
+            if rng:
+                m = re.match(r"bytes=(\d*)-(\d*)", rng)
+                if m:
+                    if m.group(1):
+                        start = int(m.group(1))
+                        if m.group(2):
+                            end = min(int(m.group(2)), size - 1)
+                    elif m.group(2):
+                        start = max(0, size - int(m.group(2)))
+                if start > end or start >= size:
+                    self.send_response(416)
+                    self.send_header("Content-Range", f"bytes */{size}")
+                    self.end_headers()
+                    return
+            length = end - start + 1
+            self.send_response(206 if rng else 200)
+            self.send_header("Content-Type", ctype)
+            self.send_header("Accept-Ranges", "bytes")
+            self.send_header("Content-Length", str(length))
+            if rng:
+                self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
+            self.end_headers()
+            if self.command == "HEAD":
+                return
+            with open(path, "rb") as f:
+                f.seek(start)
+                left = length
+                while left > 0:
+                    chunk = f.read(min(1 << 20, left))
+                    if not chunk:
+                        break
+                    try:
+                        self.wfile.write(chunk)
+                    except (BrokenPipeError, ConnectionResetError):
+                        return
+                    left -= len(chunk)
+
+        def do_HEAD(self):
+            self.do_GET()
+
+        def do_GET(self):
+            u = urlparse(self.path)
+            parts = [unquote(p) for p in u.path.split("/") if p]
+            try:
+                if not parts:
+                    return self._send(200, (HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
+                if parts == ["api", "runs"]:
+                    return self._send(200, {"runs": app.runs_summary(), "csv": str(app.csv_path)})
+                if (len(parts) == 3 and parts[:2] == ["api", "run"] and STEM_RE.match(parts[2])
+                        and (app.runs_dir / parts[2]).is_dir() and V.within(app.runs_dir, app.runs_dir / parts[2])):
+                    return self._send(200, app.run_detail(parts[2]))
+                if len(parts) == 3 and parts[0] == "media" and STEM_RE.match(parts[1]) and parts[2] == "top.mp4":
+                    return self._file(parts[1], "recording", "top.mp4")
+                if (len(parts) == 4 and parts[0] == "media" and STEM_RE.match(parts[1]) and parts[2] == "frames"
+                        and FRAME_RE.match(parts[3])):
+                    return self._file(parts[1], "frames", parts[3])
+                return self._send(404, {"error": "not found"})
+            except Exception as e:  # 画面に理由を返す
+                return self._send(500, {"error": f"{type(e).__name__}: {e}"})
+
+        def do_POST(self):
+            if urlparse(self.path).path != "/api/verdict":
+                return self._send(404, {"error": "not found"})
+            try:
+                n = int(self.headers.get("Content-Length") or 0)
+                body = json.loads(self.rfile.read(n).decode("utf-8") or "{}")
+                row = app.add_verdict(body)
+                return self._send(200, {"ok": True, "row": row})
+            except (V.NotMounted, V.BrokenCsv) as e:
+                print(f"[ABORT] {e}")
+                return self._send(503, {"error": str(e)})
+            except ValueError as e:
+                return self._send(400, {"error": str(e)})
+            except Exception as e:
+                return self._send(500, {"error": f"{type(e).__name__}: {e}"})
+    return H
+
+
+def main():
+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--port", type=int, default=8765)
+    ap.add_argument("--no-open", action="store_true", help="ブラウザを開かない")
+    ap.add_argument("--runs-dir", default=str(V.RUNS_DIR))
+    ap.add_argument("--test-csv", default=None,
+                    help="画面の確かめ用. 判定を保存先テーブルの CSV でなくこのファイルに書く (ふだんは付けない)")
+    a = ap.parse_args()
+    if not Path(a.runs_dir).is_dir():
+        print(f"[ABORT] 記録のフォルダがありません ({a.runs_dir}). KIOXIA をつないでください")
+        return 2
+    csv_path = Path(a.test_csv) if a.test_csv else V.VERDICTS_CSV
+    if a.test_csv and V.within(a.runs_dir, csv_path):
+        print(f"[ABORT] --test-csv が記録のフォルダの中です ({csv_path}). 元の記録には書きません")
+        return 2
+    if a.test_csv:
+        print(f"[TEST ] 確かめ用: 判定は {csv_path} に書きます (保存先テーブルの CSV には書きません)")
+    app = App(a.runs_dir, csv_path)
+    srv = ThreadingHTTPServer(("127.0.0.1", a.port), make_handler(app))
+    url = f"http://localhost:{a.port}/"
+    print(f"[VIEW ] {url}  (判定は {csv_path} に追記. Ctrl-C で終わる)")
+    if not a.no_open:
+        threading.Timer(0.5, lambda: webbrowser.open(url)).start()
+    try:
+        srv.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        srv.server_close()
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/pi05/verdict_viewer/test_verdicts.py b/pi05/verdict_viewer/test_verdicts.py
new file mode 100644
index 0000000..07519a9
--- /dev/null
+++ b/pi05/verdict_viewer/test_verdicts.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""判定ファイルと判定の画面のテスト (依頼 183). KIOXIA にも API にも触らない (一時フォルダだけ).
+
+  python3 pi05/verdict_viewer/test_verdicts.py
+"""
+import json
+import os
+import sys
+import tempfile
+import threading
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest import mock
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+import verdicts as V  # noqa: E402
+import seed_verdicts as S  # noqa: E402
+import server  # noqa: E402
+
+
+def row(stem, outcome, labeler, **kw):
+    r = {"run_stem": stem, "pick": "unknown", "place": "unknown", "outcome": outcome, "labeler": labeler}
+    r.update(kw)
+    return r
+
+
+def make_runs(root):
+    """偽の記録: a = json に success, b = json の outcome null, c = frames と log.jsonl だけ, d = 何もない."""
+    root = Path(root)
+    for stem in ("pi05_20261001_000001", "pi05_20261001_000002", "pi05_20261001_000003", "pi05_20261001_000004"):
+        (root / stem).mkdir(parents=True)
+    a = root / "pi05_20261001_000001"
+    (a / f"{a.name}.json").write_text(json.dumps({"outcome": "success", "ended_at": "2026-10-01T00:00:10",
+        "judgments": {"pick": {"verdict": "yes"}, "place": {"verdict": "yes"}}}))
+    b = root / "pi05_20261001_000002"
+    (b / f"{b.name}.json").write_text(json.dumps({"outcome": None}))
+    c = root / "pi05_20261001_000003"
+    (c / "frames").mkdir()
+    for i in range(3):
+        (c / "frames" / f"{i:04d}.jpg").write_bytes(b"\xff\xd8\xff\xd9")
+    (c / "frames" / "._0000.jpg").write_bytes(b"x")
+    (c / "log.jsonl").write_text("\n".join(json.dumps({"chunk": str(i), "t": str(100 + i), "executed": "True",
+        "state": json.dumps([i, 1, 2, 3, 4, -30, 0 if i < 2 else 1])}) for i in range(3)) + "\n")
+    return root
+
+
+class TestAppend(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.csv = Path(self.tmp.name) / "v.csv"
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_append_only_keeps_existing_bytes(self):
+        V.append_rows([row("pi05_1", "success", "run_input"), row("pi05_2", "failure", "ai")], self.csv)
+        before = self.csv.read_bytes()
+        V.append_rows([row("pi05_1", "failure", "human_only")], self.csv)
+        after = self.csv.read_bytes()
+        self.assertTrue(after.startswith(before), "既存の行が変わった")
+        self.assertEqual(after.count(b"labeled_at,"), 1, "見出しが 2 回書かれた")
+        self.assertEqual(len(V.read_rows(self.csv)), 3)
+
+    def test_header_columns(self):
+        V.append_rows([row("pi05_1", "success", "ai")], self.csv)
+        self.assertEqual(self.csv.read_text().splitlines()[0].split(","), V.COLUMNS)
+
+    def test_latest_row_wins(self):
+        V.append_rows([row("pi05_1", "success", "ai"), row("pi05_2", "failure", "ai")], self.csv)
+        V.append_rows([row("pi05_1", "failure", "human_fixed_ai")], self.csv)
+        V.append_rows([row("pi05_1", "unknown", "human_only")], self.csv)
+        lat = V.latest_by_stem(V.read_rows(self.csv))
+        self.assertEqual(lat["pi05_1"]["outcome"], "unknown")
+        self.assertEqual(lat["pi05_2"]["outcome"], "failure")
+        self.assertEqual(V.latest_by_stem(V.read_rows(self.csv), labelers=("ai",))["pi05_1"]["outcome"], "success")
+
+    def test_bad_values_write_nothing(self):
+        V.append_rows([row("pi05_1", "success", "ai")], self.csv)
+        before = self.csv.read_bytes()
+        for bad in (row("pi05_1", "great", "ai"), row("pi05_1", "success", "robot"),
+                    row("", "success", "ai"), row("pi05_1", "success", "ai", pick="maybe")):
+            with self.assertRaises(ValueError):
+                V.append_rows([row("pi05_9", "success", "ai"), bad], self.csv)
+        self.assertEqual(self.csv.read_bytes(), before, "まちがった行のときに途中まで書いた")
+
+    def test_comma_and_newline_in_reason(self):
+        V.append_rows([row("pi05_1", "failure", "ai", ai_reason='箱の外, "落とした"\n2 行目')], self.csv)
+        self.assertEqual(V.read_rows(self.csv)[0]["ai_reason"], '箱の外, "落とした"\n2 行目')
+
+    def test_broken_file_stops(self):
+        V.append_rows([row("pi05_1", "success", "ai")], self.csv)
+        with open(self.csv, "ab") as f:
+            f.write(b"2026,pi05_2,yes")  # 書きかけ (改行なし)
+        before = self.csv.read_bytes()
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_3", "success", "ai")], self.csv)
+        self.assertEqual(self.csv.read_bytes(), before)
+        other = Path(self.tmp.name) / "other.csv"
+        other.write_text("a,b\n1,2\n")
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_3", "success", "ai")], other)
+        self.assertEqual(other.read_text(), "a,b\n1,2\n")
+
+    def test_unclosed_quote_stops(self):
+        V.append_rows([row("pi05_1", "success", "ai")], self.csv)
+        with open(self.csv, "ab") as f:
+            f.write(b'2026,pi05_2,yes,yes,success,ai,,,,"unfinished\n')
+        before = self.csv.read_bytes()
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_3", "success", "ai")], self.csv)
+        self.assertEqual(self.csv.read_bytes(), before)
+
+    def test_long_note_is_cut(self):
+        V.append_rows([row("pi05_1", "success", "human_only", note="あ" * 200000, ai_reason="い" * 200000)], self.csv)
+        r = V.read_rows(self.csv)[0]
+        self.assertEqual(len(r["note"]), V.MAX_NOTE + 1)
+        self.assertEqual(len(r["ai_reason"]), V.MAX_REASON + 1)
+        V.append_rows([row("pi05_2", "success", "ai")], self.csv)
+
+    def test_forbid_dirs(self):
+        d = Path(self.tmp.name) / "myruns"
+        d.mkdir()
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_1", "success", "ai")], d / "v.csv", forbid_dirs=(d,))
+        self.assertFalse((d / "v.csv").exists())
+
+    def test_refuses_symlink_and_record_dirs(self):
+        real = Path(self.tmp.name) / "real.csv"
+        V.append_rows([row("pi05_1", "success", "ai")], real)
+        link = Path(self.tmp.name) / "link.csv"
+        link.symlink_to(real)
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_2", "success", "ai")], link)
+        runs = Path(self.tmp.name) / "runs"
+        (runs / "pi05_1").mkdir(parents=True)
+        with mock.patch.object(V, "RUNS_DIR", runs):
+            with self.assertRaises(V.BrokenCsv):
+                V.append_rows([row("pi05_2", "success", "ai")], runs / "pi05_1" / "x.csv")
+        with self.assertRaises(V.BrokenCsv):
+            V.append_rows([row("pi05_2", "success", "ai")], Path(self.tmp.name) / "x.json")
+        self.assertEqual(len(V.read_rows(real)), 1)
+
+    def test_skip_if_labeled_by(self):
+        V.append_rows([row("pi05_1", "failure", "human_only")], self.csv)
+        out = V.append_rows([row("pi05_1", "success", "ai"), row("pi05_2", "success", "ai")], self.csv,
+                            skip_if_labeled_by=V.HUMAN_LABELERS)
+        self.assertEqual([r["run_stem"] for r in out], ["pi05_2"])
+        self.assertEqual(V.latest_by_stem(V.read_rows(self.csv))["pi05_1"]["labeler"], "human_only")
+
+
+class TestMount(unittest.TestCase):
+    def test_not_mounted_stops_and_writes_nothing(self):
+        target = V.KIOXIA_MOUNT / "r2r2r-data" / "runs" / "pi05_policy_verdicts_v1_TEST_never.csv"
+        with mock.patch.object(V.os.path, "ismount", lambda p: False), \
+                mock.patch("builtins.open", side_effect=AssertionError("open してはいけない")):
+            with self.assertRaises(V.NotMounted):
+                V.append_rows([row("pi05_1", "success", "ai")], target)
+
+    def test_mounted_check_only_for_kioxia(self):
+        with mock.patch.object(V.os.path, "ismount", lambda p: False):
+            V.ensure_ssd_mounted("/tmp/whatever.csv")  # KIOXIA の外は確かめない (テスト用)
+
+    def test_seed_aborts_without_mount(self):
+        with mock.patch.object(V.os.path, "ismount", lambda p: False), \
+                mock.patch.object(sys, "argv", ["seed", "--run-input"]):
+            self.assertEqual(S.main(), 2)
+
+    def test_server_returns_503_without_mount(self):
+        with tempfile.TemporaryDirectory() as d:
+            runs = make_runs(Path(d) / "runs")
+            app = server.App(runs, V.KIOXIA_MOUNT / "r2r2r-data" / "runs" / "never_TEST.csv")
+            with mock.patch.object(V.os.path, "ismount", lambda p: False):
+                with self.assertRaises(V.NotMounted):
+                    app.add_verdict({"run_stem": "pi05_20261001_000003", "outcome": "success"})
+
+
+class TestLabeler(unittest.TestCase):
+    def test_labeler(self):
+        self.assertEqual(V.human_labeler_for("success", None), "human_only")
+        self.assertEqual(V.human_labeler_for("success", {"outcome": "success"}), "human")
+        self.assertEqual(V.human_labeler_for("failure", {"outcome": "success"}), "human_fixed_ai")
+
+
+class TestSeed(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.runs = make_runs(Path(self.tmp.name) / "runs")
+        self.csv = Path(self.tmp.name) / "v.csv"
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_run_input_copy_once(self):
+        c = S.do_run_input(self.csv, self.runs, dry_run=False)
+        self.assertEqual(c["success"], 1)
+        rows = V.read_rows(self.csv)
+        self.assertEqual([(r["run_stem"], r["outcome"], r["labeler"], r["pick"]) for r in rows],
+                         [("pi05_20261001_000001", "success", "run_input", "yes")])
+        S.do_run_input(self.csv, self.runs, dry_run=False)
+        self.assertEqual(len(V.read_rows(self.csv)), 1, "2 回流したら行が増えた")
+
+    def test_ai_targets_and_not_judgeable(self):
+        calls = []
+
+        def fake_api(key, imgs, evidence):
+            calls.append((len(imgs), evidence))
+            return {"pick": "yes", "place": "no", "outcome": "failure", "reason": "箱の外"}, ""
+        S.do_run_input(self.csv, self.runs, dry_run=False)
+        with mock.patch.object(S, "api_key", lambda: "k"), mock.patch.object(S, "call_api", fake_api):
+            c = S.do_ai(self.csv, self.runs, dry_run=False, limit=0, only=None)
+        self.assertEqual(calls, [(3, "frames")])  # c だけ API を呼ぶ (frames 3 枚・._ は除く)
+        self.assertEqual(c["failure"], 1)
+        self.assertEqual(c["not_judgeable"], 2)  # b (null・素材なし) と d (何もない)
+        ai = V.latest_by_stem(V.read_rows(self.csv), labelers=("ai",))
+        self.assertNotIn("pi05_20261001_000001", ai)
+        self.assertEqual(ai["pi05_20261001_000003"]["ai_reason"], "[送ったチャンク 3] 箱の外")
+        n = len(V.read_rows(self.csv))
+        with mock.patch.object(S, "api_key", lambda: "k"), mock.patch.object(S, "call_api", fake_api):
+            S.do_ai(self.csv, self.runs, dry_run=False, limit=0, only=None)
+        self.assertEqual(len(V.read_rows(self.csv)), n, "AI の行が 2 回書かれた")
+
+    def test_ai_skips_when_api_unavailable(self):
+        def boom(*a):
+            raise S.ApiUnavailable("http_401")
+        with mock.patch.object(S, "api_key", lambda: "k"), mock.patch.object(S, "call_api", boom):
+            c = S.do_ai(self.csv, self.runs, dry_run=False, limit=0, only=None)
+        self.assertEqual(c["skipped"], 1)
+        self.assertNotIn("pi05_20261001_000003", V.latest_by_stem(V.read_rows(self.csv)))
+
+    def test_seed_never_adds_after_human(self):
+        V.append_rows([row("pi05_20261001_000003", "success", "human_only"),
+                       row("pi05_20261001_000001", "failure", "human_only")], self.csv)
+        with mock.patch.object(S, "api_key", lambda: "k"), \
+                mock.patch.object(S, "call_api", lambda *a: ({"outcome": "failure"}, "")):
+            S.do_ai(self.csv, self.runs, dry_run=False, limit=0, only=None)
+        S.do_run_input(self.csv, self.runs, dry_run=False)
+        lat = V.latest_by_stem(V.read_rows(self.csv))
+        self.assertEqual(lat["pi05_20261001_000003"]["labeler"], "human_only")
+        self.assertEqual(lat["pi05_20261001_000001"]["labeler"], "human_only")
+
+    def test_seed_rechecks_inside_lock(self):
+        # API を待つ間に人が確定した → AI の行は足さない
+        def api_then_human(*a):
+            V.append_rows([row("pi05_20261001_000003", "failure", "human_only")], self.csv)
+            return {"outcome": "success"}, ""
+        with mock.patch.object(S, "api_key", lambda: "k"), mock.patch.object(S, "call_api", api_then_human):
+            c = S.do_ai(self.csv, self.runs, dry_run=False, limit=0, only={"pi05_20261001_000003"})
+        self.assertEqual(c["skipped_human"], 1)
+        self.assertEqual(V.latest_by_stem(V.read_rows(self.csv))["pi05_20261001_000003"]["labeler"], "human_only")
+
+    def test_symlinked_side_files_are_ignored(self):
+        outside = Path(self.tmp.name) / "outside.jsonl"
+        outside.write_text(json.dumps({"chunk": "0", "executed": "True"}) + "\n")
+        rd = self.runs / "pi05_20261001_000004"
+        (rd / "log.jsonl").symlink_to(outside)
+        (rd / f"{rd.name}.json").symlink_to(outside)
+        self.assertEqual(V.read_log(rd), [])
+        self.assertIsNone(V.read_run_json(rd))
+
+    def test_server_refuses_csv_inside_runs_dir(self):
+        app = server.App(self.runs, self.runs / "pi05_20261001_000003" / "v.csv")
+        with self.assertRaises(V.BrokenCsv):
+            app.add_verdict({"run_stem": "pi05_20261001_000003", "outcome": "success"})
+        self.assertFalse((self.runs / "pi05_20261001_000003" / "v.csv").exists())
+
+    def test_symlinked_run_and_frames_are_ignored(self):
+        outside = Path(self.tmp.name) / "outside"
+        (outside / "frames").mkdir(parents=True)
+        (outside / "frames" / "0000.jpg").write_bytes(b"secret")
+        (self.runs / "pi05_20261001_000099").symlink_to(outside)
+        (self.runs / "pi05_20261001_000003" / "frames" / "0009.jpg").symlink_to(outside / "frames" / "0000.jpg")
+        self.assertNotIn("pi05_20261001_000099", V.list_run_stems(self.runs))
+        self.assertNotIn("0009.jpg", V.frame_files(self.runs / "pi05_20261001_000003"))
+
+    def test_api_key_export_line(self):
+        with tempfile.TemporaryDirectory() as d:
+            p = Path(d) / ".anthropic_key"
+            p.write_text("export ANTHROPIC_API_KEY='sk-test'\n")
+            with mock.patch.dict(os.environ, {"HOME": d}, clear=False), \
+                    mock.patch.dict(os.environ, {}, clear=False) as env:
+                env.pop("ANTHROPIC_API_KEY", None)
+                self.assertEqual(S.api_key(), "sk-test")
+
+
+class TestServer(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.runs = make_runs(Path(self.tmp.name) / "runs")
+        self.csv = Path(self.tmp.name) / "v.csv"
+        V.append_rows([row("pi05_20261001_000003", "success", "ai", ai_reason="入っている", evidence="frames")], self.csv)
+        self.app = server.App(self.runs, self.csv)
+        self.srv = server.ThreadingHTTPServer(("127.0.0.1", 0), server.make_handler(self.app))
+        threading.Thread(target=self.srv.serve_forever, daemon=True).start()
+        self.base = f"http://127.0.0.1:{self.srv.server_address[1]}"
+
+    def tearDown(self):
+        self.srv.shutdown()
+        self.srv.server_close()
+        self.tmp.cleanup()
+
+    def get(self, path):
+        with urllib.request.urlopen(self.base + path) as r:
+            return json.loads(r.read())
+
+    def post(self, body):
+        req = urllib.request.Request(self.base + "/api/verdict", data=json.dumps(body).encode(),
+                                     headers={"Content-Type": "application/json"}, method="POST")
+        try:
+            with urllib.request.urlopen(req) as r:
+                return r.status, json.loads(r.read())
+        except urllib.error.HTTPError as e:
+            return e.code, json.loads(e.read())
+
+    def test_list_and_detail(self):
+        runs = {r["run_stem"]: r for r in self.get("/api/runs")["runs"]}
+        self.assertEqual(len(runs), 4)
+        self.assertEqual(runs["pi05_20261001_000003"]["ai_outcome"], "success")
+        self.assertFalse(runs["pi05_20261001_000003"]["confirmed"])
+        d = self.get("/api/run/pi05_20261001_000003")
+        self.assertEqual(d["frames"], ["0000.jpg", "0001.jpg", "0002.jpg"])
+        self.assertEqual(len(d["states"]["joints"]), 3)
+        self.assertEqual(d["states"]["grip"][-1][1], 1.0)
+        self.assertEqual(d["executed_chunks"], 3)
+
+    def test_post_appends_and_labeler(self):
+        before = self.csv.read_bytes()
+        st, j = self.post({"run_stem": "pi05_20261001_000003", "outcome": "failure", "place": "no", "note": "箱の縁"})
+        self.assertEqual(st, 200)
+        self.assertTrue(self.csv.read_bytes().startswith(before))
+        last = V.read_rows(self.csv)[-1]
+        self.assertEqual((last["labeler"], last["outcome"], last["pick"], last["place"], last["ai_outcome"], last["ai_reason"],
+                          last["evidence"], last["note"]),
+                         ("human_fixed_ai", "failure", "unknown", "no", "success", "入っている", "frames", "箱の縁"))
+        st, j = self.post({"run_stem": "pi05_20261001_000003", "outcome": "success"})
+        self.assertEqual((j["row"]["labeler"], j["row"]["pick"], j["row"]["place"]), ("human", "yes", "yes"))
+        st, j = self.post({"run_stem": "pi05_20261001_000004", "outcome": "not_judgeable"})
+        self.assertEqual(j["row"]["labeler"], "human_only")
+        runs = {r["run_stem"]: r for r in self.get("/api/runs")["runs"]}
+        self.assertTrue(runs["pi05_20261001_000003"]["confirmed"])
+        self.assertEqual(runs["pi05_20261001_000003"]["outcome"], "success")
+
+    def test_same_op_id_writes_once(self):
+        n = len(V.read_rows(self.csv))
+        b = {"op_id": "abc", "run_stem": "pi05_20261001_000003", "outcome": "failure"}
+        self.assertEqual(self.post(b)[0], 200)
+        self.assertEqual(self.post(b)[0], 200)
+        self.assertEqual(len(V.read_rows(self.csv)), n + 1)
+
+    def test_media_link_to_other_run_is_refused(self):
+        other = self.runs / "pi05_20261001_000004" / "frames"
+        other.mkdir()
+        (other / "0000.jpg").write_bytes(b"other")
+        (self.runs / "pi05_20261001_000003" / "frames" / "0008.jpg").symlink_to(other / "0000.jpg")
+        with self.assertRaises(urllib.error.HTTPError) as cm:
+            urllib.request.urlopen(self.base + "/media/pi05_20261001_000003/frames/0008.jpg")
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_post_rejects_bad(self):
+        n = len(V.read_rows(self.csv))
+        self.assertEqual(self.post({"run_stem": "pi05_20261001_000003", "outcome": "great"})[0], 400)
+        self.assertEqual(self.post({"run_stem": "../etc", "outcome": "success"})[0], 400)
+        self.assertEqual(self.post({"run_stem": "pi05_20990101_000000", "outcome": "success"})[0], 400)
+        self.assertEqual(len(V.read_rows(self.csv)), n)
+
+    def test_media_and_traversal(self):
+        with urllib.request.urlopen(self.base + "/media/pi05_20261001_000003/frames/0001.jpg") as r:
+            self.assertEqual(r.read(), b"\xff\xd8\xff\xd9")
+        for p in ("/media/pi05_20261001_000003/frames/..%2Flog.jsonl", "/media/..%2F..%2Fetc/top.mp4"):
+            with self.assertRaises(urllib.error.HTTPError) as cm:
+                urllib.request.urlopen(self.base + p)
+            self.assertEqual(cm.exception.code, 404)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=1)
diff --git a/pi05/verdict_viewer/verdicts.py b/pi05/verdict_viewer/verdicts.py
new file mode 100644
index 0000000..652fce9
--- /dev/null
+++ b/pi05/verdict_viewer/verdicts.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""推論の記録 (runs/pi05_policy/) の判定ファイルを読む・足す共通の部品 (依頼 183).
+
+判定ファイル: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy_verdicts_v1.csv (MASTER の保存先テーブル)
+  - 追記だけ. 既存の行は書き換えない. 同じ run_stem はいちばん新しい行 (ファイルの後ろの行) が正
+  - labeler=ai の行は未確定. 確定は人の行 (human / human_fixed_ai / human_only)
+  - 書く前に KIOXIA のマウントを確かめる. なければ止める (ローカルに逃がさない)
+元の記録 (runs/pi05_policy/ の中身) は読むだけ.
+"""
+import csv
+import fcntl
+import io
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+
+KIOXIA_MOUNT = Path("/Volumes/KIOXIA")
+DATA_ROOT = KIOXIA_MOUNT / "r2r2r-data"
+RUNS_DIR = DATA_ROOT / "runs" / "pi05_policy"
+VERDICTS_CSV = DATA_ROOT / "runs" / "pi05_policy_verdicts_v1.csv"
+
+COLUMNS = ["labeled_at", "run_stem", "pick", "place", "outcome", "labeler",
+           "ai_outcome", "ai_reason", "evidence", "note"]
+PICK_PLACE = ("yes", "no", "unknown")
+OUTCOMES = ("success", "failure", "unknown", "not_judgeable")
+LABELERS = ("run_input", "ai", "human", "human_fixed_ai", "human_only")
+HUMAN_LABELERS = ("human", "human_fixed_ai", "human_only")
+EVIDENCES = ("top.mp4", "frames", "both", "")
+MAX_NOTE = 500
+MAX_REASON = 2000
+
+
+class NotMounted(RuntimeError):
+    pass
+
+
+def ensure_ssd_mounted(csv_path=VERDICTS_CSV):
+    """KIOXIA の下に書くときはマウントを確かめる. なければ NotMounted (ローカルへは書かない).
+    テストで KIOXIA の外の一時ファイルに書くときは確かめない."""
+    p = Path(csv_path)
+    if str(p).startswith(str(KIOXIA_MOUNT) + os.sep) or p == KIOXIA_MOUNT:
+        if not os.path.ismount(str(KIOXIA_MOUNT)):
+            raise NotMounted(f"KIOXIA がマウントされていません ({KIOXIA_MOUNT}). 書かずに止めます")
+        if not p.parent.is_dir():
+            raise NotMounted(f"書き込み先のフォルダがありません ({p.parent}). 書かずに止めます")
+
+
+def now_iso():
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def validate_row(row):
+    r = {c: ("" if row.get(c) is None else str(row.get(c))) for c in COLUMNS}
+    # 長すぎる値は切る (csv の読み取りの上限を超えると以後読めなくなる. codex 3 回目 R3)
+    for c, n in (("note", MAX_NOTE), ("ai_reason", MAX_REASON)):
+        if len(r[c]) > n:
+            r[c] = r[c][:n] + "…"
+    if not r["run_stem"]:
+        raise ValueError("run_stem が空です")
+    if r["pick"] not in PICK_PLACE or r["place"] not in PICK_PLACE:
+        raise ValueError(f"pick / place は {PICK_PLACE} のどれか: {r['pick']!r} {r['place']!r}")
+    if r["outcome"] not in OUTCOMES:
+        raise ValueError(f"outcome は {OUTCOMES} のどれか: {r['outcome']!r}")
+    if r["labeler"] not in LABELERS:
+        raise ValueError(f"labeler は {LABELERS} のどれか: {r['labeler']!r}")
+    if r["ai_outcome"] not in OUTCOMES + ("",):
+        raise ValueError(f"ai_outcome は {OUTCOMES} のどれか か空: {r['ai_outcome']!r}")
+    if r["evidence"] not in EVIDENCES:
+        raise ValueError(f"evidence は {EVIDENCES} のどれか: {r['evidence']!r}")
+    if not r["labeled_at"]:
+        r["labeled_at"] = now_iso()
+    return r
+
+
+class BrokenCsv(RuntimeError):
+    pass
+
+
+def _check_existing(data):
+    """追記の前に今のファイルを確かめる. 見出しがちがう・最後が改行で終わらない・列の数がちがう行があれば止める
+    (そのまま足すと既存の最後の行に続けて書いてしまう. codex R4)."""
+    if not data:
+        return []
+    if not data.endswith(b"\n"):
+        raise BrokenCsv("判定ファイルの最後が改行で終わっていません (書きかけの行がある). 書かずに止めます")
+    try:  # strict: 閉じていない引用符も止める (codex 2 回目 R1)
+        rows = list(csv.reader(io.StringIO(data.decode("utf-8"), newline=""), strict=True))
+    except (csv.Error, UnicodeDecodeError) as e:
+        raise BrokenCsv(f"判定ファイルを読めません ({e}). 書かずに止めます")
+    if not rows or rows[0] != COLUMNS:
+        raise BrokenCsv("判定ファイルの見出しがちがいます. 書かずに止めます")
+    for i, r in enumerate(rows[1:], 2):
+        if len(r) != len(COLUMNS):
+            raise BrokenCsv(f"判定ファイルの {i} 行目の列の数が {len(r)} です ({len(COLUMNS)} のはず). 書かずに止めます")
+    return [dict(zip(COLUMNS, r)) for r in rows[1:]]
+
+
+def append_rows(rows, csv_path=VERDICTS_CSV, skip_if_labeled_by=None, forbid_dirs=()):
+    """行を末尾に足すだけ. 既存のバイトは触らない (追記モードで開く). 見出しはファイルが空のときだけ書く.
+    skip_if_labeled_by: この labeler の行がすでにある run_stem の行は足さない. 確かめはロックの中で行う
+    (seed が人の確定のあとに AI の行を足して未確定へ戻さないため. codex R2).
+    forbid_dirs: この中には書かない (使っている記録のフォルダ. codex 2 回目 R3). 書いた行を返す."""
+    rows = [validate_row(r) for r in rows]
+    ensure_ssd_mounted(csv_path)
+    if not rows:
+        return []
+    p = Path(csv_path)
+    if (p.suffix != ".csv" or within(RUNS_DIR, p) or within(DATA_ROOT / "datasets", p)
+            or any(within(d, p) for d in forbid_dirs)):
+        raise BrokenCsv(f"判定ファイルとして書けない場所です ({p}). 元の記録・datasets の中や .csv でないファイルには書きません")
+    if p.is_symlink():
+        raise BrokenCsv(f"判定ファイルがシンボリックリンクです ({p}). 書かずに止めます")
+    fd = os.open(str(p), os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o644)
+    with os.fdopen(fd, "r+b") as f:
+        fcntl.flock(f, fcntl.LOCK_EX)
+        try:
+            f.seek(0)
+            existing = _check_existing(f.read())
+            if skip_if_labeled_by:
+                done = {r["run_stem"] for r in existing if r.get("labeler") in skip_if_labeled_by}
+                rows = [r for r in rows if r["run_stem"] not in done]
+                if not rows:
+                    return []
+            buf = io.StringIO()
+            w = csv.DictWriter(buf, fieldnames=COLUMNS, lineterminator="\n")
+            if not existing and f.seek(0, os.SEEK_END) == 0:
+                w.writeheader()
+            for r in rows:
+                w.writerow(r)
+            f.write(buf.getvalue().encode("utf-8"))
+            f.flush()
+            os.fsync(f.fileno())
+        finally:
+            fcntl.flock(f, fcntl.LOCK_UN)
+    return rows
+
+
+def within(root, path):
+    """path がリンクをたどっても root の中にあるか (codex R6)."""
+    try:
+        Path(path).resolve().relative_to(Path(root).resolve())
+        return True
+    except (ValueError, OSError):
+        return False
+
+
+def read_rows(csv_path=VERDICTS_CSV):
+    p = Path(csv_path)
+    if not p.exists():
+        return []
+    with open(p, newline="", encoding="utf-8") as f:
+        return list(csv.DictReader(f))
+
+
+def latest_by_stem(rows, labelers=None):
+    """run_stem ごとにいちばん新しい行 (ファイルの後ろの行). labelers を渡すとその種類の行だけから選ぶ."""
+    out = {}
+    for r in rows:
+        if labelers is not None and r.get("labeler") not in labelers:
+            continue
+        out[r["run_stem"]] = r
+    return out
+
+
+def human_labeler_for(outcome, ai_row):
+    """人が付けたときの labeler. AI の行がなければ human_only, 同じなら human, 違えば human_fixed_ai."""
+    if not ai_row:
+        return "human_only"
+    return "human" if ai_row.get("outcome") == outcome else "human_fixed_ai"
+
+
+# ---- 元の記録を読む (読むだけ) ----
+
+def list_run_stems(runs_dir=RUNS_DIR):
+    return sorted(d.name for d in Path(runs_dir).iterdir()
+                  if d.is_dir() and d.name.startswith("pi05_") and within(runs_dir, d))
+
+
+def safe_file(run_dir, *rel):
+    """記録の中のファイル. リンクをたどって記録の外へ出るもの・ないものは None (codex 2 回目 R4)."""
+    p = Path(run_dir).joinpath(*rel)
+    return p if p.is_file() and within(run_dir, p) else None
+
+
+def read_run_json(run_dir):
+    p = safe_file(run_dir, f"{Path(run_dir).name}.json")
+    if p is None:
+        return None
+    try:
+        return json.loads(p.read_text(encoding="utf-8"))
+    except Exception:
+        return None
+
+
+def frame_files(run_dir):
+    d = Path(run_dir) / "frames"
+    if not d.is_dir():
+        return []
+    return sorted(f.name for f in d.iterdir()
+                  if f.suffix.lower() == ".jpg" and not f.name.startswith("._") and within(run_dir, f))
+
+
+def read_log(run_dir):
+    p = safe_file(run_dir, "log.jsonl")
+    out = []
+    if p is None:
+        return out
+    for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
+        line = line.strip()
+        if not line:
+            continue
+        try:
+            out.append(json.loads(line))
+        except Exception:
+            pass
+    return out
+
+
+def executed_chunks(log):
+    return sum(1 for x in log if "chunk" in x and str(x.get("executed")) == "True")
+
+
+def has_top_mp4(run_dir):
+    return safe_file(run_dir, "recording", "top.mp4") is not None
+
+
+def run_input_row(run_stem, run_json):
+    """{run_stem}.json の outcome を写した行 (labeler=run_input). outcome がない / null なら None."""
+    if not run_json or run_json.get("outcome") in (None, ""):
+        return None
+    oc = run_json["outcome"]
+    if oc not in OUTCOMES:
+        oc = "unknown"
+    jd = run_json.get("judgments") or {}
+
+    def pv(k):
+        v = (jd.get(k) or {}).get("verdict")
+        return v if v in PICK_PLACE else "unknown"
+    return {"labeled_at": run_json.get("ended_at") or now_iso(), "run_stem": run_stem,
+            "pick": pv("pick"), "place": pv("place"), "outcome": oc, "labeler": "run_input",
+            "ai_outcome": "", "ai_reason": "", "evidence": "",
+            "note": "{run_stem}.json の outcome を写した (記録したときの入力)"}
+
+
+if __name__ == "__main__":
+    rows = read_rows()
+    lat = latest_by_stem(rows)
+    print(f"{VERDICTS_CSV}: {len(rows)} 行 / {len(lat)} 回")
+    sys.exit(0)
