diff --git a/.agent/docs/architecture/可视化模块合同.md b/.agent/docs/architecture/可视化模块合同.md
index 0d5b6f5..f96097f 100644
--- a/.agent/docs/architecture/可视化模块合同.md
+++ b/.agent/docs/architecture/可视化模块合同.md
@@ -89,7 +89,7 @@
1. 落库表分两类:**运行注册 / 运行回执 / 质量评判(98)与模型调用(97)已建**,但写路径未接通,视图暂空;候选 / 用户决策 / raw 全文 / 规划冻结(99/100/101)尚未建。没有它们 §4.2 的视图就是空的。
2. `db/ddl/96` 授权快照表**决定不启用**(领域索引 §9,单用户本地不做多租户授权),DDL 留存不 apply。
-3. 看板首版已建成(总览 / 作品 / 知识库 / 智能体 / 运行记录五空间,只读 HTTP + §4.1 部分视图 + 待落库占位格;智能体空间读 102 登记表);待建 = 运行详情下钻、作品/章工作区标签、沿来源下钻链。页面与信息架构见 §10。
+3. 看板首版已建成(总览 / 作品 / 知识库 / 智能体 / 运行记录五空间,只读 HTTP + §4.1 部分视图 + 待落库占位格;智能体空间读 102 登记表;运行记录含按作品/按角色双维 + 运行详情下钻);待建 = 作品/章工作区标签、沿来源下钻链(raw 下钻依赖 101)。页面与信息架构见 §10。
## 10. 页面与信息架构(产品形态 SoT)
diff --git a/dashboard/server.py b/dashboard/server.py
index 57d0150..ebb7f4c 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -16,6 +16,7 @@ import html
import re
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlparse, parse_qs
import psycopg
@@ -435,71 +436,125 @@ def view_knowledge():
return layout("知识库", body, "/knowledge")
-# ── 运行记录(05 流程 + 06 质量:运行/候选/决策/质量/模型调用;智能体空间待 102 登记表建好后另加)────────
-def view_runs_page():
- # 运行注册(98 已建)
- if table_exists("example_run"):
- _, runs = q("SELECT run_id, work_id, target_chapter, trigger_source, terminal_state, started_at "
- "FROM example_run WHERE deleted=false ORDER BY started_at DESC LIMIT 50")
- else:
- runs = None
- if runs:
- trs = "".join(
- f"
{esc(r[0])} | {esc(r[1])} | {esc(r[2])} | "
- f"{esc(r[3])} | {chip(r[4])} | {esc(r[5])} |
" for r in runs)
- run_html = (f"运行(最近 50)
"
- f"| run_id | 作品 | 章 | 触发 | 终态 | 开始 |
"
- f"{trs}
")
- else:
- run_html = empty_gap("暂无运行记录", "运行注册表已就位,等创作链(写作/检测/审核)落库后,每次运行登记一行。")
+# ── 运行记录(05 流程 + 06 质量:运行列表按作品/按角色双维 + 运行详情下钻)────────
+_RUN_COLS = "| run_id | 作品 | 章 | 触发 | 终态 | 开始 |
"
- # 质量评判(98 已建)
- if table_exists("example_quality_result"):
- _, qres = q("SELECT judge_kind, dimension, scale_version, conclusion, failure_class, candidate_sha256 "
- "FROM example_quality_result ORDER BY create_time DESC LIMIT 50")
- if qres:
- trs = "".join(
- f"| {chip(r[0])} | {esc(r[1])} | {esc(r[2])} | "
- f"{chip(r[3])} | {esc(r[4])} | {esc((r[5] or '')[:12])} |
" for r in qres)
- q_html = (f"质量评判(最近 50 · 有评判就记录就可见)
"
- f"| 类型 | 维度 | 量表版本 | 结论 | 失败类别 | 候选 |
"
- f"{trs}
")
- else:
- q_html = empty_gap("暂无质量评判", "检测/评分/审核/实验落库后逐条记录(绑候选哈希 + 量表版本)。")
- else:
- q_html = empty_gap("质量评判 · 待落库", "质量结果表尚未建。")
- # 候选与决策(99 待建)
- cand_html = (empty_gap("待审候选 · 待落库", "候选表尚未建,AI 候选正文目前无持久化登记。")
- if not table_exists("example_candidate")
- else "候选表已建 · 暂无候选
")
- dec_html = (empty_gap("用户决策 · 待落库", "决策记录表尚未建,接受/合并/丢弃暂以 git 提交留痕。")
- if not table_exists("example_user_decision")
- else "决策表已建 · 暂无决策
")
+def _run_rows(runs):
+ return "".join(
+ f"{esc(r[0])} | {esc(r[1])} | "
+ f"{esc(r[2])} | {esc(r[3])} | {chip(r[4])} | {esc(r[5])} |
"
+ for r in runs)
- # 模型调用明细(97 已建)
- if table_exists("example_llm_call"):
- _, calls = q("SELECT call_time, caller, requested_model_id, actual_model_id, out_tokens, cost_usd, stop_reason "
- "FROM example_llm_call ORDER BY call_time DESC LIMIT 50")
- if calls:
- trs = "".join(
- f"| {esc(c[0])} | {esc(c[1])} | {esc(c[2])} | {esc(c[3])} | "
- f"{fmt_num(c[4])} | ${float(c[5] or 0):.5f} | {esc(c[6])} |
" for c in calls)
- call_html = (f"模型调用明细(最近 50 · 逐次记录)
"
- f"
| 时间 | 调用方 | 请求模型 | 实际模型 | "
- f"输出token | 成本 | 结束 |
"
- f"{trs}
")
- else:
- call_html = empty_gap("暂无逐次调用明细", "调用明细表已就位,模型调用落库后逐次记录(提示词全文受访问控制,单独存 raw)。")
- else:
- call_html = empty_gap("模型调用明细 · 待落库", "调用明细表尚未建。")
- body = (f"运行记录agentic 的操作与产出——运行、候选、决策、质量评判、模型调用 · 流程 05 · 质量 06(智能体空间待 102 登记表建好后另加)
"
- f"运行
{run_html}质量评判
{q_html}"
- f"待审候选
{cand_html}用户决策
{dec_html}模型调用
{call_html}")
+def _all_runs(limit=50):
+ _, runs = q("SELECT run_id, work_id, target_chapter, trigger_source, terminal_state, started_at "
+ "FROM example_run WHERE deleted=false ORDER BY started_at DESC LIMIT %s", (limit,))
+ return runs
+
+
+def _selector(label, name, options, sel):
+ """服务端渲染的下拉筛选(GET 表单,无 JS)。options=[(值,显示名)]。"""
+ opts = "".join(f""
+ for v, t in options)
+ return (f"")
+
+
+def view_runs():
+ runs = _all_runs()
+ entries = ("按维度查看:按作品看运行 · "
+ "按角色看运行
")
+ runs_html = (f"运行(最近 50)
"
+ + _RUN_COLS + _run_rows(runs) + "
") if runs else \
+ empty_gap("暂无运行记录", "创作链落库后,每次运行登记一行(example_run)。")
+ cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建,AI 候选正文暂无持久化登记。")
+ if not table_exists("example_candidate") else "候选表已建 · 暂无候选
")
+ dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建,接受/合并/丢弃暂以 git 提交留痕。")
+ if not table_exists("example_user_decision") else "决策表已建 · 暂无决策
")
+ body = (f"运行记录agentic 的操作与产出 · 流程 05 · 质量 06
"
+ f"{entries}{runs_html}待审候选
{cand}用户决策
{dec}")
return layout("运行记录", body, "/runs")
+def view_runs_by_work(params):
+ sel = (params.get("work") or [""])[0]
+ _, works = q("SELECT id, title FROM muse_content_work WHERE deleted=false ORDER BY id")
+ selector = _selector("选作品:", "work", [(w[0], w[1]) for w in works], sel)
+ if sel:
+ _, runs = q("SELECT run_id, work_id, target_chapter, trigger_source, terminal_state, started_at "
+ "FROM example_run WHERE work_id=%s AND deleted=false ORDER BY started_at DESC LIMIT 50", (sel,))
+ else:
+ runs = _all_runs()
+ tbl = (_RUN_COLS + _run_rows(runs) + "") if runs else "暂无运行
"
+ body = (f""
+ f"按作品看运行
{selector}{tbl}
")
+ return layout("按作品看运行", body, "/runs")
+
+
+def view_runs_by_role(params):
+ sel = (params.get("role") or [""])[0]
+ _, roles = q("SELECT role, display_name FROM example_agent_role WHERE deleted=false ORDER BY role")
+ selector = _selector("选角色:", "role", [(r[0], r[1] or r[0]) for r in roles], sel)
+ if sel:
+ _, runs = q("SELECT DISTINCT n.run_id, n.work_id, n.target_chapter, n.trigger_source, n.terminal_state, n.started_at "
+ "FROM example_run_receipt r JOIN example_run n ON n.run_id=r.run_id "
+ "WHERE r.adapter_role=%s AND n.deleted=false ORDER BY n.started_at DESC LIMIT 50", (sel,))
+ else:
+ runs = _all_runs()
+ tbl = (_RUN_COLS + _run_rows(runs) + "") if runs else "该角色暂无运行
"
+ body = (f""
+ f"按角色看运行
{selector}{tbl}
")
+ return layout("按角色看运行", body, "/runs")
+
+
+def view_run_detail(run_id):
+ _, rr = q("SELECT run_id, work_id, target_chapter, trigger_source, started_at, finished_at, terminal_state "
+ "FROM example_run WHERE run_id=%s AND deleted=false", (run_id,))
+ if not rr:
+ return layout("运行", "运行不存在
", "/runs")
+ rid, wid, chap, trig, started, finished, state = rr[0]
+ info = (f""
+ f"| run_id | {esc(rid)} |
"
+ f"| 作品 | {esc(wid)} |
"
+ f"| 目标章 | {esc(chap)} |
"
+ f"| 触发 | {esc(trig)} |
"
+ f"| 终态 | {chip(state)} |
"
+ f"| 起止 | {esc(started)} → {esc(finished)} |
"
+ f"
")
+ _, rc = q("SELECT sample_id, revision, stage_kind, adapter_role, attempt, loop_seq, candidate_version, is_error "
+ "FROM example_run_receipt WHERE run_id=%s ORDER BY sample_id, revision", (run_id,))
+ rc_html = ("回执链(按样本 · revision 哈希链)
"
+ "| 样本 | rev | 阶段 | 角色 | attempt | 补证 | 候选版本 | 结果 |
"
+ + "".join(f"{esc(r[0])} | {esc(r[1])} | {esc(r[2])} | {esc(r[3])} | "
+ f"{esc(r[4])} | {esc(r[5])} | {esc(r[6])} | {chip('failed' if r[7] else 'passed')} |
" for r in rc)
+ + "
") if rc else empty_gap("暂无回执", "该运行的回执落库后在此显示(revision 哈希链)。")
+ _, qres = q("SELECT judge_kind, dimension, scale_version, score, conclusion, failure_class "
+ "FROM example_quality_result WHERE run_id=%s ORDER BY create_time", (run_id,))
+ q_html = ("质量评判
"
+ "| 类型 | 维度 | 量表版本 | 分 | 结论 | 失败类别 |
"
+ + "".join(f"| {chip(u[0])} | {esc(u[1])} | {esc(u[2])} | {esc(u[3])} | {chip(u[4])} | {esc(u[5])} |
" for u in qres)
+ + "
") if qres else empty_gap("暂无质量评判", "")
+ _, calls = q("SELECT call_time, caller, requested_model_id, actual_model_id, out_tokens, cost_usd "
+ "FROM example_llm_call WHERE run_id=%s ORDER BY call_time", (run_id,))
+ call_html = ("模型调用
"
+ "| 时间 | 调用方 | 请求模型 | 实际模型 | 输出token | 成本 |
"
+ + "".join(f"| {esc(c[0])} | {esc(c[1])} | {esc(c[2])} | {esc(c[3])} | "
+ f"{fmt_num(c[4])} | ${float(c[5] or 0):.5f} |
" for c in calls)
+ + "
") if calls else empty_gap("暂无模型调用", "")
+ cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建。") if not table_exists("example_candidate")
+ else "候选表已建 · 该运行暂无候选
")
+ dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建。") if not table_exists("example_user_decision")
+ else "决策表已建 · 该运行暂无决策
")
+ body = (f""
+ f"运行详情
{info}"
+ f"回执链
{rc_html}质量评判
{q_html}模型调用
{call_html}"
+ f"候选与决策
{cand}{dec}")
+ return layout(f"运行 · {rid}", body, "/runs")
+
+
# ── 智能体(07:读 102 登记表 example_agent_role / example_skill;权威在 Git 侧,登记脚本同步)────
def _fmt_list(v):
"""JSONB 列表 → 逗号串;非列表原样转义。"""
@@ -586,26 +641,30 @@ def view_skill(name):
# ── 路由 ─────────────────────────────────────────────────────────────────────
_ROUTES = [
- (re.compile(r"^/$"), lambda m: view_home()),
- (re.compile(r"^/works$"), lambda m: view_works()),
- (re.compile(r"^/works/(\d+)$"), lambda m: view_work(int(m.group(1)))),
- (re.compile(r"^/works/(\d+)/ch/(\d+)$"), lambda m: view_chapter(int(m.group(1)), int(m.group(2)))),
- (re.compile(r"^/knowledge$"), lambda m: view_knowledge()),
- (re.compile(r"^/agents$"), lambda m: view_agents()),
- (re.compile(r"^/agents/role/([\w-]+)$"), lambda m: view_agent_role(m.group(1))),
- (re.compile(r"^/agents/skill/([\w-]+)$"), lambda m: view_skill(m.group(1))),
- (re.compile(r"^/runs$"), lambda m: view_runs_page()),
+ (re.compile(r"^/$"), lambda m, p: view_home()),
+ (re.compile(r"^/works$"), lambda m, p: view_works()),
+ (re.compile(r"^/works/(\d+)$"), lambda m, p: view_work(int(m.group(1)))),
+ (re.compile(r"^/works/(\d+)/ch/(\d+)$"), lambda m, p: view_chapter(int(m.group(1)), int(m.group(2)))),
+ (re.compile(r"^/knowledge$"), lambda m, p: view_knowledge()),
+ (re.compile(r"^/agents$"), lambda m, p: view_agents()),
+ (re.compile(r"^/agents/role/([\w-]+)$"), lambda m, p: view_agent_role(m.group(1))),
+ (re.compile(r"^/agents/skill/([\w-]+)$"), lambda m, p: view_skill(m.group(1))),
+ (re.compile(r"^/runs$"), lambda m, p: view_runs()),
+ (re.compile(r"^/runs/by-work$"), lambda m, p: view_runs_by_work(p)),
+ (re.compile(r"^/runs/by-role$"), lambda m, p: view_runs_by_role(p)),
+ (re.compile(r"^/runs/([\w.-]+)$"), lambda m, p: view_run_detail(m.group(1))),
]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
- path = self.path.split("?", 1)[0]
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
try:
for pat, fn in _ROUTES:
- m = pat.match(path)
+ m = pat.match(parsed.path)
if m:
- self._send(200, fn(m))
+ self._send(200, fn(m, params))
return
self._send(404, layout("未找到", "404 · 页面不存在
", ""))
except Exception: # 客户端给固定文案,细节写服务端 stderr(不泄露 DB 内部结构;看板挂了不牵连库)