706 lines
44 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
"""只读可视化看板(muse-example 库)—— 纯 Python 标准库 HTTP 服务。
合同:.agent/docs/architecture/可视化模块合同.md(唯一 SoT)。
布局:对齐 muse 的领域布局(作品 / 知识库 / 智能体 三空间 + 总览),
与领域索引(domains/_index.md)的领域分区一致——看板按领域看库,不按功能切。
命根子(验收按机械门查):
1. 连接只读:会话级 default_transaction_read_only=on,任何写语句数据库直接报错。
2. 代码无写语句:全文件只允许 SELECT,无 INSERT/UPDATE/DELETE/DDL(grep 即门)。
3. 不接会写的通道:不调用 confirm / db skill,只自己只读查库。
4. 挂了不牵连:进程崩了不影响库内正式内容与创作链。
视觉系统:dataviz 方法(已验证默认调色板,light/dark 双模式,状态色固定不主题化)。
跑法:.venv/bin/python dashboard/server.py (默认 http://127.0.0.1:8765)
"""
import html
import re
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import psycopg
# 与 db skill 同一库,但独立只读连接(合同 §2:连接串与写通道分开,只读是机械门)
DSN = "postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
HOST, PORT = "127.0.0.1", 8765
# 额度治理上限(llm skill 合同:5 小时窗 $24 / 6000 次)
QUOTA_USD_CAP, QUOTA_CALL_CAP = 24.0, 6000
def ro_connect():
"""只读连接:会话级锁死只读,写语句会被 PostgreSQL 拒绝(机械门,不靠自觉)。"""
return psycopg.connect(DSN, options="-c default_transaction_read_only=on")
def q(sql, params=()):
"""只读查询,返回 (列名, 行)。每次请求实时查库,不缓存(合同 §7)。"""
with ro_connect() as conn:
cur = conn.execute(sql, params)
cols = [d.name for d in cur.description] if cur.description else []
return cols, cur.fetchall()
def one(sql, params=()):
"""取单值。"""
_, rows = q(sql, params)
return rows[0][0] if rows else None
def table_exists(name):
return one("SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename=%s", (name,))
# ── 视觉系统:dataviz 已验证调色板(light/dark),状态色固定不主题化 ──────────────
CSS = """
:root {
color-scheme: light;
--plane:#f9f9f7; --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
--grid:#e1e0d9; --baseline:#c3c2b7; --ring:rgba(11,11,11,.10);
--s1:#2a78d6; --s2:#008300; --s3:#e87ba4; --s4:#eda100; --s5:#1baf7a;
--s6:#eb6834; --s7:#4a3aa7; --s8:#e34948;
--good:#0ca30c; --warn:#fab219; --serious:#ec835a; --crit:#d03b3b; --goodtxt:#006300;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--plane:#0d0d0d; --surface:#1a1a19; --ink:#ffffff; --ink2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --baseline:#383835; --ring:rgba(255,255,255,.10);
--s1:#3987e5; --s2:#008300; --s3:#d55181; --s4:#c98500; --s5:#199e70;
--s6:#d95926; --s7:#9085e9; --s8:#e66767; --goodtxt:#0ca30c;
}
}
* { box-sizing:border-box; margin:0; padding:0; }
body {
background:var(--plane); color:var(--ink);
font:15px/1.6 system-ui,-apple-system,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
display:flex; min-height:100vh;
}
nav {
width:212px; flex:none; background:var(--surface); border-right:1px solid var(--grid);
padding:22px 14px; position:sticky; top:0; height:100vh;
}
nav .brand { font-weight:700; font-size:16px; letter-spacing:.02em; padding:0 8px 16px; }
nav .brand small { display:block; font-weight:400; color:var(--muted); font-size:12px; }
nav a {
display:block; padding:8px 10px; border-radius:6px; color:var(--ink2);
text-decoration:none; font-size:14px; transition:background .12s,color .12s;
}
nav a:hover { background:var(--grid); color:var(--ink); }
nav a.on { background:var(--grid); color:var(--ink); font-weight:600; }
nav .dom { font-size:10.5px; color:var(--muted); padding:2px 10px 0; letter-spacing:.04em; }
nav .ro { margin-top:24px; padding:10px; font-size:11px; color:var(--muted);
border:1px dashed var(--baseline); border-radius:6px; }
main { flex:1; padding:30px 38px 60px; max-width:1080px; }
h1 { font-size:26px; font-weight:750; letter-spacing:-.01em; }
h1 .sub { display:block; font-size:13px; font-weight:400; color:var(--muted); margin-top:4px; }
h2 { font-size:16px; font-weight:650; margin:30px 0 12px; }
p.note { color:var(--ink2); font-size:13.5px; margin:8px 0; }
.tiles { display:grid; grid-template-columns:repeat(auto-fill,minmax(168px,1fr)); gap:12px; margin-top:18px; }
.tile { background:var(--surface); border:1px solid var(--ring); border-radius:8px; padding:14px 16px;
transition:transform .12s, box-shadow .12s; }
.tile:hover { transform:translateY(-1px); box-shadow:0 3px 10px var(--ring); }
.tile .v { font-size:28px; font-weight:720; letter-spacing:-.02em; line-height:1.15; } /* 大数字用比例数字 */
.tile .l { font-size:12.5px; color:var(--muted); margin-top:2px; }
.tile .d { font-size:12px; margin-top:5px; color:var(--ink2); }
.tile.zero .v { color:var(--muted); }
.card { background:var(--surface); border:1px solid var(--ring); border-radius:8px; margin-top:14px; overflow:hidden; }
.card > .hd { padding:12px 16px; border-bottom:1px solid var(--grid); font-weight:650; font-size:14px; }
table { width:100%; border-collapse:collapse; font-size:13.5px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:12px; padding:9px 14px;
border-bottom:1px solid var(--grid); white-space:nowrap; }
td { padding:9px 14px; border-bottom:1px solid var(--grid); color:var(--ink2); vertical-align:top; }
tr:last-child td { border-bottom:none; }
tbody tr { transition:background .1s; }
tbody tr:hover { background:var(--plane); }
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; } /* 对齐的数字列用等宽数字 */
td a { color:var(--s1); text-decoration:none; }
td a:hover { text-decoration:underline; }
.chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600;
padding:2px 9px; border-radius:999px; white-space:nowrap; border:1px solid transparent; }
.chip .dot { width:7px; height:7px; border-radius:50%; flex:none; }
.chip.good { color:var(--goodtxt); background:color-mix(in srgb,var(--good) 12%, transparent); }
.chip.good .dot { background:var(--good); }
.chip.warn { color:var(--ink2); background:color-mix(in srgb,var(--warn) 16%, transparent); }
.chip.warn .dot { background:var(--warn); }
.chip.serious { color:var(--ink2); background:color-mix(in srgb,var(--serious) 16%, transparent); }
.chip.serious .dot { background:var(--serious); }
.chip.crit { color:var(--crit); background:color-mix(in srgb,var(--crit) 12%, transparent); }
.chip.crit .dot { background:var(--crit); }
.chip.gap { color:var(--muted); border:1px dashed var(--baseline); background:transparent; }
.chip.gap .dot { background:var(--baseline); }
.meter { margin:10px 0; }
.meter .track { height:10px; border-radius:5px; background:var(--grid); overflow:hidden; }
.meter .fill { height:100%; border-radius:5px; background:var(--s1); transition:width .3s; }
.meter .cap { font-size:12px; color:var(--muted); margin-top:4px; font-variant-numeric:tabular-nums; }
.bar-row { display:flex; align-items:center; gap:12px; padding:7px 16px; font-size:13.5px; }
.bar-row .name { width:150px; flex:none; color:var(--ink2); }
.bar-row .track { flex:1; height:16px; background:var(--grid); border-radius:4px; overflow:hidden; }
.bar-row .fill { height:100%; border-radius:4px; }
.bar-row .val { width:110px; flex:none; text-align:right; font-variant-numeric:tabular-nums; color:var(--ink2); }
.empty { padding:34px 16px; text-align:center; color:var(--muted); font-size:13.5px; }
.empty.gap { border:1px dashed var(--baseline); border-radius:8px; margin-top:14px; background:transparent; }
.empty b { color:var(--ink2); }
article.prose { background:var(--surface); border:1px solid var(--ring); border-radius:8px;
padding:30px 40px; margin-top:16px; max-width:760px;
font-size:16.5px; line-height:2; white-space:pre-wrap; color:var(--ink); }
.crumb { font-size:13px; color:var(--muted); margin-bottom:6px; }
.crumb a { color:var(--s1); text-decoration:none; }
footer { margin-top:44px; font-size:11.5px; color:var(--muted); border-top:1px solid var(--grid); padding-top:12px; }
"""
# 状态 → (角色, 图标)。状态色保留语义,图标+文字同在,绝不单靠颜色(dataviz 规矩)。
_STATUS = {
"good": ("good", "✓"), "warn": ("warn", "●"), "serious": ("serious", "▲"),
"crit": ("crit", "✕"), "gap": ("gap", "◌"),
}
_STATE_MAP = {
"pending": "warn", "draft": "warn", "checking": "warn", "parsing": "warn",
"running": "warn", "writing": "warn", "shadow": "warn",
"confirmed": "good", "passed": "good", "done": "good", "parsed": "good",
"completed": "good", "accepted": "good", "active": "good", "canonical": "good",
"published": "good",
"rejected": "serious", "failed": "serious", "revise": "serious", "stale": "serious",
"discarded": "crit", "error": "crit", "unauthorized": "crit", "ignored": "crit",
}
def chip(state, label=None):
"""状态标签:颜色按语义角色,图标+文字同在。未识别状态归 warn(待审)。"""
role = _STATE_MAP.get(str(state).lower(), "warn") if state else "gap"
if state in (None, "", "not_built"):
role = "gap"
r, icon = _STATUS[role]
text = label if label is not None else (state or "未建")
return (f'<span class="chip {r}"><span class="dot"></span>{icon} {html.escape(str(text))}</span>')
def esc(v):
if v is None:
return '<span style="color:var(--muted)">∅</span>'
return html.escape(str(v))
def fmt_num(v):
try:
return f"{int(v):,}"
except (TypeError, ValueError):
return esc(v)
def empty_gap(title, desc):
"""待落库空状态:中性虚线,与"失败"红严格区分。"""
return f"<div class='empty gap'><b>{esc(title)}</b><br>{esc(desc)}</div>"
# ── 布局:对齐 muse 领域布局(总览 / 作品 / 知识库 / 智能体)────────────────────
# 每个导航项标注对应领域(domains/_index.md 的分区),看板按领域看库。
NAV = [
("/", "总览 · 健康", "数据权威 08"),
("/works", "作品", "作品领域 01"),
("/knowledge", "知识库", "实体 02 · 范式 03"),
("/agents", "智能体", "Agent 07"),
("/runs", "运行记录", "流程 05 · 质量 06"),
]
def layout(title, body, active="/"):
links = []
for href, label, dom in NAV:
cls = "on" if href == active else ""
links.append(f'<a class="{cls}" href="{href}">{html.escape(label)}</a>'
f'<div class="dom">{html.escape(dom)}</div>')
return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)} · muse 看板</title><style>{CSS}</style></head><body>
<nav><div class="brand">muse 看板<small>muse-example · 只读 · 按领域看库</small></div>{"".join(links)}
<div class="ro">只读连接<br>本看板不写任何数据<br>接受 / 丢弃仍走 confirm</div></nav>
<main>{body}
<footer>数据实时查库,无缓存 · 数字为当前库内事实 · 空白即落库缺口</footer>
</main></body></html>"""
# ── 总览(08 数据权威:落库验收面 + 健康数字 + 额度水位)──────────────────────
# 落库缺口板:一切输入产出该落哪张表,建没建、有没有数(合同 §4.2 的验收面)
_LEDGER = [
("待审候选", "example_candidate"),
("用户决策", "example_user_decision"),
("运行回执", "example_run_receipt"),
("质量评判", "example_quality_result"),
("模型调用明细", "example_llm_call"),
("运行注册", "example_run"),
("raw 全文", "example_raw_content"),
("规划 / 细纲", "example_planning_section"),
("上下文冻结", "example_context_freeze"),
]
def _quota_block():
_, rows = q("SELECT window_key, minimax_usd, total_calls FROM example_llm_quota "
"ORDER BY window_key DESC LIMIT 1")
if not rows:
return ("<h2>额度水位</h2>" + empty_gap("暂无额度记录", "发生模型调用后自动记录"))
wk, usd, calls = rows[0]
usd, calls = float(usd or 0), int(calls or 0)
def meter(val, cap, text):
pct = min(100.0, val / cap * 100) if cap else 0
return (f"<div class='meter'><div class='track'><div class='fill' style='width:{pct:.1f}%'></div></div>"
f"<div class='cap'>{text}(上限 {cap:,.0f})</div></div>")
return (f"<h2>额度水位 <span style='font-weight:400;color:var(--muted);font-size:12px'>窗口 {esc(wk)}</span></h2>"
f"<div class='card'><div style='padding:14px 16px'>"
f"{meter(usd, QUOTA_USD_CAP, f'MiniMax 花费 ${usd:.4f} / ${QUOTA_USD_CAP:.0f}')}"
f"{meter(calls, QUOTA_CALL_CAP, f'成功调用 {calls:,} / {QUOTA_CALL_CAP:,} 次')}</div></div>")
def view_home():
works = one("SELECT count(*) FROM muse_content_work WHERE deleted=false") or 0
chapters = one("SELECT count(*) FROM muse_content_chapter WHERE deleted=false") or 0
blocks = one("SELECT count(*) FROM muse_content_block WHERE deleted=false") or 0
drafts = one("SELECT count(*) FROM muse_knowledge_draft WHERE deleted=false") or 0
entities = one("SELECT count(*) FROM muse_knowledge_entity WHERE deleted=false") or 0
refs = one("SELECT count(*) FROM example_reference_work WHERE deleted=false") or 0
cleans = one("SELECT count(*) FROM example_clean_log WHERE deleted=false") or 0
tiles = f"""
<div class="tiles">
<div class="tile"><div class="v">{fmt_num(works)}</div><div class="l">作品</div></div>
<div class="tile"><div class="v">{fmt_num(chapters)}</div><div class="l">章节</div></div>
<div class="tile"><div class="v">{fmt_num(blocks)}</div><div class="l">正文块</div></div>
<div class="tile"><div class="v">{fmt_num(drafts)}</div><div class="l">知识卡(草稿)</div>
<div class="d">已确认 {fmt_num(entities)} 张</div></div>
<div class="tile {'zero' if refs == 0 else ''}"><div class="v">{fmt_num(refs)}</div><div class="l">参考书</div></div>
<div class="tile"><div class="v">{fmt_num(cleans)}</div><div class="l">清洗记录</div></div>
</div>"""
rows = []
for label, tbl in _LEDGER:
if not table_exists(tbl):
st, cnt = chip("not_built", "未建 · 待落库"), "—"
else:
n = one(f'SELECT count(*) FROM "{tbl}"') or 0
st = chip("good", "已建 · 有数据") if n > 0 else chip("warn", "已建 · 0 行")
cnt = fmt_num(n)
rows.append(f"<tr><td>{esc(label)}</td><td><code>{esc(tbl)}</code></td>"
f"<td class='num'>{cnt}</td><td>{st}</td></tr>")
ledger = f"""
<div class="card"><div class="hd">落库账本 —— 一切输入产出,落没落库一目了然</div>
<table><thead><tr><th>输入产出</th><th>承接表</th><th class="num">行数</th><th>状态</th></tr></thead>
<tbody>{"".join(rows)}</tbody></table></div>
<p class="note">「未建」= 表还没建(合同要求落库,属缺口);「已建 · 0 行」= 表已就位,等创作链写入。
知识卡已确认 {fmt_num(entities)} 张(草稿≠已确认,全部确认前此数为 0 是正常状态)。</p>"""
body = (f"<h1>总览 · 健康<span class='sub'>库里现在有什么、缺什么——这就是「一切落库』的验收面</span></h1>"
f"{tiles}{ledger}{_quota_block()}")
return layout("总览", body, "/")
# ── 作品(01 作品领域:作品 → 章节 → 正文全文)────────────────────────────────
def view_works():
_, rows = q("SELECT id,title,status,genre,chapter_count,word_count FROM muse_content_work "
"WHERE deleted=false ORDER BY id")
if not rows:
return layout("作品", "<h1>作品</h1>" + empty_gap("库里还没有作品", "导入或创建后在此显示"), "/works")
trs = "".join(
f"<tr><td><a href='/works/{r[0]}'>{esc(r[1])}</a></td><td>{chip(r[2])}</td>"
f"<td>{esc(r[3])}</td><td class='num'>{esc(r[4])}</td><td class='num'>{fmt_num(r[5])}</td></tr>"
for r in rows)
body = (f"<h1>作品<span class='sub'>正式内容以库为准(muse_content_work)· 作品领域 01</span></h1>"
f"<div class='card'><table><thead><tr><th>作品</th><th>状态</th><th>类型</th>"
f"<th class='num'>章数</th><th class='num'>字数</th></tr></thead><tbody>{trs}</tbody></table></div>")
return layout("作品", body, "/works")
def view_work(wid):
_, wrows = q("SELECT id,title,status,genre,summary,word_count,chapter_count "
"FROM muse_content_work WHERE id=%s AND deleted=false", (wid,))
if not wrows:
return layout("作品", "<h1>作品不存在</h1>", "/works")
wid_, title, status, genre, summary, wc, cc = wrows[0]
_, chs = q("SELECT id, order_no, title, status, revision FROM muse_content_chapter "
"WHERE work_id=%s AND deleted=false ORDER BY order_no", (wid,))
if chs:
trs = "".join(
f"<tr><td class='num'>{esc(c[1])}</td>"
f"<td><a href='/works/{wid}/ch/{c[1]}'>{esc(c[2]) or f'第{c[1]}章'}</a></td>"
f"<td>{chip(c[3])}</td><td class='num'>{esc(c[4])}</td></tr>" for c in chs)
chap = (f"<div class='card'><div class='hd'>章节({len(chs)})</div>"
f"<table><thead><tr><th class='num'>序</th><th>标题</th><th>状态</th><th class='num'>修订</th></tr></thead>"
f"<tbody>{trs}</tbody></table></div>")
else:
chap = empty_gap("还没有章节", "")
body = (f"<div class='crumb'><a href='/works'>作品</a> / {esc(title)}</div>"
f"<h1>{esc(title)} <span style='font-size:14px'>{chip(status)}</span></h1>"
f"<p class='note'>{esc(genre)} · {fmt_num(wc)} 字 · {esc(cc)} 章</p>"
f"<p class='note'>{esc(summary)}</p>{chap}")
return layout(title, body, "/works")
def view_chapter(wid, order_no):
_, wrow = q("SELECT title FROM muse_content_work WHERE id=%s AND deleted=false", (wid,))
if not wrow:
return layout("章节", "<h1>作品不存在</h1>", "/works")
wtitle = wrow[0][0]
_, ch = q("SELECT id,title,order_no FROM muse_content_chapter "
"WHERE work_id=%s AND order_no=%s AND deleted=false", (wid, order_no))
if not ch:
return layout("章节", "<h1>章节不存在</h1>", "/works")
cid, ctitle, _ = ch[0]
_, blocks = q("SELECT content_text, word_count FROM muse_content_block "
"WHERE chapter_id=%s AND deleted=false ORDER BY order_no", (cid,))
text = "\n\n".join((b[0] or "") for b in blocks)
body = (f"<div class='crumb'><a href='/works'>作品</a> / <a href='/works/{wid}'>{esc(wtitle)}</a> "
f"/ 第{esc(order_no)}章</div>"
f"<h1>{esc(ctitle) or f'第{order_no}章'}<span class='sub'>{fmt_num(sum(b[1] or 0 for b in blocks))} 字 · 正文全文(实时查库)</span></h1>"
f"<article class='prose'>{esc(text) if text.strip() else '(本章正文为空)'}</article>")
return layout(f"{wtitle} · 第{order_no}章", body, "/works")
# ── 知识库(02 实体 + 03 范式:知识卡 + 参考书·拆书·清洗)─────────────────────
def _barset(rows, slots):
if not rows:
return "<div class='empty'>无数据</div>"
mx = max(r[1] for r in rows)
total = sum(r[1] for r in rows)
out = []
for i, (name, n) in enumerate(rows):
color = f"var(--s{slots[i % len(slots)]})"
out.append(f"<div class='bar-row'><span class='name'>{esc(name)}</span>"
f"<span class='track'><span class='fill' style='width:{n/mx*100:.1f}%;background:{color}'></span></span>"
f"<span class='val'>{fmt_num(n)} · {n/total*100:.0f}%</span></div>")
return "".join(out)
def view_knowledge():
drafts = one("SELECT count(*) FROM muse_knowledge_draft WHERE deleted=false") or 0
entities = one("SELECT count(*) FROM muse_knowledge_entity WHERE deleted=false") or 0
public = one("SELECT count(*) FROM muse_knowledge_draft WHERE work_id=0 AND deleted=false") or 0
work_cards = one("SELECT count(*) FROM muse_knowledge_draft WHERE work_id<>0 AND deleted=false") or 0
# 型别看 payload,不看 draft_type 列(库内约定,防误导):公共卡用中文键「型」,作品卡用英文键 type
_, pub_types = q("SELECT COALESCE(draft_payload->>'型','(无型)') t, count(*) FROM muse_knowledge_draft "
"WHERE work_id=0 AND deleted=false GROUP BY 1 ORDER BY 2 DESC")
_, wk_types = q("SELECT COALESCE(draft_payload->>'type','(无型)') t, count(*) FROM muse_knowledge_draft "
"WHERE work_id<>0 AND deleted=false GROUP BY 1 ORDER BY 2 DESC")
cards = f"""<div class="tiles">
<div class="tile"><div class="v">{fmt_num(drafts)}</div><div class="l">草稿卡(待确认)</div></div>
<div class="tile {'zero' if entities==0 else ''}"><div class="v">{fmt_num(entities)}</div><div class="l">已确认知识</div>
<div class="d">{"当前为 0 · 全部草稿待确认" if entities==0 else ""}</div></div>
<div class="tile"><div class="v">{fmt_num(public)}</div><div class="l">公共范式卡(work_id=0)</div></div>
<div class="tile"><div class="v">{fmt_num(work_cards)}</div><div class="l">作品级卡(work_id≠0)</div></div>
</div>
<div class="card"><div class="hd">公共范式卡 · 按型分布(型在 payload 中文键「型」)</div>{_barset(pub_types,[1,2,3,4,5])}</div>
<div class="card"><div class="hd">作品级卡 · 按型分布(型在 payload 英文键 type)</div>{_barset(wk_types,[1,2,3,4,5])}</div>
<p class="note">型别看 <code>draft_payload</code>,<b>不看</b> <code>draft_type</code> 列(该列恒为 entity,无区分力)。
style / pacing 已注册 schema 但当前 0 张卡。草稿≠已确认:确认前 entity 表为 0 是正常状态。</p>"""
# 参考书·拆书·清洗(知识从哪来——拆书是知识的生产线)
_, refs = q("SELECT id, source_file, author, imported_chapter_count, declared_chapter_count, "
"char_count, parse_status FROM example_reference_work WHERE deleted=false ORDER BY id")
if refs:
trs = "".join(
f"<tr><td>{esc(r[1])}</td><td>{esc(r[2])}</td>"
f"<td class='num'>{esc(r[3])} / {esc(r[4] or '?')}</td><td class='num'>{fmt_num(r[5])}</td>"
f"<td>{chip(r[6])}</td></tr>" for r in refs)
reftbl = (f"<div class='card'><div class='hd'>参考书档案({len(refs)})</div>"
f"<table><thead><tr><th>原文件</th><th>作者</th><th class='num'>导入/声明章数</th>"
f"<th class='num'>字符数</th><th>拆书状态</th></tr></thead><tbody>{trs}</tbody></table></div>")
else:
reftbl = empty_gap("还没有参考书", "导入参考书后在此显示")
_, pt = q("SELECT scaffold_status, pattern_status, count(*) FROM example_parse_task "
"WHERE deleted=false GROUP BY 1,2 ORDER BY 3 DESC")
pt_html = ("<div class='card'><div class='hd'>拆书任务状态(章 × 两阶段)</div>" +
"".join(f"<div class='bar-row'><span class='name'>脚手架 {chip(s)} · 范式 {chip(p)}</span>"
f"<span class='val'>{fmt_num(n)} 章</span></div>" for s, p, n in pt) + "</div>"
) if pt else ""
cleans = one("SELECT count(*) FROM example_clean_log WHERE deleted=false") or 0
_, recent = q("SELECT batch, reason, model, occurrences, create_time FROM example_clean_log "
"WHERE deleted=false ORDER BY create_time DESC LIMIT 10")
clean_html = (f"<div class='card'><div class='hd'>清洗审计(共 {fmt_num(cleans)} 条 · 最近 10)</div>" +
"".join(f"<div class='bar-row'><span class='name'>{esc(r[0])} · {esc(r[2])}</span>"
f"<span class='track' style='background:transparent'>{esc(r[1])}</span>"
f"<span class='val'>{esc(r[3])} 处</span></div>" for r in recent) + "</div>"
) if recent else ""
body = (f"<h1>知识库<span class='sub'>实体与范式(muse_knowledge_*)+ 它们的生产线(拆书·清洗)· 实体 02 · 范式 03</span></h1>"
f"{cards}<h2>参考书 · 拆书 · 清洗</h2>{reftbl}{pt_html}{clean_html}")
return layout("知识库", body, "/knowledge")
# ── 运行记录(05 流程 + 06 质量:运行列表按作品/按角色双维 + 运行详情下钻)────────
_RUN_COLS = "<table><thead><tr><th>run_id</th><th>作品</th><th class='num'>章</th><th>触发</th><th>终态</th><th>开始</th></tr></thead><tbody>"
def _run_rows(runs):
return "".join(
f"<tr><td><a href='/runs/{esc(r[0])}'><code>{esc(r[0])}</code></a></td><td>{esc(r[1])}</td>"
f"<td class='num'>{esc(r[2])}</td><td>{esc(r[3])}</td><td>{chip(r[4])}</td><td>{esc(r[5])}</td></tr>"
for r in runs)
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"<option value='{esc(v)}'{' selected' if str(v) == str(sel) else ''}>{esc(t)}</option>"
for v, t in options)
return (f"<form method='get' style='margin:14px 0;display:flex;gap:8px;align-items:center'>"
f"<label style='color:var(--muted)'>{esc(label)}</label>"
f"<select name='{name}'><option value=''>— 全部 —</option>{opts}</select>"
f"<button type='submit'>查看</button></form>")
def view_runs():
runs = _all_runs()
entries = ("<p class='note'>按维度查看:<a href='/runs/by-work'>按作品看运行</a> · "
"<a href='/runs/by-role'>按角色看运行</a></p>")
runs_html = (f"<div class='card'><div class='hd'>运行(最近 50)</div>"
+ _RUN_COLS + _run_rows(runs) + "</tbody></table></div>") if runs else \
empty_gap("暂无运行记录", "创作链落库后,每次运行登记一行(example_run)。")
if table_exists("example_candidate"):
_, cands = q("SELECT id, work_id, target_chapter, candidate_version, run_type, state, source_role "
"FROM example_candidate WHERE deleted=false AND state IN ('draft','checking','passed') "
"ORDER BY create_time DESC LIMIT 30")
cand = ("<div class='card'><div class='hd'>待审候选(活跃)</div><table><thead>"
"<tr><th class='num'>id</th><th>作品</th><th class='num'>章</th><th>版本</th><th>类型</th><th>状态</th><th>角色</th></tr></thead><tbody>"
+ "".join(f"<tr><td class='num'>{esc(c[0])}</td><td>{esc(c[1])}</td><td class='num'>{esc(c[2])}</td>"
f"<td><code>{esc(c[3])}</code></td><td>{esc(c[4])}</td><td>{chip(c[5])}</td><td>{esc(c[6])}</td></tr>" for c in cands)
+ "</tbody></table></div>") if cands else "<div class='empty'>候选表已建 · 暂无待审候选</div>"
else:
cand = empty_gap("待审候选 · 待落库", "候选表(99)未建,AI 候选正文暂无持久化登记。")
if table_exists("example_user_decision"):
_, decs = q("SELECT work_id, target_chapter, decision, rationale, decided_by, create_time "
"FROM example_user_decision ORDER BY create_time DESC LIMIT 30")
dec = ("<div class='card'><div class='hd'>用户决策(最近 30)</div><table><thead>"
"<tr><th>作品</th><th class='num'>章</th><th>决策</th><th>理据</th><th>决策人</th><th>时间</th></tr></thead><tbody>"
+ "".join(f"<tr><td>{esc(d[0])}</td><td class='num'>{esc(d[1])}</td><td>{chip({'accept':'good','merge':'warn','discard':'crit'}.get(d[2],'warn'), d[2])}</td>"
f"<td>{esc((d[3] or '')[:40])}</td><td>{esc(d[4])}</td><td>{esc(d[5])}</td></tr>" for d in decs)
+ "</tbody></table></div>") if decs else "<div class='empty'>决策表已建 · 暂无决策</div>"
else:
dec = empty_gap("用户决策 · 待落库", "决策记录表(99)未建,接受/合并/丢弃暂以 git 提交留痕。")
body = (f"<h1>运行记录<span class='sub'>agentic 的操作与产出 · 流程 05 · 质量 06</span></h1>"
f"{entries}{runs_html}<h2>待审候选</h2>{cand}<h2>用户决策</h2>{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) + "</tbody></table>") if runs else "<div class='empty'>暂无运行</div>"
body = (f"<div class='crumb'><a href='/runs'>运行记录</a> / 按作品</div>"
f"<h1>按作品看运行</h1>{selector}<div class='card'>{tbl}</div>")
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) + "</tbody></table>") if runs else "<div class='empty'>该角色暂无运行</div>"
body = (f"<div class='crumb'><a href='/runs'>运行记录</a> / 按角色</div>"
f"<h1>按角色看运行</h1>{selector}<div class='card'>{tbl}</div>")
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("运行", "<h1>运行不存在</h1>", "/runs")
rid, wid, chap, trig, started, finished, state = rr[0]
info = (f"<div class='card'><table>"
f"<tr><td style='width:110px;color:var(--muted)'>run_id</td><td><code>{esc(rid)}</code></td></tr>"
f"<tr><td style='color:var(--muted)'>作品</td><td>{esc(wid)}</td></tr>"
f"<tr><td style='color:var(--muted)'>目标章</td><td>{esc(chap)}</td></tr>"
f"<tr><td style='color:var(--muted)'>触发</td><td>{esc(trig)}</td></tr>"
f"<tr><td style='color:var(--muted)'>终态</td><td>{chip(state)}</td></tr>"
f"<tr><td style='color:var(--muted)'>起止</td><td>{esc(started)} → {esc(finished)}</td></tr>"
f"</table></div>")
_, 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 = ("<div class='card'><div class='hd'>回执链(按样本 · revision 哈希链)</div><table><thead>"
"<tr><th>样本</th><th class='num'>rev</th><th>阶段</th><th>角色</th><th class='num'>attempt</th><th class='num'>补证</th><th>候选版本</th><th>结果</th></tr></thead><tbody>"
+ "".join(f"<tr><td><code>{esc(r[0])}</code></td><td class='num'>{esc(r[1])}</td><td>{esc(r[2])}</td><td>{esc(r[3])}</td>"
f"<td class='num'>{esc(r[4])}</td><td class='num'>{esc(r[5])}</td><td>{esc(r[6])}</td><td>{chip('failed' if r[7] else 'passed')}</td></tr>" for r in rc)
+ "</tbody></table></div>") 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 = ("<div class='card'><div class='hd'>质量评判</div><table><thead>"
"<tr><th>类型</th><th>维度</th><th>量表版本</th><th class='num'>分</th><th>结论</th><th>失败类别</th></tr></thead><tbody>"
+ "".join(f"<tr><td>{chip(u[0])}</td><td>{esc(u[1])}</td><td>{esc(u[2])}</td><td class='num'>{esc(u[3])}</td><td>{chip(u[4])}</td><td>{esc(u[5])}</td></tr>" for u in qres)
+ "</tbody></table></div>") 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 = ("<div class='card'><div class='hd'>模型调用</div><table><thead>"
"<tr><th>时间</th><th>调用方</th><th>请求模型</th><th>实际模型</th><th class='num'>输出token</th><th class='num'>成本</th></tr></thead><tbody>"
+ "".join(f"<tr><td>{esc(c[0])}</td><td>{esc(c[1])}</td><td>{esc(c[2])}</td><td>{esc(c[3])}</td>"
f"<td class='num'>{fmt_num(c[4])}</td><td class='num'>${float(c[5] or 0):.5f}</td></tr>" for c in calls)
+ "</tbody></table></div>") if calls else empty_gap("暂无模型调用", "")
cand = (empty_gap("待审候选 · 待落库", "候选表(99)未建。") if not table_exists("example_candidate")
else "<div class='empty'>候选表已建 · 该运行暂无候选</div>")
dec = (empty_gap("用户决策 · 待落库", "决策记录表(99)未建。") if not table_exists("example_user_decision")
else "<div class='empty'>决策表已建 · 该运行暂无决策</div>")
body = (f"<div class='crumb'><a href='/runs'>运行记录</a> / <code>{esc(rid)}</code></div>"
f"<h1>运行详情</h1>{info}"
f"<h2>回执链</h2>{rc_html}<h2>质量评判</h2>{q_html}<h2>模型调用</h2>{call_html}"
f"<h2>候选与决策</h2>{cand}{dec}")
return layout(f"运行 · {rid}", body, "/runs")
# ── 智能体(07:读 102 登记表 example_agent_role / example_skill;权威在 Git 侧,登记脚本同步)────
def _fmt_list(v):
"""JSONB 列表 → 逗号串;非列表原样转义。"""
if isinstance(v, list):
return esc(", ".join(str(x) for x in v)) if v else '<span style="color:var(--muted)">∅</span>'
return esc(v)
def view_agents():
_, roles = q("SELECT role, display_name, model, tools FROM example_agent_role "
"WHERE deleted=false ORDER BY role")
_, skills = q("SELECT skill_name, purpose, model_used FROM example_skill "
"WHERE deleted=false ORDER BY skill_name")
role_rows = "".join(
f"<tr><td><a href='/agents/role/{esc(r[0])}'>{esc(r[1] or r[0])}</a> "
f"<span style='color:var(--muted)'>{esc(r[0])}</span></td>"
f"<td>{esc(r[2])}</td><td>{_fmt_list(r[3])}</td></tr>" for r in roles)
skill_rows = "".join(
f"<tr><td><a href='/agents/skill/{esc(s[0])}'>{esc(s[0])}</a></td>"
f"<td>{esc((s[1] or '')[:56])}</td><td>{esc(s[2])}</td></tr>" for s in skills)
body = (f"<h1>智能体<span class='sub'>角色与技能 · 读 102 登记表(权威在 Git 侧,登记脚本同步)· Agent 07</span></h1>"
f"<div class='card'><div class='hd'>角色({len(roles)})</div>"
f"<table><thead><tr><th>角色</th><th>模型</th><th>工具</th></tr></thead><tbody>{role_rows}</tbody></table></div>"
f"<div class='card'><div class='hd'>技能({len(skills)})</div>"
f"<table><thead><tr><th>技能</th><th>用途</th><th>用模型</th></tr></thead><tbody>{skill_rows}</tbody></table></div>")
return layout("智能体", body, "/agents")
def view_agent_role(role):
_, rr = q("SELECT role, display_name, model, effort, budget_usd, tools, responsibility, source_ref, synced_at "
"FROM example_agent_role WHERE role=%s AND deleted=false", (role,))
if not rr:
return layout("角色", "<h1>角色不存在</h1>", "/agents")
role_, disp, model, effort, budget, tools, resp, src, synced = rr[0]
info = (f"<div class='card'><div class='hd'>{esc(disp or role_)} <span style='color:var(--muted)'>{esc(role_)}</span></div>"
f"<table>"
f"<tr><td style='width:120px;color:var(--muted)'>模型归属</td><td>{esc(model)}</td></tr>"
f"<tr><td style='color:var(--muted)'>工具</td><td>{_fmt_list(tools)}</td></tr>"
f"<tr><td style='color:var(--muted)'>可配置项</td><td>effort {esc(effort)} · 预算 {esc(budget)}(暂空待补)</td></tr>"
f"<tr><td style='color:var(--muted)'>出处</td><td><code>{esc(src)}</code> · 同步于 {esc(synced)}</td></tr>"
f"<tr><td style='color:var(--muted)'>职责</td><td>{esc(resp)}</td></tr>"
f"</table></div>")
_, runs = q("SELECT run_id, stage_kind, revision, create_time FROM example_run_receipt "
"WHERE adapter_role=%s ORDER BY create_time DESC LIMIT 20", (role,))
runs_html = ("<div class='card'><div class='hd'>本角色运行(回执,最近 20)</div><table><thead>"
"<tr><th>run_id</th><th>阶段</th><th class='num'>revision</th><th>时间</th></tr></thead><tbody>"
+ "".join(f"<tr><td><code>{esc(r[0])}</code></td><td>{esc(r[1])}</td><td class='num'>{esc(r[2])}</td><td>{esc(r[3])}</td></tr>" for r in runs)
+ "</tbody></table></div>") if runs else empty_gap("本角色暂无运行回执", "创作链落库后,该角色的运行会显示在这里。")
_, calls = q("SELECT call_time, requested_model_id, out_tokens, cost_usd FROM example_llm_call "
"WHERE caller=%s ORDER BY call_time DESC LIMIT 20", (role,))
calls_html = ("<div class='card'><div class='hd'>本角色模型调用(最近 20)</div><table><thead>"
"<tr><th>时间</th><th>模型</th><th class='num'>输出token</th><th class='num'>成本</th></tr></thead><tbody>"
+ "".join(f"<tr><td>{esc(c[0])}</td><td>{esc(c[1])}</td><td class='num'>{fmt_num(c[2])}</td><td class='num'>${float(c[3] or 0):.5f}</td></tr>" for c in calls)
+ "</tbody></table></div>") if calls else empty_gap("本角色暂无模型调用", "")
_, quals = q("SELECT q.judge_kind, q.dimension, q.conclusion, q.failure_class FROM example_quality_result q "
"JOIN example_run_receipt r ON r.id=q.receipt_id WHERE r.adapter_role=%s ORDER BY q.create_time DESC LIMIT 20", (role,))
quals_html = ("<div class='card'><div class='hd'>本角色质量评判(最近 20)</div><table><thead>"
"<tr><th>类型</th><th>维度</th><th>结论</th><th>失败类别</th></tr></thead><tbody>"
+ "".join(f"<tr><td>{esc(u[0])}</td><td>{esc(u[1])}</td><td>{chip(u[2])}</td><td>{esc(u[3])}</td></tr>" for u in quals)
+ "</tbody></table></div>") if quals else empty_gap("本角色暂无质量评判", "")
body = (f"<div class='crumb'><a href='/agents'>智能体</a> / {esc(disp or role_)}</div>"
f"<h1>{esc(disp or role_)}</h1>{info}<h2>运行与产出</h2>{runs_html}{quals_html}{calls_html}")
return layout(f"角色 · {disp or role_}", body, "/agents")
def view_skill(name):
_, ss = q("SELECT skill_name, purpose, consumer, reads, writes, red_lines, model_used, source_ref, synced_at "
"FROM example_skill WHERE skill_name=%s AND deleted=false", (name,))
if not ss:
return layout("技能", "<h1>技能不存在</h1>", "/agents")
sn, purpose, consumer, reads, writes, red_lines, model_used, src, synced = ss[0]
body = (f"<div class='crumb'><a href='/agents'>智能体</a> / 技能 / {esc(sn)}</div>"
f"<h1>{esc(sn)}</h1>"
f"<div class='card'><table>"
f"<tr><td style='width:120px;color:var(--muted)'>用途</td><td>{esc(purpose)}</td></tr>"
f"<tr><td style='color:var(--muted)'>给谁用</td><td>{esc(consumer) if consumer else '<span style=\"color:var(--muted)\">待补</span>'}</td></tr>"
f"<tr><td style='color:var(--muted)'>用模型</td><td>{esc(model_used) if model_used else '确定性工具(不用模型)'}</td></tr>"
f"<tr><td style='color:var(--muted)'>涉及表</td><td>{_fmt_list(reads)}<div style='color:var(--muted);font-size:12px'>自动抽取,读/写方向待人工核</div></td></tr>"
f"<tr><td style='color:var(--muted)'>红线</td><td>{esc(red_lines) if red_lines else '<span style=\"color:var(--muted)\">待补</span>'}</td></tr>"
f"<tr><td style='color:var(--muted)'>出处</td><td><code>{esc(src)}</code> · 同步于 {esc(synced)}</td></tr>"
f"</table></div>")
return layout(f"技能 · {sn}", body, "/agents")
# ── 路由 ─────────────────────────────────────────────────────────────────────
_ROUTES = [
(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):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
try:
for pat, fn in _ROUTES:
m = pat.match(parsed.path)
if m:
self._send(200, fn(m, params))
return
self._send(404, layout("未找到", "<h1>404 · 页面不存在</h1>", ""))
except Exception: # 客户端给固定文案,细节写服务端 stderr(不泄露 DB 内部结构;看板挂了不牵连库)
traceback.print_exc()
self._send(500, layout("出错", "<h1>看板渲染出错</h1><p class='note'>该请求处理失败,细节记录在服务端日志。</p>", ""))
def _send(self, code, body):
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"muse 只读看板:http://{HOST}:{PORT} (Ctrl-C 退出;只读连接,不写任何数据)")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()