mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:51:57 +00:00
2207269e07
_NUM_RE 的后瞻写的是 (?<![\w.]),而 Python 的 \w 按 Unicode 判定,汉字也算
词字符。中文写作数字紧贴汉字不加空格是常态,于是整个 token 抽不出来:
extract_numbers('本周营收9999元,订单7777单。') → [] 全部漏检
extract_numbers('本周营收 9999 元,订单 7777 单。') → ['9999','7777']
等于说"叙述层无算术权限 + 数字回指校验"这套机制只对西式排版生效,中文叙述
完全不设防,而出具物照样盖 formal 章、印 matched_numbers: 0——读的人无法
分辨是"查过都对"还是"根本没查"。改成只排 ASCII 词字符,版本号 v1.2.3、
标识符 sha1 这些仍然豁免。
顺带修同一文件里的两条 high:
## 跳过规则只看形态不看语境
年份规则的"年"字是可选的,等于把 1900–2099 的任何独立整数都免检了——而
客户数、门店数、万元金额大量落在这个区间。日期规则不限年份范围,"9600/12"
这种比值写法会被整段当日期吞掉。引用标记 \[\d+\] 不限位数,[98765] 也放行。
'本周新增客户 2050 家,流失 1987 家。' → [] 两个数字都免检
'全年目标 9600/12 个月' → [] 斜杠两边都免检
年份改成必须带"年"字,日期的年份限 19xx/20xx、月份限 1-12,引用标记限三位。
宁可误杀(要求它回指指标集)也不放过——这是校验不是排版。
## 校验没发生被当成校验通过
decide_tier 只看 missing/unmatched,而这些列表全空既可能是"查过了都对",
也可能是"根本没查":叙述模板里一个笔误让 narrative 渲染成空串、metrics_from
指向不存在的节点,都会得到一模一样的空列表,然后盖 formal 章。
contract.narrative = "{{ vars.does_not_exist }}" → tier=formal, matched=0
contract.metrics_from = "压根没这个节点" → tier=formal, metrics=0
新增 gaps 承载"校验为什么不完整"(叙述渲染为空、指标源未解析、指标集为空),
非空就不允许 formal,并把原因印进 issuance。校验没发生却盖 formal,比漏检
某个数字更糟:它让整套机制看起来在工作。
端到端四例验证:编造数字→withheld、真实数字→formal、模板写错→degraded
(带 gaps)、指标源不存在→withheld(带 gaps)。
185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
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.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", "")}
|
||
|
||
contract = ctx.cfg("contract")
|
||
if contract:
|
||
result["_issuance"] = _apply_contract(contract, state, ctx)
|
||
|
||
return {"output": result, "nodes": {ctx.node.id: result}}
|
||
|
||
|
||
def _apply_contract(
|
||
contract: dict[str, Any], state: GraphState, ctx: NodeContext
|
||
) -> dict[str, Any]:
|
||
from datetime import datetime, timezone
|
||
|
||
from app.engine.issuance import decide_tier, trace_numbers
|
||
|
||
nodes = state.get("nodes") or {}
|
||
|
||
# 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] = []
|
||
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", ""),
|
||
}
|
||
)
|
||
else:
|
||
# 节点不存在、还没跑到、或者根本不是口径卡——指标集就是缺的,
|
||
# 不能当作"这次没有指标要查"
|
||
unresolved.append(source)
|
||
if unresolved:
|
||
gaps.append(f"指标源未解析:{', '.join(unresolved[:5])}")
|
||
|
||
present = {m["id"] for m in metrics}
|
||
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]
|
||
|
||
# 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 []
|
||
|
||
# 声明了 metrics_from 却一个指标都没收到,同样是"没查成"
|
||
if sources and not metrics:
|
||
gaps.append("指标集为空,叙述里的数字无从回指")
|
||
|
||
tier = decide_tier(
|
||
missing_required=missing_required,
|
||
missing_expected=missing_expected,
|
||
unmatched=unmatched,
|
||
strict=bool(contract.get("strict")),
|
||
gaps=gaps,
|
||
)
|
||
|
||
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,
|
||
# 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查"
|
||
"gaps": gaps,
|
||
"declared_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
ctx.emit(
|
||
EventType.ISSUANCE,
|
||
tier=tier,
|
||
missing_required=missing_required,
|
||
missing_expected=missing_expected,
|
||
unmatched=len(unmatched),
|
||
calibers=calibers,
|
||
)
|
||
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":
|
||
rendered = ctx.render_str(ctx.cfg("template", "{}"), state)
|
||
try:
|
||
output = json.loads(rendered)
|
||
except json.JSONDecodeError as e:
|
||
raise NodeError(ctx.node.id, f"渲染结果不是合法 JSON:{e}") 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
|