#!/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"""

    进入评审

    直接驳回

    """ elif status == "reviewing": forms = f"""

    升格

    驳回

    """ else: forms = f"

    终态 {esc(status)},不可再流转。

    " 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()