#!/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"""
{esc(title)} · 经验确认通道
{body}
"""
def view_home() -> str:
rows = list_lessons()
if not rows:
return _page("经验确认", "暂无经验 库内还没有 example_lesson 行。
")
items = "".join(
f"#{r['id']} "
f"[{esc(r['kind'])}/{esc(r['status'])}] {esc(r['title'])} "
for r in reversed(rows)
)
return _page(
"经验确认",
f"待人审的经验登记 点进详情后在本页提交评审。
",
)
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"经验 #{lesson_id} 不存在 ")
status = lesson["status"]
detail = json.dumps(lesson["detail"], ensure_ascii=False, indent=2)
flash_html = f"{esc(flash)}
" if flash else ""
err_html = f"{esc(error)}
" if error else ""
ro = (
f"在只读看板打开 "
+ (f" · 运行 " if lesson["run_id"] else "")
+ (f" · 作品 " if lesson["work_id"] else "")
+ "
"
)
forms = ""
if status == "proposed":
forms = f"""
进入评审
直接驳回
你的署名
理据
驳回 → rejected
"""
elif status == "reviewing":
forms = f"""
升格
你的署名
目标类型
skill 技能
pattern 范式
tool 工具
落点(target_ref)
理据
批准升格 → promoted
驳回
你的署名
理据
驳回 → rejected
"""
else:
forms = f""
body = f"""
← 列表
#{lesson_id} {esc(lesson['title'])}
{flash_html}{err_html}{ro}
类型 {esc(lesson['kind'])} · 状态 {esc(status)} · 登记方 {esc(lesson['creator'])}
run_id {esc(lesson['run_id']) or '—'}
决定人 {esc(lesson['decided_by']) or '—'} · 理据 {esc(lesson['decision_rationale']) or '—'}
详情 {esc(detail)}
{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", "未找到 "))
except Exception:
traceback.print_exc()
self._send(500, _page("出错", "确认通道出错 "))
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", "未找到 "))
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", "未知动作 "))
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"{esc(exc)}
"))
except Exception:
traceback.print_exc()
self._send(500, _page("出错", "写库失败,见服务端日志 "))
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()