#!/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""
+ 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()