Files
AgentLab/backend/app/engine/nodes/io.py
T
ThearchyHelios 46eb674bc2 指标集与出具契约:口径卡节点、叙述数字回指、三档出具、周报模板
派生边界收死在固定层:新增 metrics 节点(口径卡),指标由 AST 白名单
求值器确定性计算,产出受控指标集(含口径名与版本)。叙述层没有算术
权限——不是求模型别编数,而是它编了也过不了校验。

output 节点配 contract 即升级为出具契约:核对必需/期望指标是否齐、
从叙述中抽取所有数字逐个回指指标集(支持千分位、百分数↔比率、
万/亿后缀、按书写精度给舍入容差;日期、ISO 周、枚举序号、引用标记
不参与回指),三档判定 formal / degraded / withheld,缺口清单和
口径版本一起印在成果上,另发 issuance 事件入流水。白名单(allow_numbers)
是模板配置的一部分,进版本、被审查。

新增模板⑧周报:取数固定 → 口径卡算数 → LLM 只许引用清单 → 出具
逐数回指,是'模板定骨架、agent 填关节'的完整示范。
回指校验器 10 用例 + 三档判定 + 端到端五场景全部通过。
2026-09-14 10:17:45 +08:00

160 lines
5.6 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
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]
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", ""),
}
)
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) 叙述数字回指
narrative = ctx.render_str(str(contract.get("narrative", "")), state)
trace = (
trace_numbers(narrative, metrics, allow=contract.get("allow_numbers"))
if narrative
else None
)
unmatched = trace.unmatched if trace else []
tier = decide_tier(
missing_required=missing_required,
missing_expected=missing_expected,
unmatched=unmatched,
strict=bool(contract.get("strict")),
)
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,
"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