muse-agent-example/dashboard/lesson_confirm.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

256 lines
10 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
"""经验登记确认通道 —— 会写库(proposed→reviewing→promoted/rejected)。
与只读看板(:8765)分离:看板合同禁止写;人审必须在本通道完成。
跑法:.venv/bin/python dashboard/lesson_confirm.py
默认 http://127.0.0.1:8766
"""
from __future__ import annotations
import html
import json
import sys
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
HOST, PORT = "127.0.0.1", 8766
READONLY_BASE = "http://127.0.0.1:8765"
ROOT = Path(__file__).resolve().parents[1]
EVIDENCE = ROOT / ".agent" / "skills" / "record-run-evidence" / "scripts"
if str(EVIDENCE) not in sys.path:
sys.path.insert(0, str(EVIDENCE))
from lesson_registry import ( # noqa: E402
LessonError,
list_lessons,
promote,
reject,
start_review,
)
from muse_db import connect # noqa: E402
def esc(value) -> str:
return html.escape("" if value is None else str(value))
def _load_lesson(lesson_id: int) -> dict | None:
with connect(readonly=True) as conn:
row = conn.execute(
"SELECT id, work_id, run_id, candidate_sha256, kind, title, detail, status, "
"promotion_target_type, promotion_target_ref, decided_by, decision_rationale, "
"creator, create_time "
"FROM example_lesson WHERE id=%s AND deleted=false",
(lesson_id,),
).fetchone()
if not row:
return None
detail = row[6]
if isinstance(detail, str):
try:
detail = json.loads(detail)
except json.JSONDecodeError:
detail = {"raw": detail}
return {
"id": row[0],
"work_id": row[1],
"run_id": row[2],
"candidate_sha256": row[3],
"kind": row[4],
"title": row[5],
"detail": detail,
"status": row[7],
"promotion_target_type": row[8],
"promotion_target_ref": row[9],
"decided_by": row[10],
"decision_rationale": row[11],
"creator": row[12],
"create_time": row[13],
}
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:820px;margin:24px auto;padding:0 16px;line-height:1.5}}
.banner{{background:#fff3cd;border:1px solid #e6c200;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,select,textarea{{width:100%;max-width:480px;padding:6px 8px}}
button{{margin-top:10px;margin-right:8px;padding:8px 14px}}
pre{{white-space:pre-wrap;background:#f6f6f6;padding:10px;border-radius:6px;font-size:13px}}
.err{{color:#a40000}}
.ok{{color:#0a6b0a}}
a{{color:#0645ad}}
</style></head><body>
<div class="banner"><b>确认通道会写库</b> · 只读浏览请用
<a href="{READONLY_BASE}/lessons">看板 /lessons</a> · 本页仅做经验状态流转</div>
{body}
</body></html>"""
def view_home() -> str:
rows = list_lessons()
if not rows:
return _page("经验确认", "<h1>暂无经验</h1><p>库内还没有 example_lesson 行。</p>")
items = "".join(
f"<li><a href='/lessons/{r['id']}'>#{r['id']}</a> "
f"[{esc(r['kind'])}/{esc(r['status'])}] {esc(r['title'])}</li>"
for r in reversed(rows)
)
return _page(
"经验确认",
f"<h1>待人审的经验登记</h1><p>点进详情后在本页提交评审。</p><ul>{items}</ul>",
)
def view_lesson(lesson_id: int, *, flash: str | None = None, error: str | None = None) -> str:
lesson = _load_lesson(lesson_id)
if not lesson:
return _page("未找到", f"<h1>经验 #{lesson_id} 不存在</h1>")
status = lesson["status"]
detail = json.dumps(lesson["detail"], ensure_ascii=False, indent=2)
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}/lessons/{lesson_id}'>在只读看板打开</a>"
+ (f" · <a href='{READONLY_BASE}/runs/{esc(lesson['run_id'])}'>运行</a>" if lesson["run_id"] else "")
+ (f" · <a href='{READONLY_BASE}/works/{lesson['work_id']}'>作品</a>" if lesson["work_id"] else "")
+ "</p>"
)
forms = ""
if status == "proposed":
forms = f"""
<div class="card"><h2>进入评审</h2>
<form method="post" action="/lessons/{lesson_id}/review">
<label>你的署名(decided_by)</label>
<input name="decided_by" required placeholder="例如:qingse">
<label>理据(可选)</label>
<textarea name="rationale" rows="3"></textarea>
<button type="submit">开始评审 → reviewing</button>
</form></div>
<div class="card"><h2>直接驳回</h2>
<form method="post" action="/lessons/{lesson_id}/reject">
<label>你的署名</label><input name="decided_by" required>
<label>理据</label><textarea name="rationale" rows="3" required></textarea>
<button type="submit">驳回 → rejected</button>
</form></div>"""
elif status == "reviewing":
forms = f"""
<div class="card"><h2>升格</h2>
<form method="post" action="/lessons/{lesson_id}/promote">
<label>你的署名</label><input name="decided_by" required>
<label>目标类型</label>
<select name="target_type"><option value="skill">skill 技能</option>
<option value="pattern">pattern 范式</option>
<option value="tool">tool 工具</option></select>
<label>落点(target_ref)</label>
<input name="target_ref" required placeholder="例如:decide-candidate">
<label>理据</label><textarea name="rationale" rows="3" required></textarea>
<button type="submit">批准升格 → promoted</button>
</form></div>
<div class="card"><h2>驳回</h2>
<form method="post" action="/lessons/{lesson_id}/reject">
<label>你的署名</label><input name="decided_by" required>
<label>理据</label><textarea name="rationale" rows="3" required></textarea>
<button type="submit">驳回 → rejected</button>
</form></div>"""
else:
forms = f"<div class='card'><p>终态 <code>{esc(status)}</code>,不可再流转。</p></div>"
body = f"""
<p><a href="/">← 列表</a></p>
<h1>#{lesson_id} {esc(lesson['title'])}</h1>
{flash_html}{err_html}{ro}
<div class="card">
<p>类型 <code>{esc(lesson['kind'])}</code> · 状态 <code>{esc(status)}</code> · 登记方 {esc(lesson['creator'])}</p>
<p>run_id <code>{esc(lesson['run_id']) or '—'}</code></p>
<p>决定人 {esc(lesson['decided_by']) or '—'} · 理据 {esc(lesson['decision_rationale']) or '—'}</p>
<h3>详情</h3><pre>{esc(detail)}</pre>
</div>
{forms}
"""
return _page(f"经验 #{lesson_id}", body)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
try:
if parsed.path in ("/", "/lessons"):
self._send(200, view_home())
return
m = None
if parsed.path.startswith("/lessons/"):
parts = parsed.path.strip("/").split("/")
if len(parts) == 2 and parts[1].isdigit():
self._send(200, view_lesson(int(parts[1])))
return
self._send(404, _page("404", "<h1>未找到</h1>"))
except Exception:
traceback.print_exc()
self._send(500, _page("出错", "<h1>确认通道出错</h1>"))
def do_POST(self):
parsed = urlparse(self.path)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length).decode("utf-8") if length else ""
form = {k: (v[0] if v else "") for k, v in parse_qs(raw, keep_blank_values=True).items()}
parts = parsed.path.strip("/").split("/")
try:
if len(parts) != 3 or parts[0] != "lessons" or not parts[1].isdigit():
self._send(404, _page("404", "<h1>未找到</h1>"))
return
lesson_id = int(parts[1])
action = parts[2]
by = (form.get("decided_by") or "").strip()
rationale = (form.get("rationale") or "").strip() or None
if action == "review":
start_review(lesson_id, decided_by=by, rationale=rationale)
msg = "已进入 reviewing"
elif action == "promote":
promote(
lesson_id,
decided_by=by,
target_type=(form.get("target_type") or "").strip(),
target_ref=(form.get("target_ref") or "").strip(),
rationale=rationale,
)
msg = "已升格 promoted"
elif action == "reject":
reject(lesson_id, decided_by=by, rationale=rationale)
msg = "已驳回 rejected"
else:
self._send(404, _page("404", "<h1>未知动作</h1>"))
return
self._send(200, view_lesson(lesson_id, flash=msg))
except LessonError as exc:
lid = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
self._send(400, view_lesson(lid, error=str(exc)) if lid else _page("失败", f"<p class='err'>{esc(exc)}</p>"))
except Exception:
traceback.print_exc()
self._send(500, _page("出错", "<h1>写库失败,见服务端日志</h1>"))
def _send(self, code: int, body: str) -> None:
data = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
print(f"经验确认通道(会写库):http://{HOST}:{PORT}/ (Ctrl-C 退出)")
print(f"只读看板仍在:{READONLY_BASE}/lessons")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()