#!/usr/bin/env python3 """候选决策通道 —— 会写库(accept / discard)。 与只读看板(:8765)分离:看板合同禁止写;人审决策必须在本通道完成。 与经验确认通道(:8766)并列:本通道只做候选采纳/丢弃。 跑法:.venv/bin/python dashboard/decision_channel.py 默认 http://127.0.0.1:8767 """ from __future__ import annotations import html import json import sys import traceback import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse HOST, PORT = "127.0.0.1", 8767 READONLY_BASE = "http://127.0.0.1:8765" ROOT = Path(__file__).resolve().parents[1] SKILLS = ROOT / ".agent" / "skills" for sub in ( "decide-candidate/scripts", "record-run-evidence/scripts", ): p = str(SKILLS / sub) if p not in sys.path: sys.path.insert(0, p) from muse_db import connect # noqa: E402 from write_canonical import ConflictError, accept, discard # noqa: E402 def esc(value) -> str: return html.escape("" if value is None else str(value)) def _page(title: str, body: str) -> str: return f""" {esc(title)} · 候选决策通道 {body} """ def _list_actionable_candidates(limit: int = 40) -> list[dict]: with connect(readonly=True) as conn: rows = conn.execute( """SELECT id, work_id, target_chapter, candidate_version, state, semantic_status, run_id, create_time, left(candidate_body, 160) FROM example_candidate WHERE deleted=false AND state IN ('passed','rejected','draft','checking') ORDER BY id DESC LIMIT %s""", (limit,), ).fetchall() return [ { "id": r[0], "work_id": r[1], "chapter": r[2], "version": r[3], "state": r[4], "semantic": r[5], "run_id": r[6], "create_time": r[7], "preview": r[8] or "", } for r in rows ] def _load_candidate(candidate_id: int) -> dict | None: with connect(readonly=True) as conn: row = conn.execute( """SELECT id, work_id, target_chapter, run_id, attempt, run_type, candidate_version, candidate_sha256, candidate_body, state, semantic_status, create_time FROM example_candidate WHERE id=%s AND deleted=false""", (candidate_id,), ).fetchone() if not row: return None return { "id": row[0], "work_id": row[1], "chapter": row[2], "run_id": row[3], "attempt": row[4], "run_type": row[5], "version": row[6], "sha256": row[7], "body": row[8] or "", "state": row[9], "semantic": row[10], "create_time": row[11], } def view_home(*, flash: str | None = None, error: str | None = None) -> str: rows = _list_actionable_candidates() flash_html = f"

{esc(flash)}

" if flash else "" err_html = f"

{esc(error)}

" if error else "" if not rows: return _page( "候选决策", flash_html + err_html + "

暂无待决策候选

" "

库内没有 passed/rejected/draft/checking 态候选。

", ) items = [] for r in rows: items.append( f"
  • #{r['id']} " f"作品{r['work_id']} 第{r['chapter']}章 v{esc(r['version'])} " f"[{esc(r['state'])}/{esc(r['semantic'])}] " f"{esc(r['create_time'])}
  • " ) return _page( "候选决策", flash_html + err_html + "

    待决策候选

    " "

    点进详情后在本页提交采纳或丢弃。采纳要求 state=passed 且 semantic=passed。

    " f"", ) def view_candidate( candidate_id: int, *, flash: str | None = None, error: str | None = None ) -> str: cand = _load_candidate(candidate_id) if not cand: return _page("未找到", f"

    候选 #{candidate_id} 不存在

    ") flash_html = f"

    {esc(flash)}

    " if flash else "" err_html = f"

    {esc(error)}

    " if error else "" ro = ( f"

    只读看板打开" + ( f" · 运行" if cand["run_id"] else "" ) + f" · 作品

    " ) meta = ( f"

    作品 {cand['work_id']} · 第 {cand['chapter']} 章 · " f"v{esc(cand['version'])} · attempt={cand['attempt']} · " f"run_type={esc(cand['run_type'])} · state={esc(cand['state'])} · " f"semantic={esc(cand['semantic'])}

    " f"

    sha={esc(cand['sha256'])}

    " ) body_preview = esc(cand["body"][:6000]) if len(cand["body"]) > 6000: body_preview += "\n…(截断,完整正文见只读看板)" can_accept = cand["state"] == "passed" and cand["semantic"] == "passed" can_discard = cand["state"] not in ("accepted", "discarded", "archived") forms = "" if can_accept or can_discard: forms = "

    人审决策

    " if can_accept: forms += f"""
    """ else: forms += ( "

    当前不可采纳:" "需要 state=passed 且 semantic_status=passed。

    " ) if can_discard: forms += f"""
    """ forms += "
    " else: forms = "

    该候选已终态,不可再决策。

    " return _page( f"候选 #{candidate_id}", flash_html + err_html + f"

    候选 #{candidate_id}

    {ro}{meta}" + f"

    正文预览

    {body_preview}
    " + forms + "

    ← 返回列表

    ", ) class Handler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args) -> None: sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) def _send(self, code: int, body: str, *, content_type: str = "text/html; charset=utf-8") -> None: data = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _read_form(self) -> dict[str, str]: length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"" parsed = parse_qs(raw.decode("utf-8"), keep_blank_values=True) return {k: (v[0] if v else "") for k, v in parsed.items()} def do_GET(self) -> None: path = urlparse(self.path).path if path in ("/", "/candidates"): self._send(200, view_home()) return if path.startswith("/candidates/"): parts = path.strip("/").split("/") if len(parts) == 2 and parts[1].isdigit(): self._send(200, view_candidate(int(parts[1]))) return self._send(404, _page("404", "

    未找到

    ")) def do_POST(self) -> None: path = urlparse(self.path).path form = self._read_form() parts = path.strip("/").split("/") try: if len(parts) == 3 and parts[0] == "candidates" and parts[1].isdigit(): cid = int(parts[1]) action = parts[2] decided_by = (form.get("decided_by") or "").strip() or "1" rationale = (form.get("rationale") or "").strip() or None command_id = f"decision-channel-{action}-{cid}-{uuid.uuid4().hex[:12]}" if action == "accept": result = accept( cid, decided_by=decided_by, rationale=rationale, command_id=command_id, ) msg = f"已采纳:{json.dumps(result, ensure_ascii=False, default=str)}" self._send(200, view_candidate(cid, flash=msg)) return if action == "discard": if not rationale: self._send( 400, view_candidate(cid, error="丢弃必须填写理据"), ) return result = discard( cid, decided_by=decided_by, rationale=rationale, command_id=command_id, ) msg = f"已丢弃:{json.dumps(result, ensure_ascii=False, default=str)}" self._send(200, view_candidate(cid, flash=msg)) return self._send(404, _page("404", "

    未知写路径

    ")) except ConflictError as exc: cid = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 page = view_candidate(cid, error=str(exc)) if cid else view_home(error=str(exc)) self._send(409, page) except Exception as exc: tb = traceback.format_exc() self._send( 500, _page("错误", f"

    写库失败

    {esc(tb)}
    "), ) def main() -> None: server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"候选决策通道(可写) http://{HOST}:{PORT}/", flush=True) print(f"只读看板仍为 {READONLY_BASE}/ ;经验确认 :8766", flush=True) try: server.serve_forever() except KeyboardInterrupt: print("\n已停止", flush=True) if __name__ == "__main__": main()