Files
AgentLab/backend/app/engine/issuance.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

176 lines
5.9 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 re
from dataclasses import dataclass, field
from typing import Any
# --------------------------------------------------------------------------
# 出具校验:叙述里的每个数字必须能回指到指标集
#
# 前提是派生边界已经收死:所有算术都发生在口径卡(metrics 节点)里,
# 叙述层只能引用。所以这里不需要"验证模型算得对不对"——只需要验证
# "它写的每个数都在清单里"。集合外的数字没有合法来源,一律视为现编。
# --------------------------------------------------------------------------
# 数字 token:可带千分位逗号、小数,后缀 %/% 或 万/亿
_NUM_RE = re.compile(r"(?<![\w.])(-?\d[\d,]*(?:\.\d+)?)\s*(%|%|万亿|亿|万)?")
# 不参与回指的数字形态——这些不是"结论数字"
_SKIP_PATTERNS = [
re.compile(r"\d{4}\s*[-/年]\s*\d{1,2}(\s*[-/月]\s*\d{1,2}\s*日?)?"), # 日期
re.compile(r"(?<!\d)(19|20)\d{2}\s*年?(?!\d)"), # 年份
re.compile(r"\d{4}-W\d{1,2}", re.IGNORECASE), # ISO 周(2026-W37)
re.compile(r"^\s*\d{1,2}[.、)]\s", re.MULTILINE), # 行首枚举 "1. " "2、"
re.compile(r"\[\d+\]"), # 引用标记 [1]
re.compile(r"\d{1,2}:\d{2}(:\d{2})?"), # 时间
]
_SUFFIX_MULT = {"万": 1e4, "亿": 1e8, "万亿": 1e12}
@dataclass
class NumberToken:
raw: str
value: float
decimals: int
is_percent: bool
context: str
start: int
@dataclass
class TraceReport:
matched: list[dict[str, Any]] = field(default_factory=list)
unmatched: list[dict[str, Any]] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.unmatched
def _skip_spans(text: str) -> list[tuple[int, int]]:
spans: list[tuple[int, int]] = []
for pattern in _SKIP_PATTERNS:
for m in pattern.finditer(text):
spans.append(m.span())
return spans
def extract_numbers(text: str) -> list[NumberToken]:
"""抽出叙述里所有需要回指的数字,剔除日期、枚举序号、引用标记这类形态。"""
skip = _skip_spans(text)
tokens: list[NumberToken] = []
for m in _NUM_RE.finditer(text):
start, end = m.span()
if any(s <= start < e for s, e in skip):
continue
raw_num = m.group(1).replace(",", "")
suffix = m.group(2) or ""
try:
value = float(raw_num)
except ValueError:
continue
if suffix in _SUFFIX_MULT:
value *= _SUFFIX_MULT[suffix]
decimals = len(raw_num.split(".")[1]) if "." in raw_num else 0
ctx_lo = max(0, start - 18)
tokens.append(
NumberToken(
raw=m.group(0).strip(),
value=value,
decimals=decimals,
is_percent=suffix in ("%", "%"),
context=text[ctx_lo : min(len(text), end + 18)].replace("\n", " "),
start=start,
)
)
return tokens
def _close(a: float, b: float, decimals: int) -> bool:
"""按 token 自己写出的小数位数给舍入容差:写了 12.3 就允许 |Δ|≤0.05。"""
tolerance = 0.5 * 10 ** (-decimals)
return abs(a - b) <= tolerance + 1e-9
def _token_matches(token: NumberToken, metric_value: float) -> bool:
# 直接比对(含"叙述写百分数、指标本身就是百分数"的情形)
if _close(token.value, metric_value, token.decimals):
return True
if token.is_percent:
# 指标存的是比率(0.123),叙述写成 12.3%
if _close(token.value, metric_value * 100, token.decimals):
return True
return False
def trace_numbers(
narrative: str,
metrics: list[dict[str, Any]],
*,
allow: list[Any] | None = None,
) -> TraceReport:
"""叙述 vs 指标集的回指校验。
allow 是模板作者显式放行的字面量(比如报告标题里的期数)——
白名单是配置的一部分,进版本、被审查,而不是校验器私藏的宽容。
"""
numeric_values = [
float(m["value"])
for m in metrics
if isinstance(m.get("value"), (int, float)) and not isinstance(m.get("value"), bool)
]
allowed = {str(a).strip() for a in (allow or [])}
allowed_values = set()
for a in allowed:
try:
allowed_values.add(float(a.replace(",", "").rstrip("%%")))
except ValueError:
pass
report = TraceReport()
for token in extract_numbers(narrative):
if token.raw in allowed or token.raw.rstrip("%%").strip() in allowed:
continue
if any(_close(token.value, av, token.decimals) for av in allowed_values):
continue
hit = next(
(
m
for m, mv in zip(
[m for m in metrics if isinstance(m.get("value"), (int, float))],
numeric_values,
)
if _token_matches(token, mv)
),
None,
)
if hit is not None:
report.matched.append({"token": token.raw, "metric": hit.get("id")})
else:
report.unmatched.append({"token": token.raw, "context": token.context})
return report
def decide_tier(
*,
missing_required: list[str],
missing_expected: list[str],
unmatched: list[dict[str, Any]],
strict: bool,
) -> str:
"""三档出具的判定。
withheld(不予出具):必需指标缺失,或 strict 下有未回指数字——
结论的地基不完整,宁可不出。
degraded(降档出具):期望指标缺失或有未回指数字,出但明确声明缺口。
formal(正式出具):清单齐、数字全部可回指。
"""
if missing_required:
return "withheld"
if strict and unmatched:
return "withheld"
if missing_expected or unmatched:
return "degraded"
return "formal"