muse-agent-example/dashboard/decision_channel.py
zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

313 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{esc(title)} · 候选决策通道</title>
<style>
body{{font-family:ui-sans-serif,system-ui,sans-serif;max-width:900px;margin:24px auto;padding:0 16px;line-height:1.5}}
.banner{{background:#fde8e8;border:1px solid #c44;padding:10px 14px;border-radius:8px;margin-bottom:16px}}
.card{{border:1px solid #ddd;border-radius:8px;padding:14px 16px;margin:14px 0}}
label{{display:block;margin:8px 0 4px}}
input,textarea{{width:100%;max-width:520px;padding:6px 8px}}
button{{margin-top:10px;margin-right:8px;padding:8px 14px}}
button.accept{{background:#0a6b0a;color:#fff;border:0}}
button.discard{{background:#8b0000;color:#fff;border:0}}
pre{{white-space:pre-wrap;background:#f6f6f6;padding:10px;border-radius:6px;font-size:13px;max-height:420px;overflow:auto}}
.err{{color:#a40000}}
.ok{{color:#0a6b0a}}
a{{color:#0645ad}}
.meta{{color:#555;font-size:14px}}
</style></head><body>
<div class="banner"><b>决策通道会写库</b> · 只读浏览请用
<a href="{READONLY_BASE}/">看板 :8765</a> · 本页仅做候选采纳/丢弃
(经验升格仍走 <a href="http://127.0.0.1:8766/">:8766</a>)</div>
{body}
</body></html>"""
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"<p class='ok'>{esc(flash)}</p>" if flash else ""
err_html = f"<p class='err'>{esc(error)}</p>" if error else ""
if not rows:
return _page(
"候选决策",
flash_html + err_html + "<h1>暂无待决策候选</h1>"
"<p>库内没有 passed/rejected/draft/checking 态候选。</p>",
)
items = []
for r in rows:
items.append(
f"<li><a href='/candidates/{r['id']}'>#{r['id']}</a> "
f"作品{r['work_id']} 第{r['chapter']}章 v{esc(r['version'])} "
f"[{esc(r['state'])}/{esc(r['semantic'])}] "
f"<span class='meta'>{esc(r['create_time'])}</span></li>"
)
return _page(
"候选决策",
flash_html
+ err_html
+ "<h1>待决策候选</h1>"
"<p>点进详情后在本页提交采纳或丢弃。采纳要求 state=passed 且 semantic=passed。</p>"
f"<ul>{''.join(items)}</ul>",
)
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"<h1>候选 #{candidate_id} 不存在</h1>")
flash_html = f"<p class='ok'>{esc(flash)}</p>" if flash else ""
err_html = f"<p class='err'>{esc(error)}</p>" if error else ""
ro = (
f"<p><a href='{READONLY_BASE}/candidates/{candidate_id}'>只读看板打开</a>"
+ (
f" · <a href='{READONLY_BASE}/runs/{esc(cand['run_id'])}'>运行</a>"
if cand["run_id"]
else ""
)
+ f" · <a href='{READONLY_BASE}/works/{cand['work_id']}'>作品</a></p>"
)
meta = (
f"<p class='meta'>作品 {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'])}</p>"
f"<p class='meta'>sha={esc(cand['sha256'])}</p>"
)
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 = "<div class='card'><h2>人审决策</h2>"
if can_accept:
forms += f"""
<form method="post" action="/candidates/{candidate_id}/accept"
onsubmit="return confirm('确认采纳为正式正文?此操作写库不可静默撤销。');">
<label>署名(decided_by)</label>
<input name="decided_by" required placeholder="例如:qingse">
<label>理据(可选)</label>
<textarea name="rationale" rows="3"></textarea>
<button class="accept" type="submit">采纳 → 正式正文</button>
</form>"""
else:
forms += (
"<p class='err'>当前不可采纳:"
"需要 state=passed 且 semantic_status=passed。</p>"
)
if can_discard:
forms += f"""
<form method="post" action="/candidates/{candidate_id}/discard"
onsubmit="return confirm('确认丢弃该候选?正式正文不变。');">
<label>署名</label><input name="decided_by" required>
<label>理据</label><textarea name="rationale" rows="3" required></textarea>
<button class="discard" type="submit">丢弃</button>
</form>"""
forms += "</div>"
else:
forms = "<div class='card'><p>该候选已终态,不可再决策。</p></div>"
return _page(
f"候选 #{candidate_id}",
flash_html
+ err_html
+ f"<h1>候选 #{candidate_id}</h1>{ro}{meta}"
+ f"<div class='card'><h2>正文预览</h2><pre>{body_preview}</pre></div>"
+ forms
+ "<p><a href='/'>← 返回列表</a></p>",
)
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", "<h1>未找到</h1>"))
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", "<h1>未知写路径</h1>"))
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"<h1>写库失败</h1><pre class='err'>{esc(tb)}</pre>"),
)
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()