将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
313 lines
12 KiB
Python
313 lines
12 KiB
Python
#!/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()
|