mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:41:59 +00:00
898 lines
48 KiB
Python
898 lines
48 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
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)
|
||
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,
|
||
) -> dict[str, Any]:
|
||
from datetime import datetime, timezone
|
||
|
||
from app.engine.issuance import decide_tier, incomplete_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))
|
||
# 来源查询没通过 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] = {}
|
||
if citations:
|
||
cited = _check_citations(str(report_from), contract, state, ctx, reports or [], cells=cells,
|
||
governed=governed)
|
||
gaps.extend(cited["gaps"])
|
||
unmatched = cited["unmatched"]
|
||
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 []
|
||
|
||
# 同一条查询算出几个指标、报告又引用了它的格,只记一句(按出问题的那条查询归并,列出受影响的引用)
|
||
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))
|
||
|
||
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)
|
||
|
||
# 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」
|
||
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,
|
||
"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 {}),
|
||
}
|
||
ctx.emit(
|
||
EventType.ISSUANCE,
|
||
tier=tier,
|
||
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 _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]:
|
||
"""上游节点产出里的 issuance_gaps(字符串列表),每条前面加上是哪个节点说的。
|
||
|
||
这是给「把关类」节点留的通用口子:节点觉得这次的结论不该完整出具(双盲复算两路对不上、
|
||
反方审稿的反驳没有回应),在自己的产出里写 issuance_gaps,成果节点的出具契约照单收进缺口,
|
||
出具随之降档。写的时候不用带节点名,这里统一加上「双盲复算「复核销售额」:」这样的前缀,
|
||
和别的缺口一样读得出是谁报的。
|
||
|
||
只认这个成果节点的上游:另一条支路上的节点管不到这份成果。不是字符串、空白的条目跳过——
|
||
这个键谁都能写(数据整形的 JSON 模板也行),写坏了只当没写,不能让出具崩掉。
|
||
"""
|
||
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
|
||
prefix = f"{type_label(node.type)}「{node.title}」:"
|
||
out.extend(prefix + gap.strip() for gap in reported if isinstance(gap, str) and gap.strip())
|
||
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.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": {}}
|
||
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
|
||
if doc.get("node_id") != 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": []}
|
||
for v in checked["violations"]:
|
||
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)
|
||
|
||
# 老前端要用的逐个数字出处,从复核通过的数字片段拼出来
|
||
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 一致)
|
||
hit["cell"] = f"{cite['alias']}.r{cite['locator']['row']}.{cite['locator']['column']}"
|
||
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)
|
||
|
||
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")
|
||
|
||
|
||
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 这个键,
|
||
包括跨着升级还没跑完的运行)一律不管,契约写了也只记一条缺口:报告生成于升级前,结论句未核对。
|
||
"""
|
||
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
|
||
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"),
|
||
}
|
||
|
||
|
||
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)。
|
||
"""
|
||
from app.engine.evidence import iter_units
|
||
|
||
cells: dict[str, list[tuple[Any, Any]]] = {}
|
||
support: list[str] = []
|
||
for _, unit in iter_units(doc):
|
||
for seg in unit.get("segments") or []:
|
||
cite = seg.get("cite") or {}
|
||
if cite.get("kind") == "cell" and cite.get("status") == "resolved" and cite.get("alias"):
|
||
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")))
|
||
for cite in unit.get("see") or []:
|
||
alias = str((cite or {}).get("alias") or "")
|
||
if (catalog.get(alias) or {}).get("kind") == "query" 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 [*cells, *(a for a in support if a not in cells)]:
|
||
artifact = (catalog.get(alias) or {}).get("artifact")
|
||
if not isinstance(artifact, str) or not artifact:
|
||
continue
|
||
hit = cells.get(alias) or []
|
||
if len(hit) == 1 and isinstance(hit[0][0], int):
|
||
ref = f"{alias} 第 {hit[0][0] + 1} 行「{hit[0][1]}」"
|
||
else:
|
||
ref = f"{alias} 的 {len(hit)} 格" if hit else f"{alias}(结论依据)"
|
||
queries.setdefault(artifact, []).append(ref)
|
||
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,
|
||
) -> dict[str, Any]:
|
||
"""引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。"""
|
||
report = {"node_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 {}),
|
||
}
|
||
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 {}),
|
||
)
|
||
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
|