Files
AgentLab/backend/app/engine/evidence.py
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

1906 lines
91 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.
"""可点击的证据:报告里的每个数字由系统从证据里取、按规则渲染,出处写在数据结构里。
以前叙述是模型写的自由文本,出具时再按数值回头猜每个数来自哪个指标——5 个指标
都是 0.0 时,报告里所有的 0 全算给第一个。这里反过来:模型只写引用标记
`[[m:gmv]]`,数值由系统从口径卡取出、按口径卡的格式渲染。叙述层在结构上就写不出
数字,按数值猜出处这件事不再需要。
整个模块是纯函数(不碰数据库、不调模型),报告节点自查、出口契约复核、证据接口
共用这一份,免得三处各写一套规则、各判各的。
标记语法:
[[m:<指标 id>]] 口径卡指标,渲染成「值+单位」
[[m:<指标 id>|万]] 换算显示:万 / 亿 / pct / int / .N(小数位),由渲染器完成
[[v:Q<n>.r<行>.<列>]] 查询快照里的一格(取回时复验哈希),也能加 |万 这类换算
[[table:Q<n> cols=a,b rows=0-4]] 整表:系统从快照生成 Markdown 表,每一格都是带出处的片段
[[i:<输入字段>]] 运行输入
[[see:<ref>,<ref>…]] 依据:挂在句末,不渲染
`[[t:]]` `[[c:]]` `[[q:]]` 能解析,本期一律判为解析不了,原因写「这种引用在后续版本支持」。
沙箱代码节点的产出不能直接引用(`[[v:N:calc.x]]`),原因写「沙箱算出来的数要进口径卡」。
单元格和整表要读快照:整个模块仍然不碰数据库、不调模型,快照经 loader 取(缺省是
artifact_store.load,只读文件)。测试和接口可以换成自己的 loader。
偏移一律按 Unicode 码点算(Python 的 str 下标)。前端的 JS 字符串按 UTF-16 计,
碰到码点在 BMP 之外的字符(emoji)要自己换算。
"""
from __future__ import annotations
import bisect
import json
import math
import re
from collections.abc import Callable
from decimal import ROUND_HALF_UP, Context, Decimal
from typing import Any, Literal, TypedDict
from app.core.artifact_store import canonical_json, content_hash
from app.core.artifact_store import load as load_artifact
from app.engine.expressions import CellError, cell_value, column_kind, locate_cell
from app.engine.issuance import extract_numbers, number_allowance
DOC_SCHEMA = "agentlab.report/1"
# --------------------------------------------------------------------------
# 数据结构(方案 1.5)。都是普通 dict,TypedDict 只用来写清形状
# --------------------------------------------------------------------------
class EvidenceEntry(TypedDict, total=False):
"""台账条目:state["evidence"] 里的一行,同时写进 node.finished.evidence。"""
kind: Literal["query", "metric_set", "retrieval", "tool", "node_output", "schema"]
node_id: str
exec: int # 这个节点第几次执行(循环里会有多次),从 1 数
artifact: str # 被引用的那件工件
via: str # 外层包装的工件(query_snapshot 外面的 tool_snapshot)
call_id: str
tool: str
source: str
columns: list[str]
rows: int
truncated: bool
caliber: str # metric_set 专有:口径名、版本、指标 id 清单
version: str
metrics: list[str]
code_sha: str # node_output(代码节点)专有:实际执行的代码的 sha256、
role: str # evidence_role(source / compute)、语言
language: str
class Citation(TypedDict, total=False):
ref: str # 标记里写的原文:gmv、week、Q4.r0.amount
alias: str # 目录里的键:m:gmv、i:week、Q4
locator: dict[str, Any]
eid: str
kind: str # metric / input / query / cell / table / column / quote
role: Literal["value", "entity", "quote", "support"]
status: Literal["resolved", "unresolved", "mismatch"]
reason: str # unresolved 时为什么
conv: str # 换算:万 / 亿 / pct / int / .N
value: Any
rendered: str
class Segment(TypedDict, total=False):
id: str
kind: Literal["text", "number", "value", "entity", "quote", "structural"]
text: str
span: list[int] # [start, end),在 doc.markdown 里的码点偏移
ref: str # 带前缀的原始引用:m:gmv、i:week
cite: Citation
state: Literal["deterministic", "probabilistic", "none", "neutral"]
strong: bool
issue: str # 这一段本身的问题:uncited_number / unresolved_ref
class Unit(TypedDict, total=False):
id: str
kind: Literal["claim", "connective", "heading", "code"]
span: list[int] # 内容的起止(不含行首的 Markdown 语法)
cites: list[str] # 这一句引用过的 alias,行内和 [[see:]] 合在一起、去重
see: list[Citation] # [[see:]] 里的每一个引用,单独核对
segments: list[Segment]
loc: dict[str, int] # 表格单元格:{row, col},表头 row = -1
depth: int # 列表项的缩进层级
class Block(TypedDict, total=False):
id: str
type: Literal["heading", "paragraph", "list", "table", "quote", "code", "hr"]
level: int
ordered: bool
start: int # 有序列表的起始序号
lang: str
units: list[Unit]
class Violation(TypedDict, total=False):
code: str
message: str
span: list[int]
text: str
segment: str
unit: str
ref: str
context: str
# --------------------------------------------------------------------------
# eid:内容派生的全局标识
# --------------------------------------------------------------------------
def make_eid(kind: str, artifact: str | None, locator: dict[str, Any] | None) -> str:
"""ev:<kind>:<16 位 hex>。同样的 (kind, 工件, 定位) 永远得到同一个 eid。
解析时重算一次,和目录里记的对不上就说明目录被改过。
"""
digest = content_hash(canonical_json({"kind": kind, "artifact": artifact, "locator": locator or {}}))
return f"ev:{kind}:{digest[:16]}"
def input_eid(field: str, value: Any) -> str:
"""运行输入的 eid。输入没有工件,工件那一格放值的指纹:同一个字段换了值
(2026-W37 → 2026-W38)就是另一件证据,跨运行比对才有意义。
目录条目里记着 value,拿它就能复算:make_eid("input", content_hash(canonical_json(value)), {"field": field})。
"""
return make_eid("input", content_hash(canonical_json(value)), {"field": field})
def cell_eid(artifact: str | None, row: int, column: str) -> str:
"""查询快照里一格的 eid。报告的 [[v:]]、agent 字段的出处、口径卡的 cell() 都按它算,
同一格在三处是同一个标识。column 一律是列名(列号先换成列名)。"""
return make_eid("cell", artifact, {"row": row, "column": column})
# --------------------------------------------------------------------------
# 台账:节点执行器写条目时用的几件小事
# --------------------------------------------------------------------------
def ledger_enabled(run: Any) -> bool:
"""这次运行记不记证据台账(以及二期的其他新行为)。
判断「升级前发起的运行」沿用护栏那次加的快照:agent_limits 为 None 的运行可能正停在
某个审批上,节点恢复时整个重放——多一个 task、给模型的消息多一行,checkpoint 里缓存
的结果就对错号。这类运行一律走旧逻辑,一个字都不加。
"""
return getattr(run, "agent_limits", None) is not None
def next_exec(state: Any, node_id: str) -> int:
"""这个节点这是第几次成功执行(从 1 数,循环里会有多次),按 trail 算。"""
done = sum(1 for t in (state.get("trail") or []) if t.get("node_id") == node_id and "error" not in t)
return done + 1
def query_entry_fields(payload: Any) -> dict[str, Any] | None:
"""数据源查询工具交回的 JSON(文本或已解析的对象)→ 台账 query 条目要的字段。
不是一次成功的查询(「查询失败:…」这类原话、别的工具的返回、没落成快照的)返回 None。
rows 是快照里的行数:单元格按快照里的行号引用。
"""
data = payload
if isinstance(data, str):
if not data.lstrip().startswith("{"):
return None
try:
data = json.loads(data)
except ValueError:
return None
if not isinstance(data, dict):
return None
artifact, columns, rows = data.get("artifact"), data.get("columns"), data.get("rows")
if not (isinstance(artifact, str) and artifact and isinstance(columns, list) and isinstance(rows, list)):
return None
return {"artifact": artifact, "source": data.get("source"), "columns": [str(c) for c in columns],
"rows": len(rows), "truncated": bool(data.get("truncated"))}
# --------------------------------------------------------------------------
# 渲染:数值 → 报告里显示的那几个字
#
# 规则是固定的,同一个值永远渲染成同一串字:报告节点边写边渲染一次,出口契约
# 复核时再渲染一次,两边逐字比对。所以这里一律用 Decimal 按「四舍五入」算,不走
# float 的 round(2.675 在 float 里是 2.67499…,round 出来是 2.67)。
# --------------------------------------------------------------------------
FORMATS = ("plain", "thousands", "percent_of_ratio")
#: 口径卡没写 format 时按千分位
DEFAULT_FORMAT = "thousands"
#: 值拿不到时显示的字。不插值、不写 0
MISSING = "—"
_CONV_SCALE = {"万": Decimal(10) ** 4, "亿": Decimal(10) ** 8}
_CONV_PLACES = re.compile(r"^\.([0-6])$")
#: 小数位最多写到多少。负数是取整到十位、百位……(round(x, -2) 的意思)
MAX_PLACES = 100
#: 整数部分最多多少位。浮点数到头也就 309 位,再大的整数按规则一位位写出来没有意义
_MAX_DIGITS = 1000
class RenderError(ValueError):
"""这个值按这种写法渲染不出来。报告里算作解析不了的引用,原因就是异常消息。"""
def is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _decimal(value: int | float) -> Decimal:
if isinstance(value, float):
if not math.isfinite(value):
raise RenderError(f"{value} 不是一个有限的数")
return Decimal(repr(value))
return Decimal(value)
def _ctx(d: Decimal, places: int = 10) -> Context:
"""精度跟着值走。定死一个精度的话,1e55 留两位小数就超出精度(抛 InvalidOperation),
70 位的整数去尾零时会被悄悄舍入成 60 位有效数字——显示出来的就不是那个数了。"""
need = max(len(d.as_tuple().digits), d.adjusted() + max(places, 0)) + 10
return Context(prec=max(60, need), rounding=ROUND_HALF_UP)
def _shown(d: Decimal) -> str:
text = format(d, "f")
return text if len(text) <= 24 else format(d, ".6g")
def _digits(d: Decimal, places: int | None, *, group: bool, strip: bool = False,
zero: str | None = None) -> str:
"""按小数位四舍五入后写出来。
一个不是 0 的值写出来成了 0(29 单换算成「0万单」、0.004 取整成「0」),就不写:
抛 RenderError,原因是 zero。系统渲染的数带着「有出处」的标记,一个假的 0 比模型
自己编的数还难发现。
"""
# 没写小数位时最多留 10 位再去掉尾零:0.1 + 0.2 显示 0.3,而不是 0.30000000000000004
exp = -(10 if places is None else places)
ctx = _ctx(d, -exp)
q = d.quantize(Decimal(1).scaleb(exp), context=ctx)
if q.is_zero() and not d.is_zero():
raise RenderError(zero or f"{_shown(d)} 按 {-exp} 位小数显示为 0,看不出真实的值")
if places is None or strip:
q = q.normalize(context=ctx)
if q.is_zero():
q = q.copy_abs() # 不显示 -0.0
return format(q, ",f" if group else "f")
def _clean(text: str) -> str:
"""文本值进正文前压成一行:换行会被当成新的段落或列表。"""
return re.sub(r"\s+", " ", text).strip()
def render_number(
value: Any,
*,
unit: str = "",
decimals: int | None = None,
fmt: str | None = None,
conv: str | None = None,
) -> str:
"""按口径卡的定义把一个值渲染成字。
- format:thousands(默认,千分位)/ plain(不分组,年份、编号这类)/
percent_of_ratio(值是比率 0.0235,显示成 2.35%,小数位按 decimals − 2)
- decimals:固定小数位;不写就按值本来的样子(整数不带小数点)
- 单位直接接在数字后面:45,678.5元、8.7%
- conv 是报告里写的换算:万 / 亿(保留两位、去尾零,单位接在后面:4.57万元)、
pct(比率 ×100 加 %)、int(取整)、.N(N 位小数)
值是 None 显示「—」。文本值原样(压成一行),不能换算。
不是 0 的值显示出来成了 0(换算、取整、小数位不够),抛 RenderError——报告里这个
引用就算解析不了,写作者会被要求换一种写法,而不是给读者一个带出处的假 0。
"""
fmt = fmt or DEFAULT_FORMAT
if fmt not in FORMATS:
raise RenderError(f"format 只能是 {' / '.join(FORMATS)},写的是 {fmt!r}")
if value is None:
return MISSING
if not is_number(value):
if conv:
raise RenderError(f"「{_clean(str(value))[:40]}」不是数,不能换算成 {conv}")
if isinstance(value, bool):
return "是" if value else "否"
return _clean(str(value))
if decimals is not None and (isinstance(decimals, bool) or not isinstance(decimals, int)
or not -MAX_PLACES <= decimals <= MAX_PLACES):
raise RenderError(f"小数位要是 -{MAX_PLACES} 到 {MAX_PLACES} 的整数,写的是 {decimals!r}")
try:
return _render_numeric(_decimal(value), unit=unit, decimals=decimals, fmt=fmt, conv=conv)
except RenderError:
raise
except (ArithmeticError, ValueError) as e: # decimal 的 InvalidOperation 也在这里
raise RenderError(f"{value!r} 按规则渲染不出来({type(e).__name__})") from e
def _render_numeric(d: Decimal, *, unit: str, decimals: int | None, fmt: str, conv: str | None) -> str:
if d.adjusted() >= _MAX_DIGITS:
raise RenderError(f"这个数有 {d.adjusted() + 1} 位,太大了,没法按规则显示")
ratio = fmt == "percent_of_ratio"
group = fmt != "plain"
shown = _shown(d)
if conv in _CONV_SCALE:
if ratio:
raise RenderError(f"这个指标是比率,不能换算成 {conv}")
scaled = _ctx(d).divide(d, _CONV_SCALE[conv])
return _digits(scaled, 2, group=True, strip=True,
zero=f"{shown} 换算成「{conv}」后显示为 0,精度不够:去掉换算,或者换小一级的单位"
) + conv + unit
rounded = f"{shown} 按 |{conv} 显示为 0,精度不够:去掉换算,或者换成 |.N 多留几位小数"
if conv == "pct":
if "%" in unit or "%" in unit:
raise RenderError("这个指标本身就是百分数(单位是 %),不能再换算成 pct")
places = max(decimals - 2, 0) if decimals is not None else 1
return _digits(_ctx(d).multiply(d, 100), places, group=group, zero=rounded) + "%"
if conv == "int":
places: int | None = 0
elif conv and (m := _CONV_PLACES.match(conv)):
places = int(m.group(1))
elif conv:
raise RenderError(f"不认识的换算 |{conv},只支持 万 / 亿 / pct / int / .N(N 为 0–6)")
else:
rounded = (f"{shown} 按口径卡的小数位({decimals})显示为 0,精度不够:口径卡要多留几位小数"
if decimals is not None else
f"{shown} 太小:不写小数位时最多留 10 位小数,显示为 0。口径卡要写 decimals")
places = (max(decimals - 2, 0) if ratio else decimals) if decimals is not None else None
if ratio:
return _digits(_ctx(d).multiply(d, 100), places, group=group, zero=rounded) + "%"
return _digits(d, places, group=group, zero=rounded) + unit
def render_metric(metric: dict[str, Any], conv: str | None = None) -> str:
"""口径卡里的一个指标按它自己的格式渲染。"""
return render_number(
metric.get("value"),
unit=str(metric.get("unit") or ""),
decimals=metric.get("decimals"),
fmt=metric.get("format"),
conv=conv,
)
def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None) -> str:
"""查询快照里的一格渲染成字。
- 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);DECIMAL 落盘后的数字文本
("45678.50",小数位为 0 的 "45678",规则见 expressions.cell_value)按数算,用 Decimal
渲染,不经 float 丢精度
- kind 是这一列查询时记下的类型:text 列按文本,VARCHAR 的 "2026" 不渲染成「2,026」;
老快照没记类型就按值猜
- NULL 显示「—」:这是快照里的事实(那一格就是空的),不是缺值,更不是 0
- 文本压成一行,竖线换成 ¦:它常常落在表格里,原样的 | 会多切出一格
"""
if isinstance(value, str) and cell_value(value, kind) is not value:
try:
return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=DEFAULT_FORMAT, conv=conv)
except RenderError:
raise
except (ArithmeticError, ValueError) as e:
raise RenderError(f"{value!r} 按规则渲染不出来({type(e).__name__})") from e
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False, default=str)
return render_number(value, fmt=DEFAULT_FORMAT, conv=conv).replace("|", "¦")
# --------------------------------------------------------------------------
# 标记语法
# --------------------------------------------------------------------------
#: 流式渲染时,一个 [[ 之后攒多少字还没闭合,就认定它不是标记、原样放出去
_HOLD_LIMIT = 160
#: 一个标记(从 [[ 到 ]],含两头)最长多少字。整篇解析和流式渲染用同一个上限:
#: 超长的两边都当普通文字,流里看到的才会和最终正文一模一样。标记本身最长也就几十个字
MARKER_MAX = _HOLD_LIMIT + 1
#: 紧跟在 [[ 后面:MARKER_MAX 以内就能碰到 ]](中间不许有方括号和换行)
_MARKER_GUARD = rf"(?=[^\[\]\n]{{0,{MARKER_MAX - 4}}}\]\])"
#: [[kind:body]]。body 里不许有方括号和换行——这样一个没闭合的 [[ 最多吞到行尾
MARKER_RE = re.compile(
r"\[\[" + _MARKER_GUARD + r"[ \t]*(?P<kind>[A-Za-z]+)[ \t]*:(?P<body>[^\[\]\n]*?)\]\]")
SUPPORTED_KINDS = frozenset({"m", "i", "see", "v", "table"})
LATER_KINDS = frozenset({"t", "c", "q"})
LATER_REASON = "这种引用在后续版本支持"
#: 沙箱代码节点的产出不能直接引用:它能做任意计算,那种数要进口径卡、留下代入式
CODE_REASON = "沙箱算出来的数要进口径卡"
#: 受管级别的正式出具,契约里没写 "cells": true 时复核按这个原因拒掉单元格引用
CELLS_REASON = "受管出具要在契约里声明 cells"
NODE_FIELD_REASON = "节点字段引用在后续版本支持:数要从查询单元格 [[v:Q1.r0.列]] 或口径卡指标 [[m:…]] 来"
#: 整表:不写 rows 时取前几行、最多几行、不写 cols 时最多几列
TABLE_DEFAULT_ROWS = 5
TABLE_MAX_ROWS = 20
TABLE_MAX_COLS = 12
_ROLE = {"t": "entity", "c": "entity", "q": "quote"}
_SEG_KIND = {"t": "entity", "c": "entity", "q": "quote"}
def _parse_body(kind: str, body: str) -> dict[str, Any]:
if kind == "see":
return {"refs": [r.strip() for r in body.split(",") if r.strip()]}
if kind == "table":
head, *rest = body.split() or [""]
return {"ref": head, "options": dict(p.split("=", 1) for p in rest if "=" in p)}
ref, _, tail = body.partition("|")
if kind == "q":
return {"ref": ref.strip(), "quote": tail.strip()}
return {"ref": ref.strip(), "conv": tail.strip() or None}
def parse_markers(text: str) -> list[dict[str, Any]]:
"""文本里所有的引用标记,按出现顺序:{kind, body, raw, start, end, ref / conv / refs / …}。"""
out: list[dict[str, Any]] = []
for m in MARKER_RE.finditer(text):
kind, body = m.group("kind"), m.group("body").strip()
out.append({"kind": kind, "body": body, "raw": m.group(0), "start": m.start(), "end": m.end(),
**_parse_body(kind, body)})
return out
def _parse_ref(ref: str) -> dict[str, Any]:
"""片段上记的 ref(m:gmv|万)还原成标记的解析结果。"""
kind, _, body = ref.partition(":")
kind, body = kind.strip(), body.strip()
return {"kind": kind, "body": body, **_parse_body(kind, body)}
def placeholder(kind: str, body: str) -> str:
"""解析不了的引用在正文里的占位:⟦?m:gmvx⟧。流里和最终文档里是同一个样子。"""
return f"⟦?{kind}:{body}⟧"
# --------------------------------------------------------------------------
# 证据目录:alias → 条目
# --------------------------------------------------------------------------
def _metric_sources(
nodes: dict[str, Any], ledger: list[dict[str, Any]], metrics_from: list[str] | None,
allowed: set[str] | None,
) -> list[tuple[str, dict[str, Any], str | None]]:
latest: dict[str, str] = {}
order: list[str] = []
for entry in ledger:
if isinstance(entry, dict) and entry.get("kind") == "metric_set" and entry.get("node_id"):
order.append(entry["node_id"])
if entry.get("artifact"):
latest[entry["node_id"]] = entry["artifact"]
# 台账里没有、节点产出里有的:旧 checkpoint 续跑时台账是空的,口径卡的产出还在
order.extend(nid for nid, p in nodes.items() if isinstance(p, dict) and p.get("kind") == "metric_set")
if metrics_from is not None:
order = list(metrics_from)
out = []
for nid in dict.fromkeys(order):
payload = nodes.get(nid)
if allowed is not None and nid not in allowed:
continue
if isinstance(payload, dict) and payload.get("kind") == "metric_set":
out.append((nid, payload, payload.get("artifact") or latest.get(nid)))
return out
def build_catalog(
*,
nodes: dict[str, Any],
ledger: list[dict[str, Any]] | None = None,
inputs: dict[str, Any] | None = None,
metrics_from: list[str] | str | None = None,
allowed: set[str] | frozenset[str] | list[str] | None = None,
) -> dict[str, dict[str, Any]]:
"""从证据台账、节点产出、运行输入建目录。
- nodes:state["nodes"],口径卡的指标值从这里取(和台账里的工件是同一份内容)
- ledger:state["evidence"],决定顺序、提供工件 id;查询、检索按台账顺序编 Q1… / K1…
- metrics_from:只收这几张口径卡;不传(None、空列表、空字符串)就收全部——和
validate_graph 的理解一致,那边空列表就是「所有上游口径卡」
- allowed:只收这些节点的证据(报告节点传它的祖先),不传不限
同一个指标 id 出现在两张卡里时不登记 m:<id>,只登记 m:<卡的节点 id>.<id>——
按先来后到默认给一个,正是旧契约错配的那种错。
沙箱代码节点的台账条目登记成 N:<节点 id>(kind node_output,code: True):不编号、不进
写作目录,只为了有人写 [[v:N:calc.x]] 时能说清为什么不行。
"""
if not metrics_from:
metrics_from = None
elif isinstance(metrics_from, str):
metrics_from = [metrics_from]
allow = set(allowed) if allowed is not None else None
entries = [e for e in (ledger or []) if isinstance(e, dict)]
catalog: dict[str, dict[str, Any]] = {}
sources = _metric_sources(nodes, entries, metrics_from, allow)
seen: dict[str, int] = {}
for _, payload, _ in sources:
for metric in payload.get("metrics") or []:
mid = str(metric.get("id") or "")
seen[mid] = seen.get(mid, 0) + 1
for node_id, payload, artifact in sources:
for metric in payload.get("metrics") or []:
mid = str(metric.get("id") or "")
if not mid:
continue
alias = f"m:{mid}" if seen[mid] == 1 else f"m:{node_id}.{mid}"
if alias in catalog:
continue
try:
rendered = render_metric(metric)
except RenderError:
rendered = MISSING
locator = {"metric": mid}
catalog[alias] = {
"alias": alias, "kind": "metric", "eid": make_eid("metric", artifact, locator),
"node_id": node_id, "artifact": artifact, "locator": locator,
"caliber": payload.get("caliber") or "", "version": payload.get("caliber_version") or "",
"name": metric.get("name") or mid, "unit": metric.get("unit") or "",
"decimals": metric.get("decimals"), "format": metric.get("format") or DEFAULT_FORMAT,
"value": metric.get("value"),
"status": metric.get("status") or ("ok" if metric.get("value") is not None else "missing_input"),
"rendered": rendered, "label": f"{metric.get('name') or mid} = {rendered}",
}
queries = retrievals = 0
for entry in entries:
if allow is not None and entry.get("node_id") not in allow:
continue
rows = entry.get("rows")
if entry.get("kind") == "node_output" and entry.get("code_sha"):
# 沙箱代码节点:登记下来只为了引用它时能说清为什么不行,不编号、不进写作目录
node_id = str(entry.get("node_id") or "")
catalog[f"N:{node_id}"] = {
"alias": f"N:{node_id}", "kind": "node_output", "eid": make_eid("node_output", entry.get("artifact"), {}),
"locator": {}, "code": True,
**{k: entry[k] for k in ("node_id", "exec", "artifact", "code_sha", "role", "language") if k in entry},
"label": f"沙箱代码 {node_id}({entry.get('role') or 'compute'})",
}
continue
if entry.get("kind") == "query":
queries += 1
alias, kind = f"Q{queries}", "query"
label = f"{entry.get('tool') or entry.get('source') or kind}" + (f" · {rows} 行" if isinstance(rows, int) else "")
elif entry.get("kind") == "retrieval":
retrievals += 1
alias, kind = f"K{retrievals}", "retrieval"
label = (f"知识库「{entry['source']}」" if entry.get("source") else "知识库检索") \
+ (f" · {rows} 条" if isinstance(rows, int) else "")
else:
continue
catalog[alias] = {
"alias": alias, "kind": kind, "eid": make_eid(kind, entry.get("artifact"), {}),
"locator": {},
**{k: entry[k] for k in ("node_id", "exec", "artifact", "via", "call_id", "tool", "source",
"columns", "rows", "truncated") if k in entry},
"label": label,
}
for key, value in (inputs or {}).items():
if not isinstance(key, str) or not key or isinstance(value, (dict, list, tuple)):
continue
locator = {"field": key}
rendered = render_number(value, fmt="plain") if value not in (None, "") else MISSING
catalog[f"i:{key}"] = {
"alias": f"i:{key}", "kind": "input", "eid": input_eid(key, value),
"locator": locator, "value": value, "rendered": rendered, "label": f"{key} = {rendered}",
}
return catalog
# --------------------------------------------------------------------------
# 解析一个引用
# --------------------------------------------------------------------------
def _unresolved(marker: dict[str, Any], alias: str, ev_kind: str, reason: str, *,
rendered: str | None = None) -> dict[str, Any]:
"""解析不了的引用。rendered 是正文里显示的字:缺值显示「—」,其余是占位 ⟦?m:gmvx⟧。"""
kind, body = marker["kind"], marker.get("body", "")
return {"ref": marker.get("ref") or "", "alias": alias, "kind": ev_kind,
"role": _ROLE.get(kind, "value"), "status": "unresolved", "reason": reason,
"rendered": rendered if rendered is not None else placeholder(kind, body)}
def _metric_alias(ref: str, catalog: dict[str, Any]) -> tuple[str | None, str]:
alias = f"m:{ref}"
if alias in catalog:
return alias, ""
doubles = sorted(a for a in catalog if a.startswith("m:") and a.endswith(f".{ref}"))
if doubles:
return None, (f"指标 {ref} 同时出现在好几张口径卡里,要写成带卡名的形式:"
+ "、".join(f"[[{a}]]" for a in doubles[:4]))
return None, f"目录里没有指标 {ref}(能用的指标见证据目录)"
# --------------------------------------------------------------------------
# 单元格与整表:值一律从查询快照取
# --------------------------------------------------------------------------
#: 取快照的函数:工件 id → 内容。取不到返回 None,哈希对不上抛 ValueError(artifact_store.load 的约定)
Loader = Callable[[str], Any]
class _Snapshots:
"""这一次解析里取过的查询快照。
一张整表每一格都要读同一份快照,每格都读盘、复验一次哈希不值。缓存只活在这一次
compose / verify / 流式渲染里:出口复核是另一次调用,照样重新取、重新验。
"""
def __init__(self, loader: Loader | None = None) -> None:
self.loader = loader or load_artifact
self.memo: dict[str, tuple[Any, str | None]] = {}
def get(self, alias: str, artifact: Any) -> tuple[Any, str | None]:
"""(快照内容, 取不到的原因)。原因以 alias 开头,能直接当 unresolved 的 reason。"""
key = str(artifact or "")
if key not in self.memo:
self.memo[key] = self._fetch(key)
content, why = self.memo[key]
return content, (f"{alias} {why}" if why else None)
def _fetch(self, artifact: str) -> tuple[Any, str | None]:
if not artifact:
return None, "没有查询快照"
try:
content = self.loader(artifact)
except ValueError:
return None, "的查询快照和哈希对不上,疑似被改过"
except Exception as e: # noqa: BLE001 - 取不回来就是解析不了,不能让整篇报告崩掉
return None, f"的查询快照取不回来({type(e).__name__})"
if content is None:
return None, f"的查询快照取不回来(工件 {artifact[:12]}… 不存在)"
if not isinstance(content, dict) or not isinstance(content.get("rows"), list):
return None, "的快照里没有表格数据"
return content, None
def _snapshots(loader: Loader | _Snapshots | None) -> _Snapshots:
return loader if isinstance(loader, _Snapshots) else _Snapshots(loader)
_CELL_REF = re.compile(r"^(?P<alias>[QK]\d+)\.r(?P<row>\d+)\.(?P<column>.+)$")
_NODE_REF = re.compile(r"^N:(?P<node>[^.\s]+)(?:\.(?P<field>.+))?$")
def parse_cell_ref(ref: str) -> dict[str, Any] | None:
"""Q4.r0.amount → {alias: "Q4", row: 0, column: "amount"};写法不对返回 None。"""
m = _CELL_REF.match((ref or "").strip())
return {"alias": m.group("alias"), "row": int(m.group("row")), "column": m.group("column")} if m else None
def _query_entry(alias: str, catalog: dict[str, Any]) -> tuple[dict[str, Any] | None, str]:
entry = catalog.get(alias)
if entry is None:
return None, f"目录里没有 {alias}(能引用的查询见证据目录)"
if entry.get("kind") != "query":
return None, f"{alias} 不是查询结果,不能按单元格引用"
if entry.get("code"):
return None, CODE_REASON
return entry, ""
def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
cells_allowed: bool) -> dict[str, Any]:
ref = marker.get("ref") or ""
conv = marker.get("conv")
node = _NODE_REF.match(ref)
if node:
alias = f"N:{node.group('node')}"
entry = catalog.get(alias) or {}
return _unresolved(marker, alias, "node_field", CODE_REASON if entry.get("code") else NODE_FIELD_REASON)
parsed = parse_cell_ref(ref)
if parsed is None:
return _unresolved(marker, ref.split(".")[0], "cell",
"单元格要写成 Q<编号>.r<行>.<列>(行号从 0 数),比如 [[v:Q1.r0.amount]]")
alias = parsed["alias"]
if not cells_allowed:
return _unresolved(marker, alias, "cell", CELLS_REASON)
entry, why = _query_entry(alias, catalog)
if entry is None:
return _unresolved(marker, alias, "cell", why)
snapshot, why = snaps.get(alias, entry.get("artifact"))
if why:
return _unresolved(marker, alias, "cell", why)
try:
raw, column = locate_cell(snapshot, parsed["row"], parsed["column"])
kind = column_kind(snapshot, column)
rendered = render_cell(raw, conv, kind=kind)
except CellError as e:
return _unresolved(marker, alias, "cell", f"{alias} {e}")
except RenderError as e:
return _unresolved(marker, alias, "cell", str(e))
locator = {"row": parsed["row"], "column": column}
cite = {"ref": ref, "alias": alias, "locator": locator, "eid": cell_eid(entry.get("artifact"), **locator),
"kind": "cell", "role": "value", "status": "resolved", "value": cell_value(raw, kind),
"rendered": rendered}
if conv:
cite["conv"] = conv
return cite
_TABLE_OPTIONS = ("cols", "rows")
_TABLE_ROWS = re.compile(r"^(\d+)(?:-(\d+))?$")
#: 列名里有这些字就写不进 [[v:]] 标记,或者会把表格行切错
_UNSAFE_COLUMN = re.compile(r"[\[\]\n|]")
def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
cells_allowed: bool) -> tuple[str | None, dict[str, Any] | None]:
"""整表标记 → (Markdown 表,每格是一个 [[v:]] 标记, None),或者 (None, 解析不了的 Citation)。
表由系统生成,但每一格照样是单元格引用:切块、切片段、复核都走单元格那条路,
不用为整表另写一套核对规则。
"""
alias = marker.get("ref") or ""
def fail(reason: str) -> tuple[None, dict[str, Any]]:
return None, _unresolved(marker, alias, "query", reason)
if not cells_allowed:
return fail(CELLS_REASON)
_, *rest = (marker.get("body") or "").split() or [""]
odd = [p for p in rest if p.partition("=")[0] not in _TABLE_OPTIONS or "=" not in p]
if odd:
return fail(f"不认识的选项 {odd[0]}:整表只支持 cols=列a,列b 和 rows=0-4")
options = marker.get("options") or {}
entry, why = _query_entry(alias, catalog)
if entry is None:
return fail(why)
snapshot, why = snaps.get(alias, entry.get("artifact"))
if why:
return fail(why)
columns = [str(c) for c in snapshot.get("columns") or []]
rows = snapshot["rows"]
if "cols" in options:
wanted = [c.strip() for c in options["cols"].split(",") if c.strip()]
if not wanted:
return fail("cols= 后面要写列名,用逗号分开")
missing = [c for c in wanted if c not in columns]
if missing:
return fail(f"{alias} 没有列「{missing[0]}」,有:{'、'.join(columns[:12])}")
if len(wanted) > TABLE_MAX_COLS:
return fail(f"整表最多 {TABLE_MAX_COLS} 列,cols= 写了 {len(wanted)} 列")
else:
wanted = columns
if len(wanted) > TABLE_MAX_COLS:
return fail(f"{alias} 有 {len(columns)} 列,整表最多 {TABLE_MAX_COLS} 列:用 cols=列a,列b 挑出要的列")
if not wanted:
return fail(f"{alias} 没有列,列不出表")
if not rows:
return fail(f"{alias} 是空的(0 行),没有可以列的行")
if "rows" in options:
span = _TABLE_ROWS.match(options["rows"].strip())
first, last = (int(span.group(1)), int(span.group(2) or span.group(1))) if span else (1, 0)
if last < first:
return fail(f"rows 要写成 0-4 这样从小到大的行号范围(从 0 数),写的是 {options['rows']}")
if last - first + 1 > TABLE_MAX_ROWS:
return fail(f"整表最多 {TABLE_MAX_ROWS} 行,rows={options['rows']} 是 {last - first + 1} 行:"
"挑出要的行,或者分成几张表")
if last >= len(rows):
return fail(f"{alias} 只有 {len(rows)} 行,没有第 {last} 行(从 0 数)")
else:
first, last = 0, min(TABLE_DEFAULT_ROWS, len(rows)) - 1
for c in wanted:
if not c.strip() or c != c.strip() or _UNSAFE_COLUMN.search(c) \
or len(f"[[v:{alias}.r{last}.{c}]]") > MARKER_MAX:
return fail(f"列名「{_clean(c)[:30]}」写不进表格(空的、首尾有空格、带方括号竖线换行,或者太长):"
"在 SQL 里给它起个别名再查")
lines = ["| " + " | ".join(_clean(c) for c in wanted) + " |",
"| " + " | ".join("---" for _ in wanted) + " |"]
lines += ["| " + " | ".join(f"[[v:{alias}.r{r}.{c}]]" for c in wanted) + " |" for r in range(first, last + 1)]
return "\n".join(lines), None
def _expand_tables(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
prev: str = "") -> str:
"""把能展开的整表标记换成 Markdown 表(每格一个 [[v:]])。展开不了的原样留着,后面按解析不了处理。
表格要独占几行:标记前面的字不是换行就补一个,后面的字不是换行也补一个。这只取决于
紧挨着标记的那两个字——prev 是这段文本之前的那个字(流式渲染时在上一块里)。
"""
out: list[str] = []
last = 0
for m in MARKER_RE.finditer(text):
if m.group("kind") != "table":
continue
body = m.group("body").strip()
table, _ = _table_plan({"kind": "table", "body": body, **_parse_body("table", body)}, catalog, snaps,
cells_allowed)
if table is None:
continue
before = text[m.start() - 1] if m.start() > 0 else prev
after = text[m.end()] if m.end() < len(text) else ""
out.append(text[last:m.start()])
out.append(("" if before in ("", "\n") else "\n") + table + ("" if after in ("", "\n") else "\n"))
last = m.end()
if not out:
return text
out.append(text[last:])
return "".join(out)
# --------------------------------------------------------------------------
# 解析一个行内标记
# --------------------------------------------------------------------------
def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_allowed: bool = True,
loader: Loader | _Snapshots | None = None) -> dict[str, Any]:
"""行内标记(m / i / v / t / c / q / table)→ Citation,带 rendered(正文里显示的字)。
cells_allowed=False 时单元格和整表一律解析不了,原因是 CELLS_REASON(受管出具没在契约里声明 cells)。
loader 是取查询快照的函数,缺省 artifact_store.load。
"""
kind = marker["kind"]
ref = marker.get("ref") or ""
if kind == "v":
return _resolve_cell(marker, catalog, _snapshots(loader), cells_allowed)
if kind == "table":
snaps = _snapshots(loader)
table, bad = _table_plan(marker, catalog, snaps, cells_allowed)
if bad is not None:
return bad
# 整篇渲染时整表已经按上下文展开过了,走到这里的是复核一个片段:给出不带上下文的样子
return {"ref": ref, "alias": ref, "locator": {}, "eid": catalog[ref]["eid"], "kind": "query",
"role": "value", "status": "resolved", "rendered": _render(table or "", catalog, snaps, cells_allowed)}
if kind in LATER_KINDS:
alias = {"t": f"t:{ref}", "c": f"c:{ref}"}.get(kind, ref.split(".")[0])
ev_kind = {"t": "table", "c": "column", "q": "quote"}[kind]
return _unresolved(marker, alias, ev_kind, LATER_REASON)
if kind not in ("m", "i"):
return _unresolved(marker, f"{kind}:{ref}", kind,
f"不认识的引用类型 {kind}:只支持 m(口径卡指标)、v(查询单元格)、table(整表)、"
"i(运行输入)、see(依据)")
conv = marker.get("conv")
if kind == "m":
ev_kind = "metric"
alias, why = _metric_alias(ref, catalog)
if alias is None:
return _unresolved(marker, f"m:{ref}", ev_kind, why)
entry = catalog[alias]
if entry.get("value") is None:
return _unresolved(marker, alias, ev_kind, f"指标「{entry.get('name') or ref}」这次没有值(缺输入),"
"不能写进报告", rendered=MISSING)
try:
rendered = render_metric(entry, conv)
except RenderError as e:
return _unresolved(marker, alias, ev_kind, str(e))
else:
ev_kind, alias = "input", f"i:{ref}"
entry = catalog.get(alias)
if entry is None:
return _unresolved(marker, alias, ev_kind, f"运行输入里没有 {ref}")
if entry.get("value") in (None, ""):
return _unresolved(marker, alias, ev_kind, f"运行输入 {ref} 是空的", rendered=MISSING)
try:
rendered = render_number(entry["value"], fmt="plain", conv=conv)
except RenderError as e:
return _unresolved(marker, alias, ev_kind, str(e))
cite = {"ref": ref, "alias": alias, "locator": dict(entry.get("locator") or {}), "eid": entry["eid"],
"kind": ev_kind, "role": "value", "status": "resolved", "value": entry.get("value"),
"rendered": rendered}
if conv:
cite["conv"] = conv
return cite
_ROW_REF = re.compile(r"^(?P<alias>[QK]\d+)(?:\.r(?P<row>\d+))?$")
def resolve_support(ref: str, catalog: dict[str, Any]) -> dict[str, Any]:
"""[[see:]] 里的一个依据:m:gmv、i:week、Q4、Q4.r0、K2。只核对「这件证据存在」。"""
ref = ref.strip()
kind, sep, body = ref.partition(":")
base = {"ref": ref, "role": "support"}
if sep and kind in LATER_KINDS:
return {**base, "alias": ref, "kind": kind, "status": "unresolved", "reason": LATER_REASON}
alias: str | None = None
locator: dict[str, Any] = {}
why = f"目录里没有 {ref}"
if sep and kind == "m":
alias, why = _metric_alias(body, catalog)
elif sep and kind == "i":
alias = ref if ref in catalog else None
why = f"运行输入里没有 {body}"
elif sep:
why = f"不认识的依据写法 {ref}:写 m:指标、i:输入,或者 Q1 / K1 这样的编号"
elif m := _ROW_REF.match(ref):
alias = m.group("alias") if m.group("alias") in catalog else None
if alias and m.group("row") is not None:
row, total = int(m.group("row")), catalog[alias].get("rows")
if isinstance(total, int) and row >= total:
alias, why = None, f"{m.group('alias')} 只有 {total} 行,没有第 {row} 行(从 0 数)"
else:
locator = {"row": row}
elif f"m:{ref}" in catalog:
alias = f"m:{ref}" # 漏写了 m: 前缀的指标 id,意思没有歧义
if alias is None:
return {**base, "alias": ref, "kind": kind if sep else "unknown", "status": "unresolved",
"reason": why}
entry = catalog[alias]
return {**base, "alias": alias, "kind": entry["kind"], "status": "resolved", "eid": entry["eid"],
"locator": {**(entry.get("locator") or {}), **locator}}
def resolve_ref(ref: str, catalog: dict[str, Any], *, cells_allowed: bool = True,
loader: Loader | _Snapshots | None = None) -> dict[str, Any]:
"""片段上记的 ref(m:gmv|万、i:week、v:Q1.r0.amount)→ Citation。证据接口点开片段时用它重新解析。"""
return resolve_marker(_parse_ref(ref), catalog, cells_allowed=cells_allowed, loader=loader)
def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str:
text = _expand_tables(text, catalog, snaps, cells_allowed, prev)
out: list[str] = []
last = 0
for marker in parse_markers(text):
out.append(text[last:marker["start"]])
if marker["kind"] != "see":
out.append(resolve_marker(marker, catalog, cells_allowed=cells_allowed, loader=snaps)["rendered"])
last = marker["end"]
out.append(text[last:])
return "".join(out)
def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool = True,
loader: Loader | None = None) -> str:
"""把一段文本里的标记全部换成字:m / i / v 渲染成值,整表展开成表,see 去掉,解析不了的换成占位。"""
return _render(text, catalog, _snapshots(loader), cells_allowed)
# --------------------------------------------------------------------------
# 流式渲染:让 llm.token 里出现的就是最终的数字
# --------------------------------------------------------------------------
def _safe_cut(text: str) -> int:
"""能放出去的前缀有多长:最后一个还可能长成标记的 [[ 之前都能放。
一个标记最长 MARKER_MAX 个字,所以没闭合的时候最多攒 MARKER_MAX - 1 = _HOLD_LIMIT 个字;
超过了、或者碰到换行,它就不可能再是标记。末尾单独一个 [ 也要留着——下一块的
开头可能是另一个 [。
整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。
"""
cut = len(text)
start = text.rfind("[[")
if start != -1 and text.find("]]", start) == -1:
tail = text[start:]
if len(tail) <= _HOLD_LIMIT and "\n" not in tail:
cut = start
if cut == len(text) and text.endswith("["):
cut = len(text) - 1
while (start := text.rfind("[[", 0, cut)) != -1:
m = MARKER_RE.match(text, start)
if not (m and m.end() == cut and m.group("kind") == "table"):
break
cut = start
return cut
class StreamRenderer:
"""边收模型的 token 边渲染:缓冲到没有未闭合的 [[ 为止,再把这一段里的标记换成字。
renderer = StreamRenderer(catalog)
async for chunk in model.astream(messages):
if text := renderer.feed(message_text(chunk)):
ctx.emit(EventType.LLM_TOKEN, delta=text)
if tail := renderer.flush():
ctx.emit(EventType.LLM_TOKEN, delta=tail)
所有 feed / flush 的输出拼起来,等于 render_markers(全文),不论模型怎么分块:
整篇解析也只认 MARKER_MAX 以内的标记,超长的两边都原样当文字;整表展开要看的
前一个字记在 prev 里,后一个字靠 _safe_cut 等它到了再放。
"""
def __init__(self, catalog: dict[str, Any], *, cells_allowed: bool = True, loader: Loader | None = None) -> None:
self.catalog = catalog
self.cells_allowed = cells_allowed
self.snaps = _Snapshots(loader)
self.pending = ""
self.prev = ""
def _release(self, ready: str) -> str:
if not ready:
return ""
out = _render(ready, self.catalog, self.snaps, self.cells_allowed, self.prev)
self.prev = ready[-1]
return out
def feed(self, delta: str) -> str:
self.pending += delta or ""
cut = _safe_cut(self.pending)
ready, self.pending = self.pending[:cut], self.pending[cut:]
return self._release(ready)
def flush(self) -> str:
ready, self.pending = self.pending, ""
return self._release(ready)
# --------------------------------------------------------------------------
# 切块:和前端 Markdown.tsx 认同一套块语法(标题、段落、列表、表格、引用、代码、分隔线)
#
# 在模型的原文(带标记)上切,而不是在渲染后的正文上切:一个指标值渲染出来恰好是
# 「3. 」开头,不能因此把一句话当成有序列表。
# --------------------------------------------------------------------------
_FENCE = re.compile(r"^\s*```([A-Za-z0-9_]*)\s*$")
_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
_HR = re.compile(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$")
_BULLET = re.compile(r"^(\s*)[-*+]\s+(.*)$")
_ORDERED = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
_QUOTE = re.compile(r"^(\s*>\s?)(.*)$")
_TABLE_ROW = re.compile(r"^\s*\|(.+)\|\s*$")
_TABLE_SEP = re.compile(r"^\s*\|[\s:|-]+\|\s*$")
def _is_block_start(line: str) -> bool:
return bool(_FENCE.match(line) or _HR.match(line) or _HEADING.match(line) or _BULLET.match(line)
or _ORDERED.match(line) or _QUOTE.match(line) or _TABLE_ROW.match(line))
class _Item:
"""一个将来的 unit(或者一段要再切成句子的内容):原文上的若干片 (s|c, start, end)。"""
__slots__ = ("pieces", "pos", "split", "kind", "loc", "depth")
def __init__(self, pieces: list[tuple[str, int, int]], pos: int, *, split: bool = False,
kind: str | None = None, loc: dict[str, int] | None = None, depth: int | None = None):
self.pieces, self.pos, self.split, self.kind = pieces, pos, split, kind
self.loc, self.depth = loc, depth
class _Block:
__slots__ = ("type", "meta", "items")
def __init__(self, type_: str, meta: dict[str, Any], items: list[_Item]):
self.type, self.meta, self.items = type_, meta, items
def _scan_blocks(text: str, protected: list[tuple[int, int]]) -> list[_Block]:
lines = text.split("\n")
starts: list[int] = []
acc = 0
for line in lines:
starts.append(acc)
acc += len(line) + 1
def end(i: int) -> int:
return starts[i] + len(lines[i])
blocks: list[_Block] = []
i, n = 0, len(lines)
while i < n:
line = lines[i]
if not line.strip():
i += 1
continue
fence = _FENCE.match(line)
if fence:
j = i + 1
while j < n and not _FENCE.match(lines[j]):
j += 1
body = [("c", starts[i + 1], end(j - 1))] if j > i + 1 else []
last = min(j, n - 1)
blocks.append(_Block("code", {"lang": fence.group(1)},
[_Item(body, body[0][1] if body else end(last), kind="code")]))
i = j + 1
continue
if _HR.match(line):
blocks.append(_Block("hr", {}, [_Item([], end(i), kind="connective")]))
i += 1
continue
heading = _HEADING.match(line)
if heading:
cs = starts[i] + heading.start(2)
pieces = [("s", starts[i], cs)] + ([("c", cs, end(i))] if cs < end(i) else [])
blocks.append(_Block("heading", {"level": len(heading.group(1))},
[_Item(pieces, starts[i], kind="heading")]))
i += 1
continue
if _TABLE_ROW.match(line) and i + 1 < n and _TABLE_SEP.match(lines[i + 1]):
items = _table_cells(lines[i], starts[i], -1, protected)
i += 2
row = 0
while i < n and _TABLE_ROW.match(lines[i]):
items.extend(_table_cells(lines[i], starts[i], row, protected))
row += 1
i += 1
blocks.append(_Block("table", {}, items))
continue
if _QUOTE.match(line):
pieces: list[tuple[str, int, int]] = []
first = i
while i < n and (q := _QUOTE.match(lines[i])):
if i > first:
pieces.append(("c", starts[i] - 1, starts[i])) # 行与行之间的换行属于正文
cs = starts[i] + len(q.group(1))
pieces.append(("s", starts[i], cs))
if cs < end(i):
pieces.append(("c", cs, end(i)))
i += 1
blocks.append(_Block("quote", {}, [_Item(pieces, starts[first], split=True)]))
continue
bullet, ordered = _BULLET.match(line), _ORDERED.match(line)
if bullet or ordered:
items = []
meta: dict[str, Any] = {"ordered": bool(ordered)}
if ordered:
meta["start"] = int(ordered.group(2))
while i < n:
b = _BULLET.match(lines[i])
o = None if b else _ORDERED.match(lines[i])
if b or o:
m = b or o
cs = starts[i] + m.start(2 if b else 3)
pieces = [("s", starts[i], cs)] + ([("c", cs, end(i))] if cs < end(i) else [])
items.append(_Item(pieces, starts[i],
depth=len(m.group(1).replace("\t", " ")) // 2))
i += 1
elif lines[i].strip() and not _is_block_start(lines[i]) and items:
# 悬挂缩进的续行接到上一项:换行算正文,行首缩进算结构(Markdown.tsx 会 trim 掉)
lead = len(lines[i]) - len(lines[i].lstrip())
stop = len(lines[i].rstrip())
items[-1].pieces.append(("c", starts[i] - 1, starts[i]))
if lead:
items[-1].pieces.append(("s", starts[i], starts[i] + lead))
items[-1].pieces.append(("c", starts[i] + lead, starts[i] + stop))
i += 1
else:
break
blocks.append(_Block("list", meta, items))
continue
first = i
i += 1
while i < n and lines[i].strip() and not _is_block_start(lines[i]):
i += 1
blocks.append(_Block("paragraph", {}, [_Item([("c", starts[first], end(i - 1))], starts[first],
split=True)]))
return blocks
def _table_cells(line: str, base: int, row: int, protected: list[tuple[int, int]]) -> list[_Item]:
"""一行表格拆成格。和 Markdown.tsx 的 splitRow 一样按 | 拆,只是标记里的 |([[m:x|万]])不算。"""
lo = len(line) - len(line.lstrip())
hi = len(line.rstrip())
if line[lo:lo + 1] == "|":
lo += 1
if hi > lo and line[hi - 1] == "|":
hi -= 1
inside = [(s - base, e - base) for s, e in protected if base <= s and e <= base + len(line)]
cuts = [p for p in range(lo, hi) if line[p] == "|" and not any(s <= p < e for s, e in inside)]
items = []
for col, (a, b) in enumerate(zip([lo, *[c + 1 for c in cuts]], [*cuts, hi])):
ca = a + len(line[a:b]) - len(line[a:b].lstrip())
cb = a + len(line[a:b].rstrip())
pieces = [("c", base + ca, base + cb)] if ca < cb else []
items.append(_Item(pieces, base + (ca if ca < cb else a), loc={"row": row, "col": col}))
return items
def _attach_gaps(items: list[_Item], length: int) -> bool:
"""没被任何一项认领的原文(空行、块尾换行、表格分隔行、代码围栏)作为结构片挂到后一项上。
挂好之后,所有项的片按顺序拼起来必须正好铺满原文——这是「片段拼起来等于正文」
的来源。铺不满说明切块有漏洞,返回 False,调用方退回整篇一段。
"""
covered = sorted((s, e) for item in items for _, s, e in item.pieces if e > s)
gaps: list[tuple[int, int]] = []
pos = 0
for s, e in covered:
if s > pos:
gaps.append((pos, s))
pos = max(pos, e)
if pos < length:
gaps.append((pos, length))
if not items:
return not gaps
positions = [item.pos for item in items]
for s, e in gaps:
k = bisect.bisect_left(positions, e)
(items[k] if k < len(items) else items[-1]).pieces.append(("s", s, e))
for item in items:
item.pieces = sorted((p for p in item.pieces if p[2] > p[1]), key=lambda p: p[1])
pos = 0
for item in items:
for _, s, e in item.pieces:
if s != pos:
return False
pos = e
return pos == length
# --------------------------------------------------------------------------
# 切句:段落和引用按句子切成 unit;[[see:]] 挂在它前面那一句
# --------------------------------------------------------------------------
_TERMINATOR = re.compile(r"(?:[。!?!?]+|\.(?=\s|$)|\n)[”’\"'))」』】》]*")
_SEE_TAIL = re.compile(r"(?:[ \t\u3000]*\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*\s*")
#: 和 Markdown.tsx 的 INLINE 同一套:句号落在代码、链接、粗体里面时不切,不然一对 ** 会被拆到两句
_INLINE = re.compile("|".join([
r"(`+)[^`]+?\1",
r"\[[^\]]+\]\([^)\s]+[^)]*\)",
r"\*\*[^*]+?\*\*",
r"__[^_]+?__",
r"(?<!\*)\*[^*\n]+?\*(?!\*)",
r"~~[^~]+?~~",
]))
_ONLY_SEE = re.compile(r"^(?:\s|\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*$")
def _split_sentences(item: _Item, text: str, markers: list[dict[str, Any]]) -> list[list[tuple[str, int, int]]]:
content = [(s, e) for kind, s, e in item.pieces if kind == "c"]
if not item.split or not content:
return [item.pieces]
# 把内容片接成一条虚拟文本 V,记下 V 的每个位置对应原文哪里
v_parts, v_map = [], []
for s, e in content:
v_map.append((sum(len(p) for p in v_parts), s, e))
v_parts.append(text[s:e])
virtual = "".join(v_parts)
def to_raw(v: int) -> int: # V 里第 v 个字符之前的那个边界,落在原文哪里
for v0, s, e in v_map:
if v0 < v <= v0 + (e - s):
return s + (v - v0)
return content[0][0]
def to_v(raw: int) -> int | None:
for v0, s, e in v_map:
if s <= raw <= e:
return v0 + (raw - s)
return None
protected = [(m.start(), m.end()) for m in _INLINE.finditer(virtual)]
for marker in markers:
a, b = to_v(marker["start"]), to_v(marker["end"])
if a is not None and b is not None:
protected.append((a, b))
cuts: list[int] = []
for m in _TERMINATOR.finditer(virtual):
if any(s < m.start() < e or s < m.end() < e for s, e in protected):
continue
cut = _SEE_TAIL.match(virtual, m.end()).end()
if 0 < cut < len(virtual) and not any(s < cut < e for s, e in protected):
cuts.append(cut)
cuts = sorted(set(cuts))
# 只剩空白或者只有 [[see:]] 的一截不单独成句,并进相邻的那一句
kept: list[int] = []
last = 0
for k, cut in enumerate(cuts):
following = cuts[k + 1] if k + 1 < len(cuts) else len(virtual)
if _ONLY_SEE.match(virtual[last:cut]) or _ONLY_SEE.match(virtual[cut:following]):
continue
kept.append(cut)
last = cut
raw_cuts = [to_raw(v) for v in kept]
groups: list[list[tuple[str, int, int]]] = [[]]
queue = list(raw_cuts)
for kind, s, e in item.pieces:
while queue and kind == "c" and s < queue[0] < e:
groups[-1].append((kind, s, queue[0]))
groups.append([])
s = queue.pop(0)
if queue and s >= queue[0]:
groups.append([])
queue.pop(0)
groups[-1].append((kind, s, e))
return [g for g in groups if g]
# --------------------------------------------------------------------------
# 裸数字
# --------------------------------------------------------------------------
_STRUCT_BEFORE = re.compile(r"(?:第|前|后|首|末|近|最近|Top|TOP|top|No\.|NO\.)\s*$")
_STRUCT_AFTER = re.compile(
r"\s*(?:个(?:方面|原因|问题|建议|要点|维度|阶段|步骤|部分|层面|角度|环节|关键|特点|措施)"
r"|点(?:建议|原因|看法)|条建议|步)")
def _structural_number(text: str, token: Any) -> bool:
"""「前 3 名」「第 2 季度」「从 3 个方面看」「3 月」:序号和结构,不是结论数字。限 20 以内。"""
value = token.value
if token.decimals or token.is_percent or not float(value).is_integer() or not 0 < value <= 20:
return False
before, after = text[max(0, token.start - 6):token.start], text[token.end:token.end + 8]
if _STRUCT_BEFORE.search(before) or _STRUCT_AFTER.match(after):
return True
# 行文中间的列举:(1)…(2)…、1、…2、…(行首的「1. 」「2、」出具校验本来就放过)
if (re.search(r"[((]\s*$", before) and re.match(r"\s*[))]", after)) or after.startswith("、"):
return True
return bool((value <= 12 and re.match(r"\s*月", after))
or (value <= 31 and re.match(r"\s*[日号]", after))
or (value <= 4 and re.match(r"\s*季度", after)))
#: 结构片段涂白时只涂 Markdown 符号本身,数字和字母原样留着:「100. 」前端照样显示成
#: <ol start=100>,「```45678」的语言标签显示在代码块头上——这些数字和正文过同一套规则
#: (1–2 位的行首序号照旧放过,py3 这类标识符照旧不算)。表格竖线涂成一个不是空白的字:
#: 表格第一格里的「1.」不在行首,不能沾行首序号免检的光
_SYNTAX_BLANK = {**{c: " " for c in "`>#*+-~=:"}, "|": "\u00a6"}
def _bare_numbers(markdown: str, masks: list[tuple[int, int]], allow: list[Any] | None,
syntax: list[tuple[int, int]] = ()) -> list[Any]:
"""正文里没有出处的数字,再用出具校验同一套规则抽数字。
- masks:引用渲染出来的字,整段涂白(保留换行,行首序号的免检规则要认得行首)
- syntax:结构片段,只涂 Markdown 符号(见 _SYNTAX_BLANK),里面的数字照样要查
"""
chars = list(markdown)
for s, e in masks:
for k in range(max(s, 0), min(e, len(chars))):
if chars[k] != "\n":
chars[k] = " "
for s, e in syntax:
for k in range(max(s, 0), min(e, len(chars))):
chars[k] = _SYNTAX_BLANK.get(chars[k], chars[k])
masked = "".join(chars)
allowed = number_allowance(allow)
return [t for t in extract_numbers(masked) if not allowed(t) and not _structural_number(masked, t)]
def _context(text: str, start: int, end: int) -> str:
return text[max(0, start - 18):min(len(text), end + 18)].replace("\n", " ")
# --------------------------------------------------------------------------
# 组装文档
# --------------------------------------------------------------------------
#: 粗体:**…** 或 __…__,里面可以有标记(标记里的下划线不算收尾,[[m:refund_rate]] 很常见)
_BOLD = re.compile(r"(\*\*|__)(?P<inner>(?:\[\[" + _MARKER_GUARD + r"[^\[\]\n]*?\]\]|(?!\1)[^\n])+?)\1")
_DIRECTIONAL = re.compile(
r"增长|增加|上升|提升|提高|上涨|下降|下滑|减少|降低|回落|下跌|反弹|超过|高于|低于|领先|落后|最高|最低"
r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|环比|同比|翻倍|持平|波动"
r"|(?<![A-Za-z])(?:increase|decrease|grow|growth|decline|drop|rise|due to|because|driven)(?![A-Za-z])",
re.I)
def _normalize_strong(raw: str) -> tuple[str, list[tuple[int, int]]]:
"""包着引用标记的粗体去掉星号,记下哪一段要加粗:**销售额 [[m:gmv]]** → 销售额 [[m:gmv]]。
星号留在正文里的话,数字一切成单独的片段,两边的文字片段各剩半对 **,前端只能
原样显示星号。不含标记的粗体原样留着,它整个落在一个文字片段里,前端照常渲染。
"""
out: list[str] = []
ranges: list[tuple[int, int]] = []
last = size = 0
for m in _BOLD.finditer(raw):
inner = m.group("inner")
if not MARKER_RE.search(inner):
continue
out.append(raw[last:m.start()])
size += m.start() - last
ranges.append((size, size + len(inner)))
out.append(inner)
size += len(inner)
last = m.end()
out.append(raw[last:])
return "".join(out), ranges
def _classify(kind: str | None, segments: list[dict[str, Any]], see: list[dict[str, Any]]) -> str:
"""unit 的种类。本期没有裁判,按确定的规则分:引用了证据、写了数字、或者有方向词、
因果词的算结论;其余的是连接性的话。"""
if kind:
return kind
if see or any(s["kind"] != "text" and s["kind"] != "structural" for s in segments):
return "claim"
if any(s["kind"] == "text" and _DIRECTIONAL.search(s["text"]) for s in segments):
return "claim"
return "connective"
def compose_doc(
raw: str,
catalog: dict[str, Any],
*,
node_id: str = "",
run_id: str | None = None,
allow_numbers: list[Any] | None = None,
cells_allowed: bool = True,
loader: Loader | None = None,
) -> dict[str, Any]:
"""模型写的原文(带标记)→ 报告文档:渲染后的 markdown、块、句、片段、统计、违规。
stats / violations 由 verify_doc 算出,和出口契约复核用的是同一个函数。
整表标记先展开成每格一个 [[v:]] 的 Markdown 表,之后和模型自己写的表格走同一条路;
doc["source"] 仍是模型的原文。
"""
source = (raw or "").replace("\r\n", "\n").replace("\r", "\n").strip()
snaps = _Snapshots(loader)
text, strong = _normalize_strong(_expand_tables(source, catalog, snaps, cells_allowed))
markers = parse_markers(text)
for marker in markers:
marker["strong"] = any(a <= marker["start"] and marker["end"] <= b for a, b in strong)
blocks = _scan_blocks(text, [(m["start"], m["end"]) for m in markers])
items = [item for block in blocks for item in block.items]
if not _attach_gaps(items, len(text)):
# 切块出了漏洞:整篇当一段,宁可版式粗糙,也不能让哪个字落在片段外面没人查
blocks = [_Block("paragraph", {}, [_Item([("c", 0, len(text))], 0, split=True)])] if text else []
items = [item for block in blocks for item in block.items]
_attach_gaps(items, len(text))
parts: list[str] = []
size = 0
out_blocks: list[dict[str, Any]] = []
all_segments: list[dict[str, Any]] = []
def push(seg: dict[str, Any], segments: list[dict[str, Any]]) -> None:
nonlocal size
prev = segments[-1] if segments else None
if prev is not None and seg["kind"] in ("text", "structural") and prev["kind"] == seg["kind"] \
and prev.get("strong") == seg.get("strong") and prev["span"][1] == size:
prev["text"] += seg["text"]
prev["span"][1] += len(seg["text"])
else:
seg["span"] = [size, size + len(seg["text"])]
segments.append(seg)
parts.append(seg["text"])
size += len(seg["text"])
for block in blocks:
units: list[dict[str, Any]] = []
for item in block.items:
for pieces in _split_sentences(item, text, markers):
segments: list[dict[str, Any]] = []
see: list[dict[str, Any]] = []
anchor = size
for kind, s, e in pieces:
if kind == "s":
push({"kind": "structural", "text": text[s:e], "state": "neutral"}, segments)
continue
def plain(a: int, b: int) -> None:
# 文字按粗体的边界再切一刀:一半加粗一半不加粗的不能并成一段
cuts = sorted({a, b, *(x for r in strong for x in r if a < x < b)})
for x, y in zip(cuts, cuts[1:]):
seg = {"kind": "text", "text": text[x:y], "state": "neutral"}
if any(p <= x and y <= q for p, q in strong):
seg["strong"] = True
push(seg, segments)
cursor = s
for marker in markers:
if marker["start"] < s or marker["end"] > e:
continue
if cursor < marker["start"]:
plain(cursor, marker["start"])
cursor = marker["end"]
if marker["kind"] == "see":
see.extend(resolve_support(r, catalog) for r in marker["refs"])
continue
cite = resolve_marker(marker, catalog, cells_allowed=cells_allowed, loader=snaps)
ok = cite["status"] == "resolved"
seg = {
"kind": _SEG_KIND.get(marker["kind"])
or ("number" if marker["kind"] == "m" or (ok and is_number(cite.get("value")))
else "value"),
"text": cite["rendered"], "ref": f"{marker['kind']}:{marker['body']}",
"cite": cite, "state": "deterministic" if ok else "none",
}
if not ok:
seg["issue"] = "unresolved_ref"
if marker["strong"]:
seg["strong"] = True
push(seg, segments)
if cursor < e:
plain(cursor, e)
unit: dict[str, Any] = {"kind": item.kind, "segments": segments, "see": see,
"_anchor": anchor}
if item.loc is not None:
unit["loc"] = dict(item.loc)
if item.depth is not None:
unit["depth"] = item.depth
units.append(unit)
all_segments.extend(segments)
out_blocks.append({"type": block.type, **block.meta, "units": units})
markdown = "".join(parts)
# 裸数字单独切成片段:前端要在那几个字底下画「无出处」
refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog)
syntax = [tuple(s["span"]) for s in all_segments if s["kind"] == "structural"]
bare = _bare_numbers(markdown, refs, allow_numbers, syntax)
for block in out_blocks:
for unit in block["units"]:
unit["segments"] = _split_bare(unit["segments"], bare)
seg_no = unit_no = 0
for b_no, block in enumerate(out_blocks):
block["id"] = f"b{b_no}"
for unit in block["units"]:
unit["id"] = f"u{unit_no}"
unit_no += 1
for seg in unit["segments"]:
seg["id"] = f"s{seg_no}"
seg_no += 1
body = [s for s in unit["segments"] if s["kind"] != "structural"]
anchor = unit.pop("_anchor")
unit["span"] = [body[0]["span"][0], body[-1]["span"][1]] if body else [anchor, anchor]
unit["kind"] = _classify(unit["kind"], unit["segments"], unit["see"])
unit["cites"] = list(dict.fromkeys(
c["alias"] for c in [*(s["cite"] for s in unit["segments"] if s.get("cite")), *unit["see"]]
if c["status"] == "resolved"))
# 字段顺序固定,文档内容寻址,同样的输入要得到同样的哈希
ordered = {k: unit[k] for k in ("id", "kind", "span", "cites", "see", "segments", "loc", "depth")
if k in unit}
unit.clear()
unit.update(ordered)
doc: dict[str, Any] = {
"schema": DOC_SCHEMA, "run_id": run_id, "node_id": node_id,
"markdown": markdown, "source": source, "catalog": catalog, "blocks": out_blocks,
}
checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps)
doc["stats"], doc["violations"] = checked["stats"], checked["violations"]
return doc
def _header_masks(blocks: list[dict[str, Any]], catalog: dict[str, Any]) -> list[tuple[int, int]]:
"""表头里恰好是某次查询的列名的格:整表的表头是系统照快照列名写的,「销售额2025」这种列名
里的数字不是结论数字,写作者也改不了它。只放过表头、只放过和列名一字不差的格。"""
names = {_clean(str(c)) for e in catalog.values() if e.get("kind") == "query" for c in e.get("columns") or []}
out: list[tuple[int, int]] = []
for block in blocks if names else []:
if not isinstance(block, dict) or block.get("type") != "table":
continue
for unit in block.get("units") or []:
if (unit.get("loc") or {}).get("row") != -1:
continue
body = [s for s in unit.get("segments") or [] if s.get("kind") != "structural"]
spans = [s.get("span") for s in body]
if not body or not all(isinstance(p, list) and len(p) == 2 and all(isinstance(x, int) for x in p)
for p in spans):
continue # 片段的偏移坏了:复核会报 segment_mismatch,这里不替它兜
if all(s.get("kind") == "text" for s in body) \
and "".join(str(s.get("text") or "") for s in body).strip() in names:
out.append((spans[0][0], spans[-1][1]))
return out
def _split_bare(segments: list[dict[str, Any]], bare: list[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for seg in segments:
s0, e0 = seg["span"]
hits = [t for t in bare if s0 <= t.start < e0] if seg["kind"] == "text" else []
if not hits:
out.append(seg)
continue
extra = {"strong": True} if seg.get("strong") else {}
def piece(a: int, b: int, **fields: Any) -> None:
out.append({"kind": "text", "text": seg["text"][a - s0:b - s0], "span": [a, b],
"state": seg["state"], **extra, **fields})
cursor = s0
for token in hits:
end = min(token.end, e0)
if cursor < token.start:
piece(cursor, token.start)
piece(token.start, end, kind="number", state="none", issue="uncited_number")
cursor = end
if cursor < e0:
piece(cursor, e0)
return out
# --------------------------------------------------------------------------
# 复核:不信任文档自己的说法,逐项重算
# --------------------------------------------------------------------------
_STRUCTURAL_REST = re.compile(r"^[\s|>#*+\-_:~=.]*$")
def _is_structural(text: str) -> bool:
"""结构片段里只能有 Markdown 语法:标题井号、列表符号和序号、引用号、表格竖线、围栏。
这里只回答「是不是 Markdown 语法」,不回答「里面的数字要不要出处」——后者交给裸数字
检查(结构片段只涂符号,数字照查,见 _SYNTAX_BLANK)。所以行首序号不限位数,和切块的
_ORDERED、前端 Markdown.tsx 的 ORDERED 一致:「100. 」在前端就是 <ol start=100>,把它
判成夹带内容会变成完整性问题(出口记 gap),而它真正的问题是一个没有出处的数字。
"""
rest = re.sub(r"`{3,}[A-Za-z0-9_]*", "", text)
rest = re.sub(r"(?m)^[ \t]*\d+[.)](?=\s)", "", rest)
return bool(_STRUCTURAL_REST.match(rest))
def iter_units(doc: dict[str, Any]):
for block in doc.get("blocks") or []:
for unit in block.get("units") or []:
yield block, unit
def iter_segments(doc: dict[str, Any]):
"""按正文顺序给出全部片段(含结构片段)。"""
for _, unit in iter_units(doc):
yield from unit.get("segments") or []
def find_segment(doc: dict[str, Any], segment_id: str) -> tuple[dict, dict, dict] | None:
"""(block, unit, segment),找不到返回 None。"""
for block, unit in iter_units(doc):
for seg in unit.get("segments") or []:
if seg.get("id") == segment_id:
return block, unit, seg
return None
def _violation(code: str, message: str, **extra: Any) -> dict[str, Any]:
return {"code": code, "message": message, **{k: v for k, v in extra.items() if v is not None}}
def verify_doc(
doc: dict[str, Any],
catalog: dict[str, Any],
*,
allow_numbers: list[Any] | None = None,
cells_allowed: bool = True,
loader: Loader | _Snapshots | None = None,
) -> dict[str, Any]:
"""逐项复核一份报告文档:{ok, violations, stats}。
cells_allowed=False:单元格引用(含整表里的格)一律判解析不了,原因是 CELLS_REASON——
受管级别的正式出具,契约没声明 cells 时出口复核这样调。快照经 loader 重新取、复验哈希。
报告节点自查用它(违规就让写作者重写),出口契约复核也用它(判档)——后者传的
是自己从状态里重建的目录,不用文档里存的那份。复核什么:
- 片段按顺序拼起来正好是 markdown(没有字落在片段外面)
- 结构片段里只有 Markdown 语法
- 每个带引用的片段:按给定目录重新解析、重新渲染,和片段上的字逐字相等,eid 一致
- [[see:]] 里的每个依据都能解析
- 引用涂白、结构片段只涂符号之后,正文里剩下的数字全部算裸数字(结构片段里的
「100. 」「```45678」也在内)
- 片段标的状态和核对结果一致(界面按状态画线,不能标着「有出处」其实没有)
"""
snaps = _snapshots(loader)
violations: list[dict[str, Any]] = []
stats = {"units": 0, "segments": 0, "claims": 0, "connective": 0, "headings": 0, "numbers": 0,
"numbers_cited": 0, "values": 0, "uncited_numbers": 0, "unresolved": 0, "see": 0,
"uncited_claims": 0, "violations": 0}
if not isinstance(doc, dict):
violations.append(_violation("bad_schema", "报告文档不是一个对象"))
return {"ok": False, "violations": violations, "stats": stats}
if doc.get("schema") != DOC_SCHEMA:
violations.append(_violation("bad_schema", f"不认识的报告文档格式 {doc.get('schema')!r}"))
markdown = str(doc.get("markdown") or "")
masks: list[tuple[int, int]] = [] # 引用渲染出来的字
syntax: list[tuple[int, int]] = [] # 结构片段:只涂符号,数字照查
owners: list[tuple[int, int, str, str]] = [] # (start, end, segment id, unit id)
pos, tiled = 0, True
for _, unit in iter_units(doc):
stats["units"] += 1
kind = unit.get("kind")
if kind in ("claim", "connective"):
stats["claims" if kind == "claim" else "connective"] += 1
elif kind == "heading":
stats["headings"] += 1
cited: list[str] = []
for seg in unit.get("segments") or []:
stats["segments"] += 1
text, span = str(seg.get("text") or ""), seg.get("span")
intact = (isinstance(span, list) and len(span) == 2 and all(isinstance(x, int) for x in span)
and markdown[span[0]:span[1]] == text and span[1] - span[0] == len(text))
if tiled and (not intact or span[0] != pos):
tiled = False
violations.append(_violation("segment_mismatch",
f"片段 {seg.get('id')} 和正文对不上:文档可能被改过",
segment=seg.get("id"), unit=unit.get("id")))
if intact:
pos = span[1]
owners.append((span[0], span[1], seg.get("id"), unit.get("id")))
where = {"span": list(span) if intact else None, "text": text, "segment": seg.get("id"),
"unit": unit.get("id")}
state, expected = seg.get("state"), None
found = len(violations)
if seg.get("kind") == "structural":
expected = "neutral"
if intact and _is_structural(text):
syntax.append((span[0], span[1]))
else:
violations.append(_violation("structural_text",
f"结构片段里夹带了内容「{text[:30]}」", **where))
elif seg.get("ref"):
ref = str(seg["ref"])
cite = resolve_ref(ref, catalog, cells_allowed=cells_allowed, loader=snaps)
ok = cite["status"] == "resolved"
matches = cite["rendered"] == text
if intact:
masks.append((span[0], span[1]))
if not ok:
violations.append(_violation("unresolved_ref", f"引用 [[{ref}]] 解析不了:{cite['reason']}",
ref=ref, **where))
elif not matches:
violations.append(_violation(
"render_mismatch", f"片段「{text}」和引用 [[{ref}]] 重新渲染出来的「{cite['rendered']}」"
"对不上", ref=ref, **where))
elif (seg.get("cite") or {}).get("eid") not in (None, cite["eid"]):
violations.append(_violation("eid_mismatch", f"引用 [[{ref}]] 记的证据标识和目录里的不一致,"
"文档可能被改过", ref=ref, **where))
good = ok and matches
expected = "deterministic" if good else "none"
if seg.get("kind") == "number":
stats["numbers"] += 1
stats["numbers_cited"] += int(good)
elif good:
stats["values"] += 1
if ok:
cited.append(cite["alias"])
elif seg.get("kind") == "number":
expected = "none"
elif state == "deterministic":
expected = "neutral"
# 已经为这一段报过别的问题,状态不对是它的推论,不再重复
if expected is not None and state != expected and len(violations) == found:
violations.append(_violation("state_mismatch",
f"片段「{text[:30]}」标的状态是 {state},核对下来应该是 {expected}",
**where))
for support in unit.get("see") or []:
stats["see"] += 1
again = resolve_support(str(support.get("ref") or ""), catalog)
if again["status"] == "resolved":
cited.append(again["alias"])
else:
violations.append(_violation("unresolved_ref",
f"依据 [[see:{again['ref']}]] 解析不了:{again['reason']}",
ref=again["ref"], unit=unit.get("id"),
span=list(unit.get("span") or []) or None))
if kind == "claim" and not cited:
stats["uncited_claims"] += 1
if tiled and pos != len(markdown):
violations.append(_violation("segment_mismatch", "正文末尾有一段不在任何片段里:文档可能被改过"))
masks += _header_masks(list(doc.get("blocks") or []), catalog)
for token in _bare_numbers(markdown, masks, allow_numbers, syntax):
owner = next(((sid, uid) for s, e, sid, uid in owners if s <= token.start < e), (None, None))
violations.append(_violation(
"uncited_number", f"数字「{token.raw}」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字",
span=[token.start, token.end], text=token.raw, context=_context(markdown, token.start, token.end),
segment=owner[0], unit=owner[1]))
order = {"bad_schema": 0, "segment_mismatch": 1}
violations.sort(key=lambda v: (order.get(v["code"], 2), (v.get("span") or [-1])[0]))
stats["uncited_numbers"] = sum(1 for v in violations if v["code"] == "uncited_number")
stats["numbers"] += stats["uncited_numbers"]
stats["unresolved"] = sum(1 for v in violations if v["code"] == "unresolved_ref")
stats["violations"] = len(violations)
return {"ok": not violations, "violations": violations, "stats": stats}
# --------------------------------------------------------------------------
# 给报告节点用的文字:证据目录、写作规则、违规清单
# --------------------------------------------------------------------------
MARKER_RULES = """写作规则(系统会逐字核对):
1. 任何数字都只能用引用标记写:[[m:指标id]]。要换算显示就写 [[m:指标id|万]],也可以用 |亿、|pct(比率显示成百分数)、|int(取整)、|.1(一位小数)。不要自己写数字,不要计算,不要改写数值——标记会被换成口径卡里的真实数值。
2. 运行输入用 [[i:字段名]]。
3. 每句陈述数据的结论,句末用 [[see:m:指标id,…]] 标出依据;过渡、连接性的话不用标。
4. 日期、ISO 周(2026-W37)、「前 3 名」「第 2 季度」这类序号可以直接写。
5. 目录里没有的指标不要编造引用;标着「没有值」的指标不要写进报告。"""
#: 引用查询结果的写法。只跟着目录里的查询出现(catalog_prompt):写作规则本身保持一期那份,升级前
#: 发起的运行跑到报告节点时提示一字不差;只有口径卡的目录也不该教写作者去引用不存在的 Q1
CELL_RULES = (
"查询结果(里面的数和指标一样只能用引用标记写,系统照快照换成真实数值、逐字核对):\n"
"写法:一格写 [[v:Q1.r0.列名]](行号从 0 数),换算同指标,比如 [[v:Q1.r0.列名|万]];"
"要列出多行多列,单独一行写 [[table:Q1 cols=列a,列b rows=0-4]],系统照快照生成表格"
f"(不写 rows 就是前 {TABLE_DEFAULT_ROWS} 行,最多 {TABLE_MAX_ROWS} 行);"
"结论的依据可以写查询编号,比如 [[see:Q1]]。下面没有的查询、行、列不要编造。"
)
#: 写作目录里每次查询最多列出几行、几列。更多的行照样能按行号引用
_PROMPT_ROWS = 20
_PROMPT_COLS = 12
def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool) -> list[str]:
"""一次查询在写作目录里的样子:编号、来源、列,以及能引用的行(行号 + 渲染后的值)。
值按 [[v:]] 的渲染规则写出来:写作者在这里看到的,就是引用之后报告里显示的字。
"""
alias = entry["alias"]
cols = entry.get("columns") or []
lines = [f"- {alias}:{entry.get('label')}" + (f",列 {', '.join(map(str, cols[:_PROMPT_COLS]))}" if cols else "")]
snapshot, why = snaps.get(alias, entry.get("artifact"))
if why:
return [*lines, f" ({why},不要引用)"]
if not cells_allowed:
return lines
columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS]
rows = snapshot["rows"]
example = None
for r, record in enumerate(rows[:_PROMPT_ROWS]):
cells = []
for c in columns:
try:
raw, _ = locate_cell(snapshot, r, c)
kind = column_kind(snapshot, c)
shown, numeric = render_cell(raw, kind=kind), is_number(cell_value(raw, kind))
except (CellError, RenderError):
shown, numeric = MISSING, False
cells.append(f"{c}={shown}")
if example is None and r == 0 and numeric:
example = c # 示例挑第一行里第一个数,写作者最常引用的就是它
lines.append(f" r{r}:" + ",".join(cells))
if not rows:
lines.append(" (0 行,没有可以引用的格)")
elif len(rows) > _PROMPT_ROWS:
lines.append(f" …共 {len(rows)} 行,这里只列出前 {_PROMPT_ROWS} 行;后面的行也能按行号引用")
if rows and columns:
lines.append(f" 例:[[v:{alias}.r0.{example or columns[0]}]]")
return lines
def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000, cells_allowed: bool = True,
loader: Loader | None = None) -> str:
"""证据目录的文字版,放进写作者的 prompt。超过预算就截断,并照实说截断了。
查询结果会列出能引用的行(要读快照);cells_allowed=False 时只列编号,说明只能当依据。
沙箱代码节点不列:它的产出不能引用。
"""
snaps = _Snapshots(loader)
lines = ["可引用的证据(写数字只能用下面的引用标记,系统会换成真实数值):"]
groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
for entry in catalog.values():
if entry.get("kind") == "metric":
key = (entry.get("caliber") or "", entry.get("version") or "", entry.get("node_id") or "")
groups.setdefault(key, []).append(entry)
for (caliber, version, node_id), entries in groups.items():
title = " @ ".join(v for v in (caliber, version) if v) or node_id
lines.append(f"\n口径卡「{title}」:")
for e in entries:
if e.get("value") is None:
lines.append(f"- [[{e['alias']}]] {e.get('name')}:这次没有值,不要引用")
elif e.get("rendered") == MISSING:
# 有值但按口径卡的格式显示不出来(太小、太大、不是有限的数):引用了也是解析不了
lines.append(f"- [[{e['alias']}]] {e.get('name')}:按口径卡的格式显示不出来,不要引用")
else:
lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}")
inputs = [e for e in catalog.values() if e.get("kind") == "input"]
if inputs:
lines.append("\n运行输入:")
lines.extend(f"- [[{e['alias']}]] = {e.get('rendered')}" for e in inputs)
queries = [e for e in catalog.values() if e.get("kind") == "query"]
if queries:
lines.append(f"\n{CELL_RULES}" if cells_allowed else
"\n查询结果(这次出具不允许直接引用单元格,只能写在 [[see:Q1]] 这样的依据里):")
for e in queries:
lines.extend(_query_prompt(e, snaps, cells_allowed))
retrievals = [e for e in catalog.values() if e.get("kind") == "retrieval"]
if retrievals:
lines.append("\n知识库检索(只能写在 [[see:K1]] 这样的依据里):")
lines.extend(f"- {e['alias']}:{e.get('label')}" for e in retrievals)
text = "\n".join(lines)
if len(text) > budget:
text = text[:budget].rsplit("\n", 1)[0] + "\n…(目录太长,后面的省略了)"
return text
def describe_violations(violations: list[dict[str, Any]], *, limit: int = 20) -> str:
"""违规清单的文字版:给写作者的修复指令、给节点的报错,都用这一份。"""
lines = []
for v in violations[:limit]:
where = f"(…{v['context']}…)" if v.get("context") else ""
lines.append(f"- {v['message']}{where}")
if len(violations) > limit:
lines.append(f"- …另有 {len(violations) - limit} 处")
return "\n".join(lines)