Files
AgentLab/backend/app/engine/nodes/io.py
T

1363 lines
78 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.
from __future__ import annotations
import json
import re
from typing import Any
from app.analysis.contract import LEDGER_KIND
from app.core.events import EventType
from app.engine.context import NodeContext, NodeError
from app.engine.expressions import ExpressionError, eval_expression
from app.engine.schema import GraphNode, NodeType, _ancestors
from app.engine.state import GraphState, template_context
async def run_input(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""入口节点。把运行输入按声明的字段整理进变量池,补上默认值。"""
raw = dict(state.get("input") or {})
fields = ctx.cfg("fields", []) or []
resolved: dict[str, Any] = dict(raw)
for field in fields:
name = field.get("name")
if not name:
continue
if name not in resolved or resolved[name] in (None, ""):
if field.get("required") and field.get("default") in (None, ""):
raise NodeError(ctx.node.id, f"缺少必填输入:{name}")
resolved[name] = field.get("default")
return {
"input": resolved,
"vars": resolved,
"nodes": {ctx.node.id: resolved},
}
async def run_output(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""出口节点。按配置把散落在各节点的结果收成一个结构化成果。
配了 contract 就升级成出具契约:核对指标齐不齐、叙述里的数字
能不能回指到指标集,然后给出三档判定(formal / degraded / withheld)。
判定和缺口清单一起印在成果上——降档不是悄悄地降。
"""
mapping = ctx.cfg("fields", None)
tctx = template_context(state)
if mapping:
result: dict[str, Any] = {}
for field in mapping:
name = field.get("name")
if not name:
continue
result[name] = ctx.render(field.get("value", ""), state)
else:
# 没配映射就把最后一条消息当作结果,让新建的图开箱即用
result = {"result": tctx.get("last_message", "")}
reports = _report_fields(result, mapping, state, ctx)
contract = ctx.cfg("contract")
if contract:
cells, governed = True, False
if isinstance(contract, dict) and contract.get("report_from") not in (None, ""):
from app.engine.governance import cells_allowed, governed_formal
governed = await governed_formal(ctx.run.run_id)
cells = cells_allowed(contract, governed=governed)
result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports, cells=cells, governed=governed,
fields=result)
evidence = _evidence_of(reports, contract)
if evidence:
result["_evidence"] = evidence
return {"output": result, "nodes": {ctx.node.id: result}}
# --------------------------------------------------------------------------
# 成果字段和上游报告的对应
# --------------------------------------------------------------------------
def _reference(node: GraphNode) -> re.Pattern[str]:
"""字段模板里「带出了这个报告的正文」的写法。
nodes.write.text、nodes['write']['text']、整个 nodes.write(过 json 也带着正文),以及
存的就是正文的 vars.<assign_to>。nodes.write.repairs / stats / doc_artifact 只是把报告的
统计或文档 id 一起放进成果,没碰正文——算成改动的话,一份全部带引用的报告会被一个
「改写次数」字段拖成降档。模板只认路径加过滤器、没有方法调用,所以这几种写法就是全部。
"""
nid = re.escape(node.id)
head = rf"nodes\s*(?:\.\s*{nid}(?![\w-])|\[\s*['\"]{nid}['\"]\s*\])"
alternatives = [rf"{head}(?:\s*\.\s*text(?![\w-])|\s*\[\s*['\"]text['\"]\s*\]|(?!\s*[.\[]))"]
var = str(node.config.get("assign_to") or "").strip()
if var:
alternatives += [rf"vars\s*\.\s*{re.escape(var)}(?![\w-])",
rf"vars\s*\[\s*['\"]{re.escape(var)}['\"]\s*\]"]
return re.compile("|".join(alternatives))
def _report_fields(
result: dict[str, Any], mapping: Any, state: GraphState, ctx: NodeContext,
) -> list[dict[str, Any]]:
"""上游每个写出了文档的报告节点:哪些成果字段逐字就是它的原文,哪些引用了它却改动过。
逐字相等的字段,界面能按文档逐段画、逐个数点开证据;模板在报告前后拼了别的字、
或者过了过滤器,偏移就对不上了——这种字段不标证据,有契约时记一条 gap。
首尾空白不算改动:Markdown 渲染出来一样,文档的 markdown 本来就去掉了首尾空白。
"""
spec = ctx.run.spec
upstream = _ancestors(spec, ctx.node.id)
nodes = state.get("nodes") or {}
sources = {f.get("name"): f.get("value", "") for f in mapping or [] if isinstance(f, dict) and f.get("name")}
out = []
for node in spec.nodes:
payload = nodes.get(node.id)
if node.type != NodeType.REPORT or node.id not in upstream or not isinstance(payload, dict):
continue
text = payload.get("text")
if not payload.get("doc_artifact") or not isinstance(text, str) or not text.strip():
continue
verbatim = [name for name, value in result.items()
if not name.startswith("_") and isinstance(value, str) and value.strip() == text]
pattern = _reference(node)
edited = [name for name, src in sources.items() if name not in verbatim and pattern.search(
src if isinstance(src, str) else json.dumps(src, ensure_ascii=False, default=str))]
out.append({"node": node, "doc_artifact": payload["doc_artifact"], "verbatim": verbatim,
"edited": edited})
return out
def _evidence_of(reports: list[dict[str, Any]], contract: Any) -> dict[str, Any] | None:
"""成果上的 _evidence:{report_node, doc_artifact, fields}。
不管有没有契约、是不是正式运行都标——探索运行里的报告一样能点开看证据。契约的
report_from 指着的那份优先;另有报告也被原样放进了成果的,列在 others 里。
"""
shown = [r for r in reports if r["verbatim"]]
if not shown:
return None
preferred = contract.get("report_from") if isinstance(contract, dict) else None
shown.sort(key=lambda r: r["node"].id != preferred)
first, *rest = [{"report_node": r["node"].id, "doc_artifact": r["doc_artifact"], "fields": r["verbatim"]}
for r in shown]
return {**first, "others": rest} if rest else first
def _apply_contract(
contract: dict[str, Any], state: GraphState, ctx: NodeContext,
*, reports: list[dict[str, Any]] | None = None, cells: bool = True, governed: bool = False,
fields: dict[str, Any] | None = None,
) -> dict[str, Any]:
from datetime import datetime, timezone
from app.engine.governance import board_only
from app.engine.issuance import decide_tier, incomplete_gaps, no_data_gaps, sql_check_findings, trace_numbers
nodes = state.get("nodes") or {}
# 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述
report_from = contract.get("report_from")
citations = report_from not in (None, "")
# 1) 收指标集:contract.metrics_from 指向一个或多个口径卡节点
metrics: list[dict[str, Any]] = []
calibers: list[dict[str, str]] = []
sources = contract.get("metrics_from") or []
if isinstance(sources, str):
sources = [sources]
# gaps 记的是"校验本身哪里没跑起来",和"校验跑了但没通过"是两回事。
# 没有它,一个写错的节点名会让所有列表都是空的,看起来和"全部通过"一模一样。
gaps: list[str] = []
unresolved: list[str] = []
#: 指标 id → 它来自哪张口径卡(「销售口径 @ v2」),逐个数字回指时标出处
caliber_of: dict[str, str] = {}
for source in sources:
payload = nodes.get(source)
if isinstance(payload, dict) and payload.get("kind") == "metric_set":
metrics.extend(payload.get("metrics") or [])
calibers.append(
{
"node": source,
"caliber": payload.get("caliber", ""),
"version": payload.get("caliber_version", ""),
}
)
label = " @ ".join(str(v) for v in (payload.get("caliber"), payload.get("caliber_version")) if v)
for m in payload.get("metrics") or []:
caliber_of.setdefault(str(m.get("id")), label)
else:
# 节点不存在、还没跑到、或者根本不是口径卡——指标集就是缺的,
# 不能当作"这次没有指标要查"
unresolved.append(source)
if unresolved:
gaps.append(f"指标来源未解析:{'、'.join(unresolved[:5])}")
# 拿截断的查询结果整组算出来的指标:数回指得上,但算的只是取回的那一截,不能完整出具
gaps.extend(incomplete_gaps(metrics))
# 输入取自「查询区间里这张表没有数据」的列的指标:数回指得上、复算一致,但那个 0 不代表真的是 0,不能完整出具
gaps.extend(no_data_gaps(metrics))
# 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总):口径卡复算得一致,算的却是错数。
# 报告直接引用的查询要等引用核对之后才知道,缺口放在这个位置,下面一起算
sql_at = len(gaps)
# 引用模式下,缺输入记成空值的指标(口径卡 on_missing=null)就是缺:报告里引用不了它,
# 也不能因为 id 在清单里就算「齐了」。旧模式照旧只看 id,行为不变
present = {m["id"] for m in metrics if not citations or m.get("value") is not None}
missing_required = [m for m in (contract.get("required") or []) if m not in present]
missing_expected = [m for m in (contract.get("expected") or []) if m not in present]
trace = None
cited: dict[str, Any] = {}
by_metrics = not citations and board_only(contract, ctx.run.spec, ctx.node.id)
if citations:
cited = _check_citations(str(report_from), contract, state, ctx, reports or [], cells=cells,
governed=governed)
gaps.extend(cited["gaps"])
unmatched = cited["unmatched"]
elif by_metrics:
# 指标出具:没有叙述,给人看的是成果字段——字段里的数回指指标集,和叙述同一套规则;每个指标的输入要追到原始
# 数据,追不到的是「这个数有算式、没出处」,照实记成缺口
shown = _shown_text(fields or {})
trace = trace_numbers(shown, metrics, allow=contract.get("allow_numbers")) if shown.strip() else None
unmatched = trace.unmatched if trace else []
gaps.extend(_untraced_gaps(metrics, ctx))
else:
# 2) 叙述数字回指
declared_narrative = str(contract.get("narrative", ""))
narrative = ctx.render_str(declared_narrative, state)
# 模板取不到路径时 render 出来是空串,所以"声明了叙述但渲染成空"必须单独识别:
# 节点改名、vars 写错都会让回指校验静默变成空操作
if declared_narrative.strip() and not narrative.strip():
gaps.append("叙述模板渲染为空(路径可能有误)")
elif not declared_narrative.strip():
gaps.append("出具契约未设置「叙述」,未执行数字追溯")
trace = (
trace_numbers(narrative, metrics, allow=contract.get("allow_numbers"))
if narrative.strip()
else None
)
unmatched = trace.unmatched if trace else []
# 口径卡指标上的可比性(口径卡跨两份同形查询算环比、同比时自动查的):报告引用了的(引用模式按文档,别的按数字回指上
# 的指标)、契约必需的指标两期不可比,记缺口降档;有条件可比的写进出具声明作标注
required = {str(r) for r in contract.get("required") or []}
matched_ids = {str(h.get("metric")) for h in (trace.matched if trace else []) if h.get("metric")}
compared_gaps, comparability = _comparability(
[*(cited.get("cited_metrics") or []),
*(m for m in metrics if str(m.get("id")) in required | (set() if citations else matched_ids))])
gaps.extend(compared_gaps)
# 同一条查询算出几个指标、报告又引用了它的格,只记一句(按出问题的那条查询归并,列出受影响的引用)
titles = {n.id: n.title for n in ctx.run.spec.nodes}
aliases: dict[str, str] = cited.get("aliases") or {}
def query_label(artifact: str, node_id: str | None) -> str:
from app.engine.sql_problems import node_of
title = titles.get(node_id or node_of(artifact, state.get("evidence")) or "")
alias = aliases.get(artifact)
if title and alias:
return f"查询「{title}」({alias})"
return f"查询「{title}」" if title else (f"查询 {alias}" if alias else "")
findings = sql_check_findings(metrics, cited.get("queries"), label=query_label)
gaps[sql_at:sql_at] = [f["text"] for f in findings]
# 出具声明另存一份结构化的:报告页在显眼处说「因为 SQL 检查没通过而降档」,不从缺口的中文里认。没有就不加这个键
from app.engine.sql_problems import node_of
for f in findings:
if not f.get("node_id") and f.get("artifact"):
f["node_id"] = node_of(f["artifact"], state.get("evidence"))
# gap 是 gaps 里对应的那一句:界面单列这几条时,据它不在「其余缺口」里重复
sql_checks = [{**{k: f[k] for k in ("query", "node_id", "problems", "refs") if f.get(k)}, "gap": f["text"]}
for f in findings]
# 声明了 metrics_from 却一个指标都没收到,同样是"没查成"
if sources and not metrics:
gaps.append("指标集为空,叙述中的数字无法追溯")
# 上游有协作团队用完轮数、按降档交付的:叙述里的话不是调度者认可的结论,
# 数字全都对得上也不能盖「完整出具」
for node_id, payload in nodes.items():
if isinstance(payload, dict) and payload.get("exhausted"):
gaps.append(f"协作团队「{titles.get(node_id, node_id)}」用完 {payload.get('rounds', '?')} "
"轮仍未完成,交付的是成员最后的回复")
# 上游节点自己报上来的缺口(双盲复算比不上、反方审稿的反驳没回应……):只会降档,不会升档。报告节点报的分析说法那一条,
# 引用模式下出口自己复核过、记了自己的缺口的不再重复收
gaps.extend(_upstream_issuance_gaps(state, ctx, skip=cited.get("analysis_skip")))
tier = decide_tier(
missing_required=missing_required,
missing_expected=missing_expected,
unmatched=unmatched,
strict=bool(contract.get("strict")),
gaps=gaps,
unresolved=cited.get("unresolved"),
unsupported=cited.get("unsupported"),
uncited_claims=cited.get("uncited"),
claims_policy=cited.get("claims_policy"),
)
if citations:
return _declare_citations(tier, cited, calibers=calibers, metrics=metrics, gaps=gaps,
missing_required=missing_required, missing_expected=missing_expected,
report_from=str(report_from), declared_at=datetime.now(timezone.utc),
ctx=ctx, sql_checks=sql_checks, comparability=comparability)
# 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」
matched = [
{**hit, **({"caliber": caliber_of[hit["metric"]]} if caliber_of.get(hit.get("metric")) else {})}
for hit in (trace.matched if trace else [])
][:50]
issuance = {
"tier": tier,
**({"mode": "metrics"} if by_metrics else {}),
"calibers": calibers, # 口径版本照常印
"metrics_checked": len(metrics),
"missing_required": missing_required,
"missing_expected": missing_expected, # 缺数据声明照常印
"unmatched_numbers": unmatched[:20],
"matched_numbers": len(trace.matched) if trace else 0,
"matched": matched,
# 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查"
"gaps": gaps,
"declared_at": datetime.now(timezone.utc).isoformat(),
**({"sql_checks": sql_checks} if sql_checks else {}),
# 指标的可比性(有条件可比的标注、不可比的同时在缺口里):只在有的时候带这个键
**({"comparability": comparability} if comparability else {}),
}
ctx.emit(
EventType.ISSUANCE,
tier=tier,
**({"mode": "metrics"} if by_metrics else {}),
missing_required=missing_required,
missing_expected=missing_expected,
unmatched=len(unmatched),
calibers=calibers,
# 降档的原因要跟着事件走:只有 gaps 的时候,时间线上的出具步骤以前只能写
# 一个光秃秃的「降档出具」,横幅还说「请对照下方声明」,下方却什么都没有
gaps=gaps,
metrics_checked=len(metrics),
matched_numbers=len(trace.matched) if trace else 0,
matched=matched,
**({"sql_checks": sql_checks} if sql_checks else {}),
)
return issuance
def _shown_text(fields: dict[str, Any]) -> str:
"""指标出具时给人看的文字:成果字段里的文字和数(成果自己的 _issuance、_evidence 不算)。表格、对象这类结构化的值不在
这里核:它们是查询结果,出处是自己的快照,不是口径卡的指标。"""
parts = []
for name, value in fields.items():
if str(name).startswith("_") or isinstance(value, bool):
continue
if isinstance(value, (str, int, float)):
parts.append(str(value))
return "\n".join(parts)
def _untraced(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[dict[str, Any]]:
"""输入没追到原始数据的指标(engine/lineage 的认法):[{metric, name, stops: [{path, node_id}], gap}]。gap 是缺口那一句,
说清停在哪个节点。metrics 可以是契约收来的全部指标(指标出具),也可以只是报告引用的那几个(引用模式,D15)。"""
from app.engine.lineage import untraced
titles = {n.id: n.title for n in ctx.run.spec.nodes}
out = []
for m in metrics:
stops = untraced(m.get("inputs") or [])
if not stops:
continue
# 停在子工作流里的(节点 id「子工作流节点 id/…」)说停在那个子工作流:这张图上看得到的是它,静态检查也这么说
where = "、".join(dict.fromkeys(
f"{s.get('path')}(停在「{titles.get(at, at or '未知来源')}」)"
for s in stops[:3] for at in [str(s.get('node_id') or '').split('/', 1)[0]]))
out.append({"metric": m.get("id"), "name": m.get("name") or m.get("id"),
"stops": [{"path": s.get("path"), "node_id": s.get("node_id")} for s in stops[:3]],
"gap": f"指标「{m.get('name') or m.get('id')}」的输入没追到原始数据:{where}"})
return out
def _untraced_gaps(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[str]:
"""指标出具的缺口:输入没追到原始数据的指标,每个一句(_untraced)。"""
return [u["gap"] for u in _untraced(metrics, ctx)]
def _comparability(metrics: list[dict[str, Any]]) -> tuple[list[str], list[dict[str, Any]]]:
"""口径卡指标上的可比性(contract.MetricComparability,口径卡跨两份同形查询算环比、同比时自动做的检查):
(缺口, 出具声明里的那一项)。metrics 是要看的指标(报告引用了的、契约必需的,调用方挑好):两期不可比的记缺口(降档,
同 sql_check_failed),有条件可比的只标注。没有可比性记录的指标不在里面;同一条指标只看一次。"""
gaps: list[str] = []
declared: list[dict[str, Any]] = []
seen: set[int] = set()
for m in metrics:
mid = str(m.get("id") or "") if isinstance(m, dict) else ""
found = m.get("comparability") if mid else None
if not isinstance(found, dict) or id(m) in seen:
continue
seen.add(id(m))
verdict = found.get("verdict")
if verdict not in ("conditional", "incomparable"):
continue
name = m.get("name") or mid
phrase = str(found.get("phrase") or "").strip()
declared.append({"metric": mid, "name": name, "verdict": verdict, "phrase": phrase,
"reasons": [str(r) for r in found.get("reasons") or [] if r],
**({"artifact": found["artifact"]} if found.get("artifact") else {})})
if verdict == "incomparable":
gaps.append(f"指标「{name}」{phrase or '两期不可比'}")
return gaps, declared
def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext,
skip: dict[str, set[str]] | None = None) -> list[str]:
"""上游节点产出里的 issuance_gaps(字符串列表),每条前面加上是哪个节点说的。
这是给「把关类」节点留的通用口子:节点觉得这次的结论不该完整出具(双盲复算两路对不上、
反方审稿的反驳没有回应),在自己的产出里写 issuance_gaps,成果节点的出具契约照单收进缺口,
出具随之降档。写的时候不用带节点名,这里统一加上「双盲复算「复核销售额」:」这样的前缀,
和别的缺口一样读得出是谁报的。节点名就是类型的叫法(拖进画布没改名)时只写一次:「反方审稿:」,
不写成「反方审稿「反方审稿」:」。缺口正文也不必再提节点类型,前缀已经说了。
只认这个成果节点的上游:另一条支路上的节点管不到这份成果。不是字符串、空白的条目跳过——
这个键谁都能写(数据整形的 JSON 模板也行),写坏了只当没写,不能让出具崩掉。
skip:{节点 id: 不再收的那几条}。引用模式的出口自己复核过报告文档的分析说法、记了自己的缺口时,报告节点报上来的同一件事
不再重复收一条(_analysis_claims)。
"""
from app.engine.labels import type_label
spec = ctx.run.spec
upstream = _ancestors(spec, ctx.node.id)
nodes = state.get("nodes") or {}
out: list[str] = []
for node in spec.nodes:
payload = nodes.get(node.id)
if node.id not in upstream or not isinstance(payload, dict):
continue
reported = payload.get("issuance_gaps")
if not isinstance(reported, list):
continue
kind = type_label(node.type)
prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:"
dropped = (skip or {}).get(node.id) or set()
out.extend(prefix + gap.strip() for gap in reported
if isinstance(gap, str) and gap.strip() and gap not in dropped)
return out
# --------------------------------------------------------------------------
# 引用模式:按报告文档逐段复核
# --------------------------------------------------------------------------
#: 文档本身对不上(不是某个数没出处):这次复核没法完成,记 gap
_INTEGRITY = {"bad_schema", "segment_mismatch", "structural_text", "render_mismatch", "eid_mismatch",
"state_mismatch"}
#: 出具声明里列多少个可疑名字、多少句没挂依据的结论(计数照实,列表只是给人看的样本)
_LISTED = 20
def _check_citations(
report_from: str, contract: dict[str, Any], state: GraphState, ctx: NodeContext,
reports: list[dict[str, Any]], *, cells: bool = True, governed: bool = False,
) -> dict[str, Any]:
"""独立复核报告文档:{matched, unmatched, unresolved, gaps, doc_artifact, stats, claims_policy, uncited,
unsupported, claims, entities}。
不信报告节点自己的统计:文档按 id 从工件库取回(取回时复验哈希),目录按报告节点
同一套参数从状态里重建(不用文档里存的那份),每个带引用的片段重新解析、重新渲染、
逐字比对。一个配错的报告节点绕不过这里。
cells:受管级别的正式运行、契约没声明 cells 时为 False,单元格引用一律判解析不了。
governed:受管级别的正式运行。可疑实体(名字哪里都找不到)只在这时按缺口降档,别处只标注。
"""
from app.core.artifact_store import load
from app.engine import analysis_claims as said
from app.engine.evidence import iter_units, ledger_enabled, resolve_ref, verify_doc
from app.engine.nodes.report import report_catalog, report_entities
out: dict[str, Any] = {"matched": [], "unmatched": [], "unresolved": [], "gaps": [],
"doc_artifact": None, "stats": None, "claims_policy": None, "uncited": None,
"unsupported": None, "claims": None, "entities": None, "queries": {}, "aliases": {},
"analysis": None, "analysis_skip": {}, "cited_metrics": [], "untraced": None}
gaps = out["gaps"]
spec = ctx.run.spec
node = spec.node_map().get(report_from)
if node is None or node.type != NodeType.REPORT:
gaps.append(f"出具契约的「报告来自」指向的「{report_from}」不是报告撰写节点,未执行引用核对")
return out
payload = (state.get("nodes") or {}).get(report_from)
doc_artifact = payload.get("doc_artifact") if isinstance(payload, dict) else None
if not doc_artifact:
gaps.append(f"报告撰写节点「{node.title}」没有产出报告文档(未执行、被跳过或失败),"
"未执行引用核对")
return out
out["doc_artifact"] = doc_artifact
try:
doc = load(doc_artifact)
except ValueError:
gaps.append(f"报告「{node.title}」的文档与其哈希不一致,可能已被修改,未执行引用核对")
return out
if not isinstance(doc, dict):
gaps.append(f"无法读取报告「{node.title}」的文档,未执行引用核对")
return out
# 文档记的是这次运行里的节点 id(子工作流里的报告带前缀,nodes/report 的 compose)
if doc.get("node_id") != ctx.run.global_id(str(report_from)) or doc.get("run_id") != ctx.run.run_id \
or doc.get("markdown") != payload.get("text"):
gaps.append(f"报告「{node.title}」的文档与本次运行中的产出不一致,未执行引用核对")
return out
catalog = report_catalog(state, spec, node)
checked = verify_doc(doc, catalog, allow_numbers=contract.get("allow_numbers"), cells_allowed=cells,
entities=report_entities(spec, node) == "link")
out["stats"] = checked["stats"]
broken: list[str] = []
flagged: set[str] = set()
suspicious: dict[str, list[dict[str, Any]]] = {"unknown_entity": [], "unverified_entity": []}
said_found: list[dict[str, Any]] = [] # 分析说法的违规(只有记了核对这一项的文档才有,_analysis_claims)
for v in checked["violations"]:
if v["code"] in said.CODES:
said_found.append(v)
continue
where = {k: v[k] for k in ("segment", "unit") if v.get(k)}
if v.get("span"):
where.update(start=v["span"][0], end=v["span"][1])
if v.get("segment"):
flagged.add(v["segment"])
if v["code"] == "uncited_number":
out["unmatched"].append({"token": v.get("text", ""), "context": v.get("context", ""), **where})
elif v["code"] == "unresolved_ref":
out["unresolved"].append({"ref": v.get("ref", ""), "message": v["message"], **where})
elif v["code"] in suspicious:
# 写 [[see:t:x]] 时违规上没有正文里的字,名字取自引用
name = v.get("text") or str(v.get("ref") or "").partition(":")[2]
suspicious[v["code"]].append({"name": name, **({"ref": v["ref"]} if v.get("ref") else {}), **where})
elif v["code"] in _INTEGRITY:
broken.append(v["message"])
if broken:
more = f"等 {len(broken)} 处" if len(broken) > 1 else ""
gaps.append(f"报告「{node.title}」的文档未通过复核:{broken[0]}{more}")
unknown, unverified = suspicious["unknown_entity"], suspicious["unverified_entity"]
if unknown and governed:
# 用户拍板:可疑实体在受管级别的正式出具里按「有缺口」降档,不拦截——反引号里的一个业务词
# 也可能被当成名字,为它不予出具太重;探索运行、已发布级别只标注
names = "".join(f"「{n}」" for n in dict.fromkeys(u["name"] for u in unknown[:5]))
more = "等" if len(unknown) > 5 else ""
gaps.append(f"报告「{node.title}」中有 {len(unknown)} 处可疑实体({names}{more}):本次运行的表结构、查询和"
"结果列中都没有这些名字,可能是编造的")
if unknown or unverified:
# 表结构快照不全时核对不了的名字(unverified)在哪个级别都只标注
out["entities"] = {"unknown": unknown[:_LISTED], "unverified": unverified[:_LISTED],
"counted": governed and bool(unknown)}
out["queries"], out["aliases"] = _cited_queries(doc, catalog)
# 报告直接引用了「查询区间里这张表没有数据」的列的格(查询快照的 no_data):这些 0 不代表真的是 0,按缺口降档
gaps.extend(_cited_no_data(doc, catalog))
# 老前端要用的逐个数字出处,从复核通过的数字片段拼出来
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
if seg.get("kind") != "number" or not seg.get("ref") or seg.get("id") in flagged:
continue
cite = resolve_ref(str(seg["ref"]), catalog, cells_allowed=cells)
if cite["status"] != "resolved" or cite["rendered"] != seg.get("text"):
continue
entry = catalog.get(cite["alias"]) or {}
start, end = seg["span"]
hit: dict[str, Any] = {"token": seg["text"], "metric": cite["locator"].get("metric")}
if cite["kind"] == "metric":
label = " @ ".join(str(v) for v in (entry.get("caliber"), entry.get("version")) if v)
if label:
hit["caliber"] = label
elif cite["kind"] == "cell":
# 查询单元格:Q1.r0.gmv(全局编号,和 doc.catalog 一致);分析结论结果表的一格同一种写法(A3.r0.share)
hit["cell"] = f"{cite['alias']}.r{cite['locator']['row']}.{cite['locator']['column']}"
elif cite["kind"] == LEDGER_KIND:
# 分析结论的具名标量([[a:A3.top_share]]):A3.top_share
key = (cite.get("locator") or {}).get("key")
hit["analysis"] = f"{cite['alias']}.{key}" if key else str(cite["alias"])
else:
hit["input"] = cite["locator"].get("field")
out["matched"].append({**hit, "segment": seg["id"], "unit": unit["id"], "start": start, "end": end,
"span": [start, end], "eid": cite["eid"]})
_claims(out, contract, payload, evidence_on=ledger_enabled(ctx.run), governed=governed,
uncited=checked.get("uncited") or [], doc=doc, title=node.title)
_analysis_claims(out, doc, payload, said_found, title=node.title, node_id=str(report_from))
# 报告引用的指标([[m:]] 片段和句末依据里的 m:):可比性按它们看(_apply_contract);输入追不到原始数据的,受管级别的
# 正式运行记缺口(降档,和证据面板「追不到原始数据」的说法一致:代码节点自称取数也不能完整出具),其余写进出具声明
out["cited_metrics"] = _cited_metrics(doc, catalog, state)
if stray := _untraced(out["cited_metrics"], ctx):
if governed:
gaps.extend(u["gap"] for u in stray)
out["untraced"] = {"metrics": [{k: u[k] for k in ("metric", "name", "stops")} for u in stray[:_LISTED]],
"counted": governed}
mine = next((r for r in reports if r["node"].id == report_from), None)
edited = mine["edited"] if mine else []
for name in edited:
gaps.append(f"成果字段「{name}」改动了报告,无法逐段对应")
if not (mine and mine["verbatim"]) and not edited:
gaps.append(f"成果中没有任何字段是报告「{node.title}」的原文:读者看到的内容未经过引用核对")
return out
#: 没挂依据的结论句怎么处置,从松到严。认不出的写法按 degrade 算:写错一个词不能让缺口悄悄消失
_ON_UNCITED = ("ignore", "degrade", "withhold")
#: 证据相矛盾的结论句怎么处置(claims: judge 的 on_unsupported),从松到严。认不出的写法同样按 degrade 算
_ON_UNSUPPORTED = ("degrade", "withhold")
#: 要求结论句挂依据的策略:judge 在这一点上和 require_citation 一样严,另外再按裁判的判定判档
_CITING = ("require_citation", "judge")
#: 报告的正文写在代码块里、一句结论都没切出来时的缺口(_fenced_body)。不带核查节点的前缀:界面归在「校验未全部完成」下
GAP_FENCED_BODY = "报告「{title}」的正文写在代码块里,结论句未能核对"
#: 写的是文字的代码块:信息串为空、markdown、md(报告节点去整篇外层围栏时认的那几个,report._PROSE_INFO),另加 text、
#: plaintext、txt、plain 这几种纯文字的写法。sql、python、json 这类是模型真要贴的代码
_PROSE_FENCES = frozenset({"", "markdown", "md", "text", "plaintext", "txt", "plain"})
_CJK = re.compile(r"[\u4e00-\u9fff]")
#: 中文的句读:一行里有汉字、又有它们的,是在写句子(SQL 里的中文列名、别名不带这些)
_CJK_PUNCT = re.compile(r"[,。!?;:]")
#: 一行里连着六个以上汉字:信息串写明是文字的块里,这就是在写中文。表格的列名、地区名、门店名这类标签多在五个字以内
_CJK_RUN = re.compile(r"[\u4e00-\u9fff]{6,}")
#: 英文的词串:单词之间只隔空格、逗号。五个以上的单词紧跟句末标点算一句(_en_sentence)
_EN_RUN = re.compile(r"[A-Za-z]+(?:[ ,]+[A-Za-z]+)*")
_EN_WORD = re.compile(r"[A-Za-z]+")
#: 整行是注释(-- // # /* */ 开头),和行尾隔着空白的注释:写着中文句子也是代码的一部分。# 开头的 Markdown 标题本来
#: 也不算结论句(evidence._scan_blocks 把它切成 heading)
_COMMENT_LINE = re.compile(r"\s*(?:--|//|#|/\*|\*/)")
_TRAILING_COMMENT = re.compile(r"\s(?:--|//|#).*")
#: 看得出是代码的行里,-- // 紧挨着前面的字也是注释(SELECT SUM(amount)--按区域汇总):字符串已经先去掉了
_CODE_COMMENT = re.compile(r"(?:--|//|\s#).*")
#: 写明是文字的块里顶格写的 Markdown 标题:标题前后是两段(_code_lines)
_MD_HEADING = re.compile(r"#{1,6}\s")
#: 代码里的字符串字面量('…'、"…",可带 f、r、b、u、N 这类前缀):里面的中文标点是数据。引号前面紧挨着字母、数字的
#: 不算开头(英文的 it's、don't)
_STRING_LITERAL = re.compile(r"(?<![A-Za-z0-9_])[rRbBuUfFnN]{0,2}(?:'[^'\n]*'|\"[^\"\n]*\")")
#: 可以跨行的块注释、多行字符串:开头的写法,和它对应的收尾
_SPAN_OPEN = re.compile(r"/\*|\"\"\"|'''")
_SPAN_CLOSE = {"/*": "*/", '"""': '"""', "'''": "'''"}
#: SQL、Python 语句的开头。SQL 的关键字只认全小写、全大写的写法:「From 数据看……」「Update ……」这类首字母大写的是
#: 正文里的英文词;def、class 要写成定义的样子(def 名字(、class 名字: ),「class A 门店……」说的是门店的分类
_CODE_LINE = re.compile(r"\s*(?:(?:select|with|from|where|join|group\s+by|order\s+by|having|union|insert|update|delete"
r"|create|import|return|SELECT|WITH|FROM|WHERE|JOIN|GROUP\s+BY|ORDER\s+BY|HAVING|UNION|INSERT"
r"|UPDATE|DELETE|CREATE)\b|(?:def|class)\s+[A-Za-z_]\w*\s*[(:])")
#: YAML 的键(「columns:」「- name: 退款占比」),只认小写开头的:正文里的「Note:」「Q1:」是标签。值为空、下面缩进着
#: 列表或子键的另记一种(_YAML_NEST)
_YAML_KEY = re.compile(r"\s*(?:-\s+)?[a-z_][\w.-]*:(?:\s|$)")
_YAML_NEST = re.compile(r"\s*(?:-\s+)?[a-z_][\w.-]*:\s*$")
#: 块里再嵌的代码块:~~~ 或三个以上反引号开头。compose_doc 只认整行三个反引号的围栏,那样的一行外层就收了,块里剩下的
#: 嵌套写法是 ~~~sql、````sql 这些
_NESTED_FENCE = re.compile(r"\s*(`{3,}|~{3,})(.*)")
#: 像数据、公式的一行:前面有字的等号(公式、赋值、比较)和箭头(连起的流程),画目录树的制表符,JSON 的对象、数组开头
#: ({"、[")和「"键":」,函数调用(print(、groupby(),带扩展名的文件名(周报.xlsx)。行首的「→ 」「=> 」是列表的
#: 记号,不算
_DATA_LINE = re.compile(r"\S\s*(?:[==→⟶⇒]|->)|[\u2500-\u257f]|^\s*[\[{]\s*[\"'\[{]|[\"']\s*:"
r"|(?<![\w.])[A-Za-z_][\w.]*\(|(?<=[^\s.])\.[A-Za-z][A-Za-z0-9]{1,4}(?![\w.])")
def _en_sentence(line: str) -> bool:
"""一句英文:五个以上的单词(之间只隔空格、逗号)紧跟句末标点,后面是空白或行尾。按词串逐个数,不靠正则回溯:
回溯的写法遇上很长、不带句末标点的一行,耗时随行长平方增长,出口复核又是在事件循环上同步执行的。"""
for run in _EN_RUN.finditer(line):
end = run.end()
if line[end:end + 1] in (".", "!", "?") and (end + 1 == len(line) or line[end + 1].isspace()) \
and len(_EN_WORD.findall(run.group())) >= 5:
return True
return False
def _sentence(line: str) -> bool:
"""成句:两个以上汉字又有中文句读,或者是一句英文。"""
return (len(_CJK.findall(line)) >= 2 and _CJK_PUNCT.search(line) is not None) or _en_sentence(line)
def _concluding(line: str) -> bool:
"""按正文的规矩这一行会算一句结论(evidence._classify 认文字结论的那条):带方向词、因果词,又不只是在下定义。
只认有两个以上汉字的行:drop、rise 这类英文词在代码里也常见。数字不看,代码块里的数字报告节点按裸数字查。"""
from app.engine.evidence import _CAUSAL, _DEFINITION, _DIRECTIONAL
return len(_CJK.findall(line)) >= 2 and _DIRECTIONAL.search(line) is not None \
and not (_DEFINITION.search(line) is not None and _CAUSAL.search(line) is None)
def _written(line: str) -> bool:
"""信息串写明是文字(markdown、text 这类)的块里,不是代码的这一行在写正文:成句、按正文的规矩会算结论,或者一行里
连着六个以上汉字。不看列表标记:标记的写法太多(- + 1. 1、 一、 (1) · ●,也常不带标记),补一种漏一种;也不去
字符串、行尾注释,# -- 和半角引号都是正文里的符号。整行是注释、标题的不算。"""
if _COMMENT_LINE.match(line):
return False
return _sentence(line) or _CJK_RUN.search(line) is not None or _concluding(line)
def _prose_line(line: str, *, code: bool) -> bool:
"""代码的行(code),或者信息串为空的块里的行,在写正文:去掉字符串字面量和行尾注释之后成句;不是代码的行另认按正文
的规矩会算结论的(不带句读的列表项、不带标记的行)。不带句读、也不像结论的不算:YAML 里的列名、diff 里改的口径和
列表项没有分别。整行是注释的不算。"""
if _COMMENT_LINE.match(line):
return False
line = (_CODE_COMMENT if code else _TRAILING_COMMENT).sub("", _STRING_LITERAL.sub(" ", line), count=1)
return _sentence(line) or (not code and _concluding(line))
def _blank_spans(text: str) -> str:
"""块注释(/* … */)、三个引号括起的多行字符串涂成空格,换行留着,块里还是原来那几行:里面写着中文句子也是代码的
一部分。没收尾的不动,开头那一行照注释行跳过。"""
out: list[str] = []
at = 0
while (start := _SPAN_OPEN.search(text, at)) is not None:
end = text.find(_SPAN_CLOSE[start.group()], start.end())
if end == -1:
break
stop = end + len(_SPAN_CLOSE[start.group()])
out += [text[at:start.start()], "\n".join(" " * len(part) for part in text[start.start():stop].split("\n"))]
at = stop
return "".join(out) + text[at:]
def _diff(lines: list[str], *, labelled: bool) -> bool:
"""整块是 diff:有 - 开头的行也有 + 开头的行,其余是空格开头的上下文行、@@ 开头的块头。diff 里一处改动先列删掉的行、
再列加上的行,第一个改动行就是 + 的,是 + / - 开头的正负面要点(有 @@ 块头的照旧算 diff)。信息串写明是文字的块里,
+ - 后面空一格的是 Markdown 的列表项,只有紧挨着字写的(-按下单日期统计)才是 diff。"""
heads = [line[:1] for line in lines if line.strip()]
if not {"+", "-"} <= set(heads) <= {"+", "-", " ", "@"}:
return False
if labelled and any(line[:2] in ("+ ", "- ") for line in lines):
return False
return "@" in heads or next(h for h in heads if h in ("+", "-")) == "-"
def _data_like(line: str) -> bool:
"""像数据、公式的一行(_DATA_LINE),或者逗号隔开的一行表头、数据(两个以上半角逗号,逗号后面都不空格)。"""
return _DATA_LINE.search(line) is not None or (line.count(",") >= 2 and ", " not in line)
def _yaml(lines: list[str], *, labelled: bool) -> bool:
"""一段是 YAML:第一行是小写开头的键,其余每一行都是键或者缩进的行(键下面的列表项、子键),注释行不看。写明是文字的
块里另要有值为空、往下缩进的键:一行一个「键: 一句话」的(「- note: 华南销售额超过华北」)多半是正文里的标签。"""
body = [line for line in lines if line.strip() and not _COMMENT_LINE.match(line)]
if not body or not _YAML_KEY.match(body[0]):
return False
if not all(_YAML_KEY.match(line) or line[:1].isspace() for line in body[1:]):
return False
return not labelled or any(_YAML_NEST.match(line) for line in body)
def _bare_fence(line: str) -> str:
"""整行只写着围栏(``` 或 ~~~,不带信息串)的,返回这串围栏,能给块里再嵌的代码块收尾;别的行返回空串。"""
fence = _NESTED_FENCE.match(line)
return fence.group(1) if fence and not fence.group(2).strip() else ""
def _code_lines(lines: list[str], raw: list[str], *, labelled: bool) -> list[bool]:
"""逐行标出哪些是代码:块里再嵌的代码块(~~~sql),SQL、Python 的语句(从开头那一行到这一段结束,续行也算;前面的
注释行本来就不算正文),整段是 YAML 的(_yaml),像数据、公式的行(_data_like)。
lines 是涂掉块注释、多行字符串之后的行,raw 是原样的行:一段到空行为止,只写着注释的行不算空行;写明是文字的块里,
顶格的标题也另起一段。不把整块当代码:正文里另贴一段 SQL、嵌一个 ~~~sql,或者有一行像 YAML 的键,别的行照样按正文认。
再嵌的代码块要收得了才算(后面有同一种符号、不比开头短的收尾),开了没收的那一行照普通的行看。"""
code = [False] * len(lines)
paragraph: list[int] = []
fence = ""
# 每一行后面最长的收尾围栏(整行只有 ``` 或 ~~~),按符号分开记:开了收不了的围栏不算,照普通的行看,免得后面的
# 正文都被当成代码
reach = {"`": [0] * (len(lines) + 1), "~": [0] * (len(lines) + 1)}
for k in range(len(lines) - 1, -1, -1):
for after in reach.values():
after[k] = after[k + 1]
if bare := _bare_fence(lines[k]):
reach[bare[0]][k] = max(reach[bare[0]][k], len(bare))
def close() -> None:
statement = _yaml([lines[k] for k in paragraph], labelled=labelled)
for k in paragraph:
statement = statement or _CODE_LINE.match(lines[k]) is not None
code[k] = statement or _data_like(lines[k])
paragraph.clear()
for k, line in enumerate(lines):
if fence:
code[k] = True
closing = _bare_fence(line)
if closing[:1] == fence[0] and len(closing) >= len(fence):
fence = ""
continue
if (opening := _NESTED_FENCE.match(line)) and reach[opening.group(1)[0]][k + 1] >= len(opening.group(1)):
close()
fence, code[k] = opening.group(1), True
continue
if not raw[k].strip() or (labelled and _MD_HEADING.match(line)):
close()
continue
paragraph.append(k)
close()
return code
def _fenced_body(doc: dict[str, Any]) -> bool:
"""报告的正文写在代码块里:文档里一句结论都没有,却有写文字的代码块(_PROSE_FENCES),里面写着引用(片段里的
[[m:…]] 这类标记、句子上挂的 [[see:…]] 依据,代码里不会这样写)或者在写正文。
报告节点只在整篇正好是一个代码块时去掉外层围栏(report._unfenced),围栏外有前言、只有开头没有收尾、信息串写成
text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据
的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。
在不在写正文逐行看。先标出哪些行是代码(_code_lines;信息串为空、整块是 diff 的全是代码),代码的行只认去掉注释、
字符串之后成句的(_prose_line),中文别名、YAML 的值、公式里的「最高」「占比」这类词都不算。其余的行:信息串写明
是文字的按 _written,信息串为空的块里也常是代码,去掉注释、字符串之后再看(_prose_line)。一块里只要有一行在写正文
就算,所以宁可把拿不准的行当代码:藏起来的正文总有别的行认得出,贴的一段代码、公式里却一行认错就会记错缺口。"""
from app.engine.evidence import iter_units
units = list(iter_units(doc))
if any(unit.get("kind") == "claim" for _, unit in units):
return False
for block, unit in units:
lang = str(block.get("lang") or "").lower()
if block.get("type") != "code" or lang not in _PROSE_FENCES:
continue
segments = [s for s in unit.get("segments") or [] if isinstance(s, dict)]
if unit.get("see") or any(s.get("ref") for s in segments):
return True
# 围栏那两行是挂在块上的结构片段,不是块里写的内容:算进来的话,开头那行 ```markdown 会被当成块里再嵌的代码块,
# 整块是不是 diff 也看不准
text = "".join(str(s.get("text") or "") for s in segments if s.get("kind") != "structural")
raw, lines = text.split("\n"), _blank_spans(text).split("\n")
code = [True] * len(lines) if _diff(raw, labelled=bool(lang)) else _code_lines(lines, raw, labelled=bool(lang))
if any(_written(line) if lang and not is_code else _prose_line(line, code=is_code)
for line, is_code in zip(lines, code)):
return True
return False
def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, Any], *, evidence_on: bool,
governed: bool = False, uncited: list[dict[str, Any]], doc: dict[str, Any] | None = None,
title: str = "") -> None:
"""结论句策略:没挂依据的、裁判判为证据不支持的结论句按 claims_policy 交给 decide_tier 判档。
两处可以要求挂依据:报告节点写作时记下的 claims(节点产出里的,写作提示按它要求过),和契约自己写的
require_citation / judge(对象形式可以另写 on_uncited)。两处都写了取更严的那个——契约只能收紧、不能放松:
报告节点要求了,契约写 ignore 或 off 也照旧计入缺口,写 withhold 就收紧成不予出具。受管级别的正式运行
里 ignore 一律当 degrade:没有哪道门禁查契约里的 claims,不能让它把用户要的底线拉低。
报告节点写的是 judge 时,判定在文档里(按哈希取回的那份:判定是模型给的,出口复算不了,只能信封存的
文档)。节点里判过的(正式运行,以及交给人工审批的探索运行):证据相矛盾的(contradicted,旧文档里的 unsupported 同样算)按 on_unsupported
判档(报告节点和契约取更严的);证据不足的(insufficient)记一条缺口——最多降档,不会不予出具,也不会
完整出具;没判完的(裁判没跑成、触顶)把裁判摘要里的缺口照抄进来——不能判完整出具,也不当成「矛盾」。
其余探索运行按需裁判,这里只标注。契约要 judge、报告节点却没开的,照实记缺口:出口替不了报告节点去裁判。
数的是这里重新核对出来的 uncited,不信文档里记的 cites。升级前写的报告(节点产出里没有 claims 这个键,
包括跨着升级还没跑完的运行)一律不管,契约写了也只记一条缺口:报告生成于升级前,结论句未核对。
要核对结论句、正文却写在代码块里(一句结论都没切出来,_fenced_body)的,另记一条缺口:数出 0 句不是「核对过、
没问题」。
"""
written = payload.get("claims") if isinstance(payload, dict) else None
# 报告节点升级后才在产出里记 claims(off 也记):没有这个键就是升级前写的报告
upgraded = evidence_on and isinstance(payload, dict) and "claims" in payload
declared = contract.get("claims")
name = declared.get("policy") if isinstance(declared, dict) else declared
asks: list[str] = []
if upgraded and written in _CITING:
asks.append("degrade")
if upgraded and name in _CITING:
wanted = declared.get("on_uncited") if isinstance(declared, dict) else None
asks.append(wanted if wanted in _ON_UNCITED else "degrade")
elif name not in (None, "", "off"):
out["gaps"].append("出具契约要求核对结论句,但这份报告生成于系统升级前,没有结论句核对记录,结论句未核对")
judged = upgraded and written == "judge"
if upgraded and name == "judge" and not judged:
out["gaps"].append(f"出具契约要求结论句由模型裁判,但报告「{title}」未开启模型裁判,结论句未裁判")
if not asks:
return
if doc is not None and _fenced_body(doc):
# 正文在代码块里,一句结论都没切出来:没挂依据的、证据相矛盾的都是 0 句,不能当成「核对过、没问题」
out["gaps"].append(GAP_FENCED_BODY.format(title=title))
on_uncited = max(asks, key=_ON_UNCITED.index)
if governed and on_uncited == "ignore":
on_uncited = "degrade"
extra = {"on_uncited": on_uncited} if on_uncited != "degrade" else {}
if not judged:
policy: Any = "require_citation" if on_uncited == "degrade" else \
{"policy": "require_citation", "on_uncited": on_uncited}
out["claims_policy"], out["uncited"] = policy, uncited
out["claims"] = {"policy": "require_citation", "uncited_claims": len(uncited), "uncited": uncited[:_LISTED],
**extra}
return
from app.engine.evidence import iter_units
from app.engine.judge import COUNT_KEYS, UNSUPPORTED
summary = (doc or {}).get("judge")
if not isinstance(summary, dict):
# 报告节点开了 judge、文档里却没有裁判摘要(不该发生):当作没判,不能判完整出具
summary = {"mode": "inline", "counts": {}, "unjudged": {}, "limits_hit": [], "complete": False,
"gaps": ["已开启模型裁判,但文档中没有裁判结果,结论句未裁判"]}
asked = [summary.get("on_unsupported")]
if name == "judge" and isinstance(declared, dict):
asked.append(declared.get("on_unsupported"))
on_unsupported = max((a if a in _ON_UNSUPPORTED else "degrade" for a in asked), key=_ON_UNSUPPORTED.index)
inline = summary.get("mode") == "inline"
flagged: dict[str, list[dict[str, Any]]] = {s: [] for s in ("contradicted", "insufficient", UNSUPPORTED, "partial")}
if inline:
# 按需裁判的判定不在文档里(在封存之后追加的 evidence.judged 事件里),这里只数节点里判过的
markdown = str((doc or {}).get("markdown") or "")
for _, unit in iter_units(doc or {}):
verdict = unit.get("verdict")
status = verdict.get("status") if isinstance(verdict, dict) else None
if status in flagged:
span = unit.get("span") or [0, 0]
flagged[status].append({"unit": unit.get("id"), "span": span, "text": markdown[span[0]:span[1]],
"rationale": verdict.get("rationale") or "",
**({"missing": verdict["missing"]} if status == "insufficient"
and verdict.get("missing") else {})})
out["gaps"].extend(f"报告「{title}」{gap}" for gap in summary.get("gaps") or [])
if lacking := flagged["insufficient"]:
# 证据不足按缺口处理:最多降档,不因 on_unsupported 不予出具,也不能完整出具
missing = list(dict.fromkeys(i["missing"] for i in lacking if i.get("missing")))
what = f"(缺少:{'、'.join(missing[:5])}{'等' if len(missing) > 5 else ''})" if missing else ""
out["gaps"].append(f"报告「{title}」有 {len(lacking)} 句结论证据不足{what}:裁判未能在证据中找到判断所需的"
"信息,这些结论未经核实")
counts = {k: int((summary.get("counts") or {}).get(k) or 0) for k in COUNT_KEYS}
# 证据相矛盾的按 on_unsupported 判档;旧文档里的 unsupported(当时没分矛盾和不足)照旧当 contradicted
contradicted = [*flagged["contradicted"], *flagged[UNSUPPORTED]]
out["claims_policy"] = {"policy": "judge", "on_unsupported": on_unsupported, "on_uncited": on_uncited}
out["uncited"], out["unsupported"] = uncited, contradicted
out["claims"] = {
"policy": "judge", "uncited_claims": len(uncited), "uncited": uncited[:_LISTED], **extra,
"on_unsupported": on_unsupported, "mode": "inline" if inline else "on_demand", "counts": counts,
"contradicted": flagged["contradicted"][:_LISTED], "insufficient": flagged["insufficient"][:_LISTED],
"unsupported": flagged[UNSUPPORTED][:_LISTED], "partial": flagged["partial"][:_LISTED],
"unjudged": dict(summary.get("unjudged") or {}), "limits_hit": list(summary.get("limits_hit") or []),
"complete": bool(summary.get("complete")), "model": summary.get("model"),
}
#: 报告节点 analysis_claims 的三种处置,从松到严
_ANALYSIS_ORDER = ("off", "check", "strict")
def _analysis_claims(out: dict[str, Any], doc: dict[str, Any], payload: Any, found: list[dict[str, Any]], *,
title: str, node_id: str) -> None:
"""分析说法核对的出口独立复核(00-总方案 §3.5、D9):found 是 verify_doc 这次重新核出的说法违规(只有文档记了
analysis_claims 那一项才核,老文档复核结果不变)。
处置取报告文档记下的和报告节点产出里写的更严的那个:strict 有违规就记一条缺口(降档),报告节点报上来的同一条
(产出的 analysis_gap)不再重复收;check 只写进出具声明作标注、不影响档位。要求 strict、文档里却没有核对记录的
(不该发生),照实记一条缺口:出口替不了报告节点去核。结果放进 out["analysis"](出具声明的 analysis_claims),
strict 和有违规时才有。"""
from app.engine import analysis_claims as said
found_mode = said.recorded(doc)
written = payload.get("analysis_claims") if isinstance(payload, dict) else None
modes = [m for m in (found_mode[0] if found_mode else None, written) if m in _ANALYSIS_ORDER]
mode = max(modes, key=_ANALYSIS_ORDER.index) if modes else None
if mode in (None, "off"):
return
if found_mode is None:
if mode == "strict":
out["gaps"].append(f"报告「{title}」要求严格核对分析说法,但文档中没有分析说法的核对记录,分析说法未核对")
return
counted = mode == "strict"
if found and counted:
out["gaps"].append(f"报告「{title}」有 {len(found)} 处分析说法未通过核对({said.summary(found)}):"
"判断要挂对应的分析结论并照判定写,原因要有知识库原话或运行输入作依据")
if isinstance(payload, dict) and isinstance(payload.get("analysis_gap"), str):
out["analysis_skip"] = {node_id: {payload["analysis_gap"]}}
if found or counted:
keep = ("code", "text", "claim", "op", "analyses", "unit", "segment", "span", "message", "context")
out["analysis"] = {"mode": mode, "count": len(found), "counts": said.counts(found), "counted": counted,
"violations": [{k: v[k] for k in keep if v.get(k) not in (None, "", [])}
for v in found[:_LISTED]]}
def _cited_metrics(doc: dict[str, Any], catalog: dict[str, Any], state: GraphState) -> list[dict[str, Any]]:
"""报告引用了的指标([[m:]] 片段和句末 [[see:m:…]]):口径卡产出里的那一条(带 inputs、comparability),按引用的先后、
同一张卡的同一个指标只算一次。目录里认不出、卡的产出里找不到的跳过。"""
from app.engine.evidence import iter_units
nodes = state.get("nodes") or {}
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for _, unit in iter_units(doc):
cites = [s.get("cite") for s in unit.get("segments") or [] if s.get("ref") and not s.get("auto")]
for cite in [*cites, *(unit.get("see") or [])]:
if not isinstance(cite, dict) or cite.get("kind") != "metric" or cite.get("status") != "resolved":
continue
entry = catalog.get(cite.get("alias")) or {}
card, mid = str(entry.get("node_id") or ""), str((entry.get("locator") or {}).get("metric") or "")
if entry.get("kind") != "metric" or not card or not mid or (card, mid) in seen:
continue
seen.add((card, mid))
payload = nodes.get(card)
metric = next((m for m in (payload.get("metrics") or []) if isinstance(m, dict) and str(m.get("id")) == mid),
None) if isinstance(payload, dict) else None
if metric is not None:
out.append(metric)
return out
def _cited_no_data(doc: dict[str, Any], catalog: dict[str, Any]) -> list[str]:
"""报告直接引用的格里,落在「查询区间里这张表没有数据」的列上的(查询快照的 no_data,data/coverage.py):按列一句缺口
「报告引用的 Q1「上周到访人数」:所依据的查询区间里这张表没有数据(…)」。读不出来的快照不算。"""
from app.core.artifact_store import load
from app.data.coverage import NO_DATA_SHORT, no_data_of
from app.engine.evidence import iter_units
snapshots: dict[str, Any] = {}
out: list[str] = []
seen: set[tuple[str, str]] = set()
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") or {}
if cite.get("kind") != "cell" or cite.get("status") != "resolved" or not cite.get("alias"):
continue
alias = str(cite["alias"])
column = str((cite.get("locator") or {}).get("column") or "")
artifact = (catalog.get(alias) or {}).get("artifact")
if not column or not isinstance(artifact, str) or (alias, column) in seen:
continue
seen.add((alias, column))
if artifact not in snapshots:
try:
snapshots[artifact] = load(artifact)
except (ValueError, OSError):
snapshots[artifact] = None
if (found := no_data_of(snapshots[artifact], column)) is not None:
out.append(f"报告引用的 {alias}「{column}」:{NO_DATA_SHORT}({found.get('message') or ''})")
return out
def _cited_queries(doc: dict[str, Any], catalog: dict[str, Any]) -> tuple[dict[str, list[str]], dict[str, str]]:
"""报告直接引用的查询:({查询快照 id: [引用的写法…]}, {目录里每个查询的快照 id: 编号})。
单元格([[v:Q1.r0.gmv]],[[table:]] 展开后也是一格一格的引用)按查询数:一格写「Q1 第 1 行「gmv」」,
几格写「Q1 的 3 格」;只拿它当依据([[see:Q1]])的写「Q1(结论依据)」。出具契约据此判这些查询有没有通过
SQL 检查(issuance.sql_check_gaps)。
分析结论按它的工件记(sql_problems.query_problems 顺着结论的输入找到查询):结果表的格同上(A3 第 1 行「share」);
判定语、具名标量写「A3(判定语)」「A3「top_share」」,当依据([[see:A3]])的写「A3(结论依据)」——引用了一份分析
结论,它的输入查询上的问题照样计入缺口。
"""
from app.engine.evidence import iter_units
cells: dict[str, list[tuple[Any, Any]]] = {}
said: dict[str, list[str]] = {} # 分析结论的判定语、具名标量:{A 编号: [键或 None…]}
support: list[str] = []
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") or {}
if cite.get("status") != "resolved" or not cite.get("alias"):
continue
if cite.get("kind") == "cell":
loc = cite.get("locator") or {}
cells.setdefault(str(cite["alias"]), [])
if (loc.get("row"), loc.get("column")) not in cells[str(cite["alias"])]:
cells[str(cite["alias"])].append((loc.get("row"), loc.get("column")))
elif cite.get("kind") == LEDGER_KIND:
keys = said.setdefault(str(cite["alias"]), [])
key = (cite.get("locator") or {}).get("key")
if key not in keys:
keys.append(key)
for cite in unit.get("see") or []:
alias = str((cite or {}).get("alias") or "")
if (catalog.get(alias) or {}).get("kind") in ("query", LEDGER_KIND) and alias not in support:
support.append(alias)
queries: dict[str, list[str]] = {}
# 目录里全部查询的编号:合并查询的输入没被直接引用,缺口里也要写得出它是 Q 几
aliases = {str(e["artifact"]): str(e["alias"]) for e in catalog.values()
if isinstance(e, dict) and e.get("kind") == "query" and e.get("artifact") and e.get("alias")}
for alias in dict.fromkeys([*cells, *said, *support]):
artifact = (catalog.get(alias) or {}).get("artifact")
if not isinstance(artifact, str) or not artifact:
continue
hit = cells.get(alias) or []
refs = []
if len(hit) == 1 and isinstance(hit[0][0], int):
refs.append(f"{alias} 第 {hit[0][0] + 1} 行「{hit[0][1]}」")
elif hit:
refs.append(f"{alias} 的 {len(hit)} 格")
refs.extend(f"{alias}「{key}」" if key else f"{alias}(判定语)" for key in said.get(alias) or [])
queries.setdefault(artifact, []).extend(refs or [f"{alias}(结论依据)"])
return queries, aliases
def _declare_citations(
tier: str, cited: dict[str, Any], *, calibers: list[dict[str, str]], metrics: list[dict[str, Any]],
gaps: list[str], missing_required: list[str], missing_expected: list[str], report_from: str,
declared_at: Any, ctx: NodeContext, sql_checks: list[dict[str, Any]] | None = None,
comparability: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。"""
# 节点 id 和报告文档、report.checked 事件上记的同一个(这次运行里的,子工作流里的带前缀)
report = {"node_id": ctx.run.global_id(report_from), "doc_artifact": cited["doc_artifact"]}
issuance = {
"mode": "citations",
"tier": tier,
"report": report,
"calibers": calibers,
"metrics_checked": len(metrics),
"missing_required": missing_required,
"missing_expected": missing_expected,
"unmatched_numbers": cited["unmatched"][:20],
"unresolved": cited["unresolved"][:20],
"matched_numbers": len(cited["matched"]),
"matched": cited["matched"][:50],
"stats": cited["stats"],
"gaps": gaps,
"declared_at": declared_at.isoformat(),
# 结论句策略和可疑实体:只在用上时才有这两个键,升级前的运行和没用上的契约形状不变
**({"claims": cited["claims"]} if cited.get("claims") else {}),
**({"entities": cited["entities"]} if cited.get("entities") else {}),
# 没通过 SQL 检查的查询(结构化):报告页据此在显眼处说明降档原因。没有就不加这个键
**({"sql_checks": sql_checks} if sql_checks else {}),
# 分析说法核对(strict,或者有违规时)、指标的可比性、报告引用的指标里追不到原始数据的:同样只在有的时候带
**({"analysis_claims": cited["analysis"]} if cited.get("analysis") else {}),
**({"comparability": comparability} if comparability else {}),
**({"untraced": cited["untraced"]} if cited.get("untraced") else {}),
}
entities = cited.get("entities")
ctx.emit(
EventType.ISSUANCE,
mode="citations",
tier=tier,
report=report,
missing_required=missing_required,
missing_expected=missing_expected,
unmatched=len(cited["unmatched"]),
unresolved=len(cited["unresolved"]),
calibers=calibers,
gaps=gaps,
**({"sql_checks": sql_checks} if sql_checks else {}),
metrics_checked=len(metrics),
matched_numbers=len(cited["matched"]),
matched=cited["matched"][:50],
# on_uncited 只在不是默认的 degrade 时才有(收紧成 withhold、探索运行的契约写了 ignore);
# judge 另带不支持时怎么判档、在哪判的和各判定的句数(名单只在出具声明里)
**({"claims": {k: cited["claims"][k] for k in ("policy", "uncited_claims", "on_uncited", "on_unsupported",
"mode", "counts")
if k in cited["claims"]}} if cited.get("claims") else {}),
# 计数取复核的统计(声明里的名单最多列 _LISTED 个)
**({"entities": {"unknown": (cited["stats"] or {}).get("unknown_entities", len(entities["unknown"])),
"unverified": (cited["stats"] or {}).get("unverified_entities", len(entities["unverified"])),
"counted": entities["counted"]}} if entities else {}),
# 分析说法:处置、几处、记没记缺口(名单只在出具声明里)
**({"analysis_claims": {k: cited["analysis"][k] for k in ("mode", "count", "counted")}}
if cited.get("analysis") else {}),
)
return issuance
async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""数据整形。用受限表达式把上游输出揉成下游要的形状,不用为此专门起一个 LLM。"""
tctx = template_context(state)
mode = ctx.cfg("mode", "expression")
output: Any
if mode == "expression":
expr = ctx.cfg("expression", "")
if not expr:
raise NodeError(ctx.node.id, "「数据整形」节点还没有填写表达式")
try:
output = eval_expression(expr, tctx)
except ExpressionError as e:
raise NodeError(ctx.node.id, f"表达式有误:{e}") from e
elif mode == "template":
output = ctx.render_str(ctx.cfg("template", ""), state)
elif mode == "json":
template = ctx.cfg("template", "{}")
rendered = ctx.render_str(template, state)
try:
output = json.loads(rendered)
except json.JSONDecodeError as e:
raise _json_error(e, str(template or ""), tctx, ctx) from e
else:
raise NodeError(ctx.node.id, f"不支持的整形方式:{mode}")
updates: dict[str, Any] = {"nodes": {ctx.node.id: output}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: output}
return updates
# --------------------------------------------------------------------------
# 按 JSON 解析失败:错在模板,还是错在上游模型写的文字
# --------------------------------------------------------------------------
#: 产出是模型写的文字的节点(llm 配了 output_schema 时 text 是系统序列化的,但那样也解析得了)
_MODEL_TEXT = (NodeType.AGENT, NodeType.LLM, NodeType.SUPERVISOR)
#: 技术细节里给出出错位置前后各多少个字
_NEAR = 30
_NODE_HEAD = re.compile(r"^nodes\s*(?:\.\s*([\w-]+)|\[\s*['\"]([^'\"]+)['\"]\s*\])")
_VAR_HEAD = re.compile(r"^vars\s*(?:\.\s*([\w-]+)|\[\s*['\"]([^'\"]+)['\"]\s*\])")
TEMPLATE_HINT = "请检查模板中的引号和逗号,字符串值需使用 | json 过滤器输出"
#: 解析器在这一段的头一个字上要的是这些:前一个值在模板里已经写完了,缺的是模板里的标点
_DELIMITED = ("Expecting ',' delimiter", "Expecting ':' delimiter", "Extra data")
def _json_error(e: json.JSONDecodeError, template: str, tctx: dict[str, Any], ctx: NodeContext) -> NodeError:
"""整形节点按 JSON 解析失败时的报错。
真实踩过:整个模板就是 {{ nodes.X.text }},agent 在 JSON 字符串里夹了没转义的英文双引号,
报错却让人去检查模板、加 | json,把人引到了错的方向。反过来也不行:出错的位置落在模型
写的那一段里,不等于错在模型——模板把一句话原样放进值的位置、放进引号里,或者紧挨着它
漏了逗号,都是模板的错,而且改法就是那句老提示。所以分三种说(见 _diagnose):
- 上游确实在写 JSON 而没写对:点名上游、按实情说原因、指向结构化输出;
- 模板少了 | json:点名是模板里哪一段,给出改好的写法;
- 其余:原来那句提示。
出错位置前后的原文只进技术细节(包在原异常里):首行是给人看的一句话,不贴模型的原文。
"""
doc, pos = e.doc, e.pos
near = (doc[max(0, pos - _NEAR):pos] + "⟨此处⟩" + doc[pos:pos + _NEAR]).replace("\n", "\\n")
e.args = (f"{e.args[0]}\n出错位置前后的原文:{near}",)
verdict = _diagnose(template, tctx, ctx, e)
if verdict is None or isinstance(verdict, str):
return NodeError(ctx.node.id, f"模板渲染出来的不是合法 JSON(第 {e.lineno} 行第 {e.colno} 列附近)。"
f"{verdict or TEMPLATE_HINT}")
node, text, offset, reason = verdict
line = text.count("\n", 0, offset) + 1
column = offset - (text.rfind("\n", 0, offset) + 1) + 1
where = f"第 {line} 行第 {column} 列" if line > 1 else f"第 {column} 列"
fix = ("如需模型输出结构化数据,请为其配置「结构化输出 Schema」;数字需要进入口径卡时,"
"请改用 Agent 并开启「按出处核对字段」"
if node.type == NodeType.LLM else
"如需模型输出结构化数据,请配置「结构化输出 Schema」并开启「按出处核对字段」,"
"不要用「数据整形」节点解析模型写的文字")
return NodeError(ctx.node.id, f"上游「{node.title}」输出的不是合法 JSON({where}附近),{reason};{fix}")
def _diagnose(template: str, tctx: dict[str, Any], ctx: NodeContext,
e: json.JSONDecodeError) -> tuple[GraphNode, str, int, str] | str | None:
"""按 JSON 解析失败,错在谁。
返回 (上游节点, 它写的那段文字, 出错位置在文字里的偏移, 原因) 表示错在上游;返回一句话
表示错在模板、而且说得出是哪一段;返回 None 表示照原来那句提示。
只有上游确实在写 JSON(去掉开头空白后以 { 或 [ 开头,或者包在 ``` 代码块里)、而且这一段
不在模板的引号里,才算上游的错;整个模板就是这一段时,模板不可能写错,也算上游的。
解析器停在这一段末尾时(紧跟着的逗号漏了),只有它自己开了括号、引号没收尾才怪它。
每个 {{ }} 单独渲染一次再拼起来,和 render_template 的整段替换结果逐字相同。
"""
from app.engine.expressions import _TEMPLATE_RE, render_template, resolve_path
doc, pos = e.doc, e.pos
at, last = 0, 0
for m in _TEMPLATE_RE.finditer(template):
at += m.start() - last
piece = render_template(m.group(0), tctx)
start, end = at, at + len(piece)
at, last = end, m.end()
expr = m.group(1).strip()
head, *filters = [p.strip() for p in expr.split("|")]
if not head or pos < start:
continue
# 解析器越过空白才报错:停在这一段后面、中间只隔着空白的,也是停在它末尾
at_end = pos >= end and not doc[end:pos].strip()
if not piece.strip():
# 取出来是空的,解析器正好在这里要一个值
if e.msg == "Expecting value" and not doc[start:pos].strip() and not _in_json_string(doc, start):
return f"模板里 {{{{ {expr} }}}} 的取值为空,此处缺少一个值:请检查路径是否正确、前面的节点是否有产出"
continue
if pos > end and not at_end:
continue
quoted = _in_json_string(doc, start)
if "json" in filters:
# 过了 | json 就是一个合法的 JSON 值:错不在它的内容里,除非外面又套了一层引号
return (f"模板里 {{{{ {expr} }}}} 的结果已是合法的 JSON(文字自带引号),外面不要再加引号"
if quoted and not at_end else None)
value = resolve_path(tctx, head)
if not isinstance(value, str) or value != piece:
return None
node = _producer(head, ctx)
model = node is not None and node.type in _MODEL_TEXT
lead = start + len(piece) - len(piece.lstrip())
if pos == lead and e.msg.startswith(_DELIMITED):
return None # 前一个值在模板里就写完了,缺的是它前面的标点
if template.strip() == m.group(0):
return (node, piece, pos - start, _why(piece, at_end)) if model else None
if at_end and not _unclosed(piece):
return None # 这一段本身完整,缺的是它后面的标点
if quoted:
return _needs_json(expr, model)
if _writes_json(piece):
return (node, piece, pos - start, _why(piece, at_end)) if model else None
return _needs_json(expr, model)
return None
def _needs_json(expr: str, model: bool) -> str:
what = "模型写的文字" if model else "一段文字"
return f"模板里 {{{{ {expr} }}}} 是{what},放入 JSON 时请写成 {{{{ {expr} | json }}}}(外面不要再加引号)"
def _why(text: str, at_end: bool) -> str:
"""上游写的 JSON 为什么解析不了,按看得出来的实情说。"""
body = text.lstrip()
if body.startswith("```"):
return "模型把 JSON 包在了 ``` 代码块中"
if at_end and _unclosed(text):
return "模型输出的 JSON 不完整,可能在中途被截断"
if not body.startswith(("{", "[")):
return "模型输出的是一段文字,而不是 JSON"
return "常见原因是字符串中有未转义的英文引号"
def _writes_json(text: str) -> bool:
"""这段文字看得出是想写 JSON:以 { 或 [ 开头,或者是包在 ``` 代码块里的 JSON。"""
body = text.lstrip()
if body.startswith("```"):
body = body.split("\n", 1)[1].lstrip() if "\n" in body else ""
return body.startswith(("{", "["))
def _in_json_string(doc: str, index: int) -> bool:
"""doc[:index] 结束时是不是在一个 JSON 字符串里。解析器已经越过了这里,这一段前缀是
合法的 JSON 开头,数引号(跳过转义)就数得准。"""
inside, i = False, 0
while i < index:
ch = doc[i]
if inside and ch == "\\":
i += 2
continue
if ch == '"':
inside = not inside
i += 1
return inside
def _unclosed(text: str) -> bool:
"""这段文字自己开了引号、括号却没收尾(单独看它,从字符串外面数起)。"""
depth, inside, i = 0, False, 0
while i < len(text):
ch = text[i]
if inside:
if ch == "\\":
i += 1
elif ch == '"':
inside = False
elif ch == '"':
inside = True
elif ch in "{[":
depth += 1
elif ch in "}]":
depth -= 1
i += 1
return inside or depth > 0
def _producer(path: str, ctx: NodeContext) -> GraphNode | None:
"""模板路径取的是哪个节点的产出:nodes.X 就是 X;vars.Y 是 assign_to 为 Y 的节点。"""
nodes = ctx.run.spec.node_map()
if m := _NODE_HEAD.match(path):
return nodes.get(m.group(1) or m.group(2))
if m := _VAR_HEAD.match(path):
var = m.group(1) or m.group(2)
writers = [n for n in ctx.run.spec.nodes if str(n.config.get("assign_to") or "").strip() == var]
# 好几个节点写同一个变量时说不准是谁,宁可给原来的提示也不点错名
return writers[0] if len(writers) == 1 else None
return None