Files

461 lines
24 KiB
Python
Raw Permalink 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.
"""报告导出的构建:报告文档 → 自包含的 HTML(正文服务端渲染 + 每个数字的出处链嵌进数据块)。
拿到文件的人在浏览器里打开:不执行脚本也读得到整份报告(正文是服务端按文档的块、句、片段渲染好的 HTML);
执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的
出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查),
以及运行输入、表名字段名、引文各自的步骤;分析结论(判定语、具名的数、结果表的格)点开是判定 → 方法 → 计算 → 输入 →
复算 → 封存。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。报告文档带着系统写的「分析范围」
(analysis_scope)时,正文后面另渲染这一段,编号点得开同一条出处链;没有这个字段的文档一个字都不多。
这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希
(可验证报告,app/bundle)。页面页脚写明这一点。
主体是纯函数:不碰数据库。调用方(api/evidence 的路由)取好报告文档、逐个片段算好出处链再交进来。
"""
from __future__ import annotations
import base64
import hashlib
import html
import json
import re
from collections.abc import Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from urllib.parse import quote
SCHEMA = "agentlab.report/1"
#: 和 bundle/build.py 的附件名同一个规矩:文件名里不能有的字(Windows 最严,按它来)换成下划线
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
_NAME_MAX = 80
FILE_SUFFIX = "-报告.html"
#: 没挂在工作流上的运行叫「未保存的工作流」(同 bundle/build.py、前端 lib/terms.ts 的 runName)
UNSAVED_NAME = "未保存的工作流"
_UNSAVED_NAMES = frozenset({"", "临时图", UNSAVED_NAME})
def display_name(run: Mapping[str, Any]) -> str:
"""页面标题、附件名用的运行名:工作流名;没保存的工作流一律叫「未保存的工作流」。(同 bundle/build.py)"""
name = str(run.get("workflow_name") or "").strip()
if not run.get("workflow_id") and name in _UNSAVED_NAMES:
return UNSAVED_NAME
return name or f"运行 {str(run.get('id') or '')[:8]}".strip()
def attachment_name(run: Mapping[str, Any]) -> str:
"""附件名「<运行名>-报告.html」。"""
name = _BAD_FILENAME.sub("_", display_name(run)).strip(" .")[:_NAME_MAX].strip(" .")
return f"{name or UNSAVED_NAME}{FILE_SUFFIX}"
def content_disposition(filename: str, *, run_id: str) -> str:
"""附件头:中文名按 RFC 5987 写在 filename* 里;filename 给不认 filename* 的老客户端一个纯 ASCII 的名字。
(同 bundle/build.py 的拼法,脚本 check-bundle.mjs 对这份文件同样断言附件名。)"""
ascii_id = re.sub(r"[^A-Za-z0-9]", "", run_id)[:8]
return f"attachment; filename=\"agentlab-{ascii_id}-report.html\"; filename*=UTF-8''{quote(filename, safe='')}"
# --------------------------------------------------------------------------
# 正文:报告文档 → HTML(服务端渲染)
# --------------------------------------------------------------------------
#: 行内语法的写法和 bundle/verifier.js、前端 run/Markdown.tsx 同一套:代码(反引号)、链接(只显示文字:
#: 页面不连网)、粗体、斜体、删除线。斜体左边紧挨 * 的不算(否则 **粗体** 会先被它拆掉)
_INLINE = re.compile(
r"(`+)([^`]+?)\1|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|\*\*([^*]+?)\*\*|__([^_]+?)__"
r"|\*([^*\n]+?)\*(?!\*)|~~([^~]+?)~~")
_CODE, _LINK, _BOLD, _EM, _STRIKE = 1, 2, 4, 8, 16
#: 列表项的开头:行首的 -、*、+ 或者序号(同 verifier.js 的 ITEM_START)
_ITEM_START = re.compile(r"(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$")
#: 表格里看着是数的列(右对齐):同 verifier.js 的 NUMERIC_CELL
_NUMERIC_CELL = re.compile(r"^[-+−]?[\d,]+(?:\.\d+)?%?[^\d\s]{0,4}$")
#: 片段的状态 → 正文里的线型(class)。同网页上证据文档的颜色语言:实线绿是有出处,点线是核对不了,
#: 波浪红是可疑(反引号里发现的编造名字这类)。probabilistic / candidate 按未裁判、候选画
_STATE_CLASS = {
"deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad",
"unjudged": "na", "candidate": "na",
}
_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文", "analysis": "判定"}
def _esc(text: Any) -> str:
return html.escape(str(text if text is not None else ""))
def _inline_matches(part: str) -> list[re.Match[str]]:
"""一句里的行内语法,按先后。斜体左边紧挨着 * 的跳过、从下一个字接着找(同 verifier.js)。"""
out: list[re.Match[str]] = []
pos = 0
while True:
m = _INLINE.search(part, pos)
if m is None:
return out
if m.group(7) is not None and m.start() > 0 and part[m.start() - 1] == "*":
pos = m.start() + 1
continue
out.append(m)
pos = m.end()
def _inline_marks(text: str) -> tuple[bytearray, bytearray]:
"""整句算一次行内样式:hide 是语法符号占的位置(不显示),style 是每个可见字的样式位。
粗体、代码可能跨片段(服务端把一句切成了好几个片段),所以按整句算、每个片段取自己那一截。
( verifier.js inlineStyles 的 Python 版,规则一致。)"""
hide = bytearray(len(text))
style = bytearray(len(text))
def walk(frm: int, to: int, inherited: int, depth: int) -> None:
part = text[frm:to]
last = 0
for m in _inline_matches(part):
code, link_text, bold1, bold2, italic, strike = (
m.group(2), m.group(3), m.group(5), m.group(6), m.group(7), m.group(8))
for i in range(last, m.start()):
style[frm + i] = inherited
inner = next((v for v in (code, link_text, bold1, bold2, italic, strike) if v is not None), "") or ""
base = frm + m.start()
whole = m.group(0)
off = len(m.group(1) or "") if code is not None else whole.find(inner)
for i in range(len(whole)):
hide[base + i] = 1
for i in range(len(inner)):
hide[base + off + i] = 0
bit = _CODE if code is not None else _LINK if link_text is not None \
else _BOLD if bold1 is not None or bold2 is not None \
else _EM if italic is not None else _STRIKE
if code is not None or link_text is not None or depth >= 3:
for i in range(len(inner)):
style[base + off + i] = inherited | bit
else:
walk(base + off, base + off + len(inner), inherited | bit, depth + 1)
last = m.end()
for i in range(last, len(part)):
style[frm + i] = inherited
walk(0, len(text), 0, 0)
return hide, style
def _styled_html(text: str, hide: bytearray, style: bytearray, from_: int, to: int) -> str:
"""一句里 [from_, to) 那一段按算好的样式写成 HTML。"""
out: list[str] = []
i = from_
while i < to:
if hide[i]:
i += 1
continue
st = style[i]
j = i + 1
while j < to and not hide[j] and style[j] == st:
j += 1
piece = _esc(text[i:j])
if st & _CODE:
piece = f"<code>{piece}</code>"
if st & _EM:
piece = f"<em>{piece}</em>"
if st & _BOLD:
piece = f"<strong>{piece}</strong>"
if st & _STRIKE:
piece = f"<s>{piece}</s>"
if st & _LINK:
piece = f'<span class="link">{piece}</span>'
out.append(piece)
i = j
return "".join(out)
def segment_state(seg: Mapping[str, Any]) -> str | None:
"""片段的状态(同前端 segmentState 的分法):文字、结构片段是 None(不画线、点不开)。
导出的路由按它决定哪个片段要算出处链,正文渲染按它画线,两边同一个分法。"""
if seg.get("kind") == "structural":
return None
state = seg.get("state")
if state == "deterministic":
return "deterministic"
if state == "none":
if seg.get("issue") == "unknown_entity" or (seg.get("cite") or {}).get("unknown"):
return "suspect"
if seg.get("issue") == "unverified_entity" or (seg.get("cite") or {}).get("unverified"):
return "unverified"
return "none"
if state in ("probabilistic", "candidate"):
return "unjudged" if state == "probabilistic" else "candidate"
return None
def _seg_html(seg: Mapping[str, Any], body: str) -> str:
"""一个片段:有状态的是按钮(点开出处面板),文字、结构片段照字渲染。"""
state = segment_state(seg)
kind = str(seg.get("kind") or "")
if state is None:
inner = f"<strong>{body}</strong>" if seg.get("strong") else body
return f"<span>{inner}</span>"
cls = _STATE_CLASS.get(state, "na")
label = _KIND_LABEL.get(kind, "片段")
title = f"{label}「{_esc(seg.get('text'))}」:点开查看出处"
attrs = (f'class="num num-{cls}" data-seg="{_esc(seg["id"])}" data-state="{_esc(state)}"'
f' data-kind="{_esc(kind)}" title="{title}"')
if seg.get("strong"):
body = f"<strong>{body}</strong>"
return f'<button type="button" {attrs}>{body}</button>'
def _render_unit(unit: Mapping[str, Any]) -> str:
"""一句:片段先拼成整句算行内样式,再各自取自己那一截(结构片段不进正文,列表序号由 <li> 自己画)。"""
segs = [s for s in unit.get("segments") or [] if isinstance(s, dict) and s.get("kind") != "structural"]
text = "".join(str(s.get("text") or "") for s in segs)
hide, style = _inline_marks(text)
out: list[str] = []
pos = 0
for seg in segs:
body = _styled_html(text, hide, style, pos, pos + len(str(seg.get("text") or "")))
out.append(_seg_html(seg, body))
pos += len(str(seg.get("text") or ""))
return "".join(out)
def _unit_text(unit: Mapping[str, Any]) -> str:
return "".join(str(s.get("text") or "") for s in unit.get("segments") or []
if isinstance(s, dict) and s.get("kind") != "structural")
def _render_block(block: Mapping[str, Any]) -> str:
"""一块:标题、正文、引用、代码、列表、表格、分隔线。(verifier.js renderBlock 的 Python 版。)"""
units = [u for u in block.get("units") or [] if isinstance(u, dict)]
btype = str(block.get("type") or "paragraph")
if btype == "heading":
level = min(max(int(block.get("level") or 2), 1), 3)
return f'<h{level + 3} class="doc-h doc-h{level}">{"".join(_render_unit(u) for u in units)}</h{level + 3}>'
if btype == "hr":
return "<hr>"
if btype == "quote":
return f'<blockquote>{"".join(_render_unit(u) for u in units)}</blockquote>'
if btype == "code":
return f'<pre><code>{_esc("".join(_unit_text(u) for u in units))}</code></pre>'
if btype == "list":
items: list[dict[str, Any]] = []
for u in units:
lead = (u.get("segments") or [None])[0]
starts = not items or (isinstance(lead, dict) and lead.get("kind") == "structural"
and _ITEM_START.match(str(lead.get("text") or "")))
if starts:
items.append({"depth": int(u.get("depth") or 0), "units": [u]})
else:
items[-1]["units"].append(u)
tag = "ol" if block.get("ordered") else "ul"
start = f' start="{int(block["start"])}"' if block.get("ordered") \
and isinstance(block.get("start"), int) and block["start"] != 1 else ""
rows = []
for item in items:
margin = f' style="margin-left:{item["depth"] * 14}px"' if item["depth"] else ""
rows.append(f'<li{margin}>{"".join(_render_unit(u) for u in item["units"])}</li>')
return f"<{tag}{start}>{''.join(rows)}</{tag}>"
if btype == "table":
# 表头 row = -1;一格可能被分成几句,按位置归拢(同 verifier.js)
cells: dict[str, list[Mapping[str, Any]]] = {}
row_ids: list[int] = []
cols = 0
for u in units:
loc = u.get("loc") if isinstance(u.get("loc"), dict) else {"row": 0, "col": 0}
key = f'{loc.get("row")}:{loc.get("col")}'
cells.setdefault(key, []).append(u)
cols = max(cols, int(loc.get("col") or 0) + 1)
if int(loc.get("row") or 0) >= 0 and int(loc.get("row") or 0) not in row_ids:
row_ids.append(int(loc.get("row")))
row_ids.sort()
def text_at(r: int, c: int) -> str:
return re.sub(r"[*`]", "", "".join(_unit_text(u) for u in cells.get(f"{r}:{c}", []))).strip()
numeric = []
for c in range(cols):
texts = [text_at(r, c) for r in row_ids if text_at(r, c)]
numeric.append(bool(texts) and all(_NUMERIC_CELL.match(t) for t in texts))
def cell(tag: str, r: int, c: int) -> str:
cls = f' class="n"' if tag == "td" and numeric[c] else ""
return f"<{tag}{cls}>{''.join(_render_unit(u) for u in cells.get(f'{r}:{c}', []))}</{tag}>"
head = "".join(cell("th", -1, c) for c in range(cols))
body = "".join("<tr>" + "".join(cell("td", r, c) for c in range(cols)) + "</tr>" for r in row_ids)
return (f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
f"<tbody>{body}</tbody></table></div>")
return f'<p>{"".join(_render_unit(u) for u in units)}</p>'
def render_body(doc: Mapping[str, Any]) -> str:
"""报告文档的正文 HTML。块都读不出来时退回 markdown 原文(等宽显示,至少读得到)。"""
blocks = [b for b in doc.get("blocks") or [] if isinstance(b, dict)]
if not blocks:
return f'<div class="doc"><pre>{_esc(doc.get("markdown") or "")}</pre></div>'
return f'<div class="doc">{"".join(_render_block(b) for b in blocks)}</div>'
#: 「分析范围」各组的叫法(00-总方案 §3.6),按这个顺序写
SCOPE_TITLES = (("phenomena", "现象"), ("decomposed", "拆过"), ("excluded", "排除"), ("refused", "算不了"))
SCOPE_NOTE = "这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定标准、计算、输入和复算。"
#: 「检验」那一项后面接的校正说明(analysis.contract.ScopeBlock.corrected):只做了一次的,一次里比了几对的已按次数校正;
#: 做了不止一次的,几次之间没有校正。和可验证报告的核对页(bundle/verifier.js 的 scopeCorrected)同一种说法
SCOPE_CORRECTED = {True: "(一次里比了几对的已按次数校正)", False: ",几次检验之间没有按次数校正"}
#: 分析范围里一个编号的片段键(api/evidence.SCOPE_REF 同一个写法):点开时按它找出处链
_SCOPE_REF = "scope:{alias}"
def _scope_item(item: Mapping[str, Any], *, depth: int = 0) -> str:
"""分析范围里的一条:编号(按钮,点开出处链)、维度、下钻自哪一条、判定语或算不了的原因。"""
alias = str(item.get("alias") or "")
title = f"分析结论 {_esc(alias)}:点开查看出处"
button = (f'<button type="button" class="num num-ok" data-seg="{_esc(_SCOPE_REF.format(alias=alias))}"'
f' data-state="deterministic" data-kind="analysis" title="{title}">'
f"{_esc(alias)}</button>") if alias else ""
dims = [str(d) for d in item.get("dims") or [] if d]
bits = ([f"按{'、'.join(dims)}"] if dims else []) + ([f"下钻自 {item['parent']}"] if item.get("parent") else [])
lead = f"({','.join(_esc(b) for b in bits)})" if bits else ""
said = item.get("reason") if item.get("reason") and not item.get("phrase") else item.get("phrase")
margin = f' style="margin-left:{depth * 14}px"' if depth else ""
return f"<li{margin}>{button}{lead}{':' if said else ''}{_esc(said or '')}</li>"
def render_scope(doc: Mapping[str, Any]) -> str:
"""报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):现象、拆过(下钻按链缩进)、排除、算不了、没拆、
检验了几次、校正过没有。文档没有这个字段(老文档、这次没有分析结论、报告节点关了这一段)时是空串,页面一个字都不多。"""
scope = doc.get("analysis_scope")
if not isinstance(scope, Mapping):
return ""
rows: list[str] = []
for key, title in SCOPE_TITLES:
items = [i for i in scope.get(key) or [] if isinstance(i, Mapping)]
if not items:
continue
depth: dict[str, int] = {}
lines = []
for item in items:
level = depth.get(str(item.get("parent") or ""), -1) + 1 if item.get("parent") else 0
depth[str(item.get("alias") or "")] = level
lines.append(_scope_item(item, depth=level))
rows.append(f"<dt>{title}</dt><dd><ul>{''.join(lines)}</ul></dd>")
missing = scope.get("not_decomposed")
if isinstance(missing, list):
names = "、".join(_esc(n) for n in missing if n)
rows.append(f"<dt>没拆</dt><dd>{names or '候选维度都拆过了'}</dd>")
elif "not_decomposed" in scope:
rows.append(f"<dt>没拆</dt><dd>{_esc(scope.get('note') or '候选维度核对不了')}</dd>")
tests = scope.get("tests")
if isinstance(tests, int) and not isinstance(tests, bool) and tests > 0:
corrected = scope.get("corrected")
note = SCOPE_CORRECTED.get(corrected, "") if isinstance(corrected, bool) else ""
rows.append(f"<dt>检验</dt><dd>这次做了 {tests} 次差异检验{note}</dd>")
if not rows:
return ""
return (f'<section class="scope" aria-labelledby="scope-title"><h3 id="scope-title">分析范围</h3>'
f'<p class="muted">{SCOPE_NOTE}</p><dl class="scope-list">{"".join(rows)}</dl></section>')
# --------------------------------------------------------------------------
# 数据块和页面
# --------------------------------------------------------------------------
#: 数据放在 <script type="application/json"> 里:< > & 换成转义,正文里的 </script>、<!-- 截不断数据块
#: (同 bundle/build.py 的 _JSON_ESCAPES)
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
"\u2028": "\\u2028", "\u2029": "\\u2029"})
_PLACEHOLDER = re.compile(r"__REPORT_(TITLE|NAME|SEAL|RUN|BODY|CSP|DATA|SCRIPT)__")
def build_report(
run: Mapping[str, Any],
*,
report_node: str,
doc_artifact: str,
doc_hash_ok: bool | None,
doc: Mapping[str, Any],
seal: Mapping[str, Any],
chains: Mapping[str, Sequence[Mapping[str, Any]]],
refs: Mapping[str, str],
notes: Mapping[str, str],
nodes: Mapping[str, Mapping[str, Any]],
exported_at: str,
ledger: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""页面里的数据(report-data)。
- run:运行记录的几样(id、workflow_id、workflow_name、run_class、status、end_reason、started_at、finished_at);
end_reason 分开同是 cancelled 的被驳回和手动停止(05-已知遗留 (a))
- report_node / doc_artifact / doc_hash_ok:选中的报告节点、它的文档工件、取回时哈希核对的结果
- doc:报告文档(正文的服务端渲染另走 render_body,这里不给:数据块只装点开数字要用的东西)
- seal:{_sealed 的 seal 字段里的几样}(sealed、ok、manifest_hash、manifest_seq)
- chains:每处引用的出处链(api/evidence._chain 的原样输出),键是调用方编的引用键
- refs:片段 id → 引用键(点开这个片段看哪条链)
- notes:片段 id → 为什么是现在这个状态(api/evidence._note)
- nodes:节点 id → {label, type}
- ledger:口径账本给这份报告的标签(app/ledger/view.report_labels,已按遮罩给):{labels: {片段 id: 标签}, note, retro, …}。
面板最上面多「口径」一步,写明「封存后比对,不在封存范围内」。没比对过的运行不给,数据块里就没有这个键(和以前一字不差)
"""
out = {
"schema": SCHEMA,
"meta": {
"run_id": run.get("id"), "workflow_id": run.get("workflow_id"),
"workflow_name": display_name(run), "run_class": run.get("run_class"), "status": run.get("status"),
"end_reason": run.get("end_reason"),
"started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")),
"report": {"node_id": report_node, "label": (nodes.get(report_node) or {}).get("label") or report_node},
"doc_artifact": doc_artifact, "doc_hash_ok": doc_hash_ok,
"seal": {k: seal.get(k) for k in ("sealed", "ok", "manifest_hash", "manifest_seq") if k in seal},
"exported_at": exported_at,
},
"nodes": dict(nodes),
"chains": {k: [*v] for k, v in chains.items()},
"refs": dict(refs),
"notes": dict(notes),
}
if ledger:
out["ledger"] = {k: v for k, v in ledger.items() if k in ("labels", "note", "retro", "retro_note", "algo")}
return out
def _iso(value: Any) -> str | None:
return None if value is None else value.isoformat() if hasattr(value, "isoformat") else str(value)
@lru_cache(maxsize=1)
def _assets() -> tuple[str, str]:
here = Path(__file__).parent
return (here / "template.html").read_text(encoding="utf-8"), (here / "runtime.js").read_text(encoding="utf-8")
def _json_text(data: Mapping[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")).translate(_JSON_ESCAPES)
def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str:
"""把正文和数据填进模板:样式、脚本都内联,零外链。
内容安全策略按哈希只放行这一段脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
正文是服务端渲染的 HTML,封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览常常不执行脚本,
也要读得到报告、看得到该拿什么去系统里对照;执行脚本后头部换成完整的信息、数字点得开出处。
占位符一次替换完,填进去的内容不会再被当成占位符。(同 bundle/build.py render_html 的做法。)"""
template, script = _assets()
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
meta = data.get("meta") or {}
name = str(meta.get("workflow_name") or UNSAVED_NAME)
seal = meta.get("seal") or {}
manifest = str(seal.get("manifest_hash") or "")
# 不执行脚本时封存那一格写什么:封存了给哈希(拿去系统对照),没封存照实说
seal_text = f"封存清单哈希 <code>{_esc(manifest)}</code>(运行结束并封存后可用它与系统对照)" if manifest \
else "导出时这次运行尚未封存:出处与封存状态是导出那一刻的记录"
values = {
"TITLE": _esc(f"{name} · 报告"),
"NAME": _esc(name),
"SEAL": seal_text,
"RUN": _esc(str(meta.get("run_id") or "")),
"BODY": render_body(doc) + render_scope(doc),
"CSP": f"'sha256-{digest}'",
"DATA": _json_text(data),
"SCRIPT": script,
}
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)