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