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

1532 lines
93 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.
"""双盲复算节点:正式出具前,口径卡上输入来自查询的指标,另起一个看不到原 SQL 的只读 agent 独立算一遍再比对。
**按输入复算。** 指标由口径卡的原式算出,原式本身确定、可复算(metrics 的 recompute_ok);会错的是喂给它的那几个数:
查哪张表、按哪天归属、排没排除退款……所以盲算的单位是「一个来自查询快照的输入」。盲算 agent(engine/verify_agent.py)
只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项、本次运行的输入参数和数据源的数据目录,
看不到原 SQL 和原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询快照里取值。盲值是拿原式代入盲算的
输入算出来的(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时按绝对误差。两个指标用到同一格的
只盲算一次。几个盲算同时进行(BLIND_CONCURRENCY 个),先单独试第一个:核查模型用不了时不必个个去试。
**原流程的取法一点都不透。** 原流程结果的列名(gmv_by_order_date、下单金额)、同一行的维度列名(order_date)都是原流程
的取舍,告诉盲算就等于告诉它按哪一列归属,它照着算出同一个数,下单日和支付日这类陷阱就漏过去了。所以结果分了组时只给
这一行的取值(「华东」、2026-09-02 这一天),按哪一列分组请它按口径说明判断;原 SQL 里写死的文字列('下单日' AS basis)
连取值也不给;遮罩列不给。计算式只有一项来自查询、或者写了口径说明时不给列名;几项都来自查询又没写口径说明的,列名
里看不出日期、口径的取舍时才给,好让它分得清哪一项是哪一项。
**遮罩列上的输入不复算。** cell() 取自遮罩列的输入不能单独复算,改了名字、套了函数或汇总的遮罩列(SUM(amount) AS
gmv,amount 设了遮罩)按 SQL 认出来也一样(engine/masking.derived_columns):盲算的查询在工具边界按同一套认法遮掉,
派出去也取不到数,不白花一次盲算。别的输入(计算式里写成当时代进去的值)里按位置取自
查询结果的一格(vars.res.rows[0][2]),顺着取值链认出它取自哪份快照的哪一列(recompute.input_origin,证据面板、推演、
可验证报告同一个认法),按需算那份快照要遮的列(和证据面板同一套,见 _Sources.hidden)——它不一定是 cell() 用到的
那几份。取自遮罩列、遮没遮核对不了的,整个指标按无法复算记:遮罩值不能当字面量写进交给盲算的计算式。伸进查询结果的行、
却认不出取自哪一列的(下标不是字面量、数据整形改过形状的结果没带快照的 id 和数据源名),计算式里写成「其他输入」。
交给盲算的分组取值同样按 SQL 认出取自遮罩列的结果列(region AS area、子查询里改了名的),和审批质询同一个认法
(_value_hidden)。
**一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff):
两边先收窄到这一格(只留算出这一列的那一项;分组的按这一行的分组值改成等值条件,HAVING 一并去掉),一条查询算好几列、
分组后取一行时才不会混进「选出的列」「分组」这类和这一格无关的差别。分组的列设了遮罩时不收窄:这一行的分组值写进
SQL,就跟着差异说明进了审批卡、对照工件和目录日志;遮罩列改了名再分组的(region AS area,在子查询、WITH 里改的也算)
按 SQL 认出来(_value_hidden),一样不收窄。完整对照落成一件工件(审批 payload 经 runner._safe 会截断,
长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用原流程(a)、采用盲算(b)或两边都不对(neither):a 没有
缺口;b、neither、没选的写进产出的 issuance_gaps,成果节点的出具契约据此降档(on_reject 为 fail 时节点直接失败)。
两边只差一个方面、又对得上数据目录事实的,选择写进数据目录(engine/dual_check_facts.py,指标自己的筛选范围不写)。
选了会怎样要在卡上看得出:payload 在约定的几项之外带上实际生效的 on_reject,摘要里每个指标带 catalog——选 a、选 b
按发起审批时的目录各会往目录里写什么、不写的为什么(dual_check_facts.preview,和真写同一套判断);几个指标落到目录
同一项上时每种选择组合合起来会怎样写(preview_joint),放在对照工件的 catalog_joint 里。
盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口,发 dual_check_unchecked。运行这一段的
时长上限将至时不再开始新的盲算、开始的按剩余时间收紧时长(engine/deadline.py),没来得及的照此记,运行不被中止;
这几个另发一条 dual_check_unchecked,带 cause: run_deadline,界面据此说原因、给对的下一步。
**产出的状态。** 全部一致是 agreed;没有不一致、但有无法复算的:一个都没核成是 unchecked,核成了一部分是 partial;
有不一致、人裁决过的是 resolved。产出和台账都带 counts(一致、不一致、无法复算各几个)。
**重放。** 审批恢复时节点从头重放。读设置(发起时没快照的才读)、盲算连同比对、差异说明连同工件、写数据目录各在一个
@task 里,重放时直接取 checkpoint 里的结果:不再调用模型、不再计费、不再写目录,事件也都在 task 里发、不会再发一遍;
task 外面的日志经 replay.once 发。task 的先后只取决于这次运行固定下来的东西(图、运行选项、上游的产出)。设置读发起
时的快照(launch_run 写进 run.options["verify"]):审批停下期间改了设置,恢复后照旧按发起时的。
**不进写作目录。** 盲算的查询发 tool.end(role: verify),随运行封存、证据面板能下钻;台账只记一条 kind: dual_check,
绝不写 kind: query——报告的写作目录按台账里的 query 编号,盲算的查询混进去,写作者就会引用它们。
"""
from __future__ import annotations
import ast
import asyncio
import copy
import math
import re
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any
from langgraph.func import task
from sqlalchemy import select
from app.core import artifact_store
from app.core.errors import describe_exception
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.data import catalog_view
from app.data.coverage import NO_DATA_SHORT, no_data_of
from app.engine import deadline, recompute
from app.engine.approval import read_decision
from app.engine.context import NodeContext, NodeError, NodeRejected
from app.engine.dual_check_facts import preview, preview_joint, record_choices, upload_dates
from app.engine.evidence import build_catalog, is_number, next_exec
from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cell_value, column_kind, numeric_text, \
parse_expression
from app.engine.replay import _in_graph, ask, once
from app.engine.labels import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT_LABEL
from app.engine.masking import derived_columns
from app.engine.schema import DUAL_CHECK_ON_REJECT, NodeType, dual_check_card
from app.engine.state import GraphState
# 原值和盲算值按相对误差比、相差比例:和评测(app/eval/score.py)同一个函数(engine/tolerance.py)
from app.engine.tolerance import agree as _agree, delta_pct as _delta_pct
#: 节点产出的状态:跳过、全部一致、核成了一部分(其余无法复算)、一个都没核成、人已裁决
SKIPPED, AGREED, PARTIAL, NONE_CHECKED, RESOLVED = "skipped", "agreed", "partial", "unchecked", "resolved"
#: 审核人驳回了裁决、接了驳回出口沿它往下走(docs/design/orchestration-round/00-总方案.md §3):这一步的结论
REJECTED = "rejected"
#: 单个指标的结果:一致、不一致、无法复算
SAME, DIFFERENT, UNCHECKED = "agreed", "disagreed", "unchecked"
#: 审核人的三种选择:采用原流程、采用盲算、两边都不对
CHOICES = ("a", "b", "neither")
#: 盲算触顶的原因里交给界面认的几种(dual_check_limit 日志的 limit);max_steps 只由核查本身的 verify_limit 日志说
LIMIT_CODES = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd")
#: 输入的出处:口径卡的 cell() 取的格、Agent 经 cite_fields 交来的字段,都落在一份查询快照上
QUERY_VIAS = ("tool_cell", "agent_field")
#: 差异说明里两边的叫法,先原流程后盲算:dual_check_facts 按 a / b 认这个顺序
SIDES = ("原流程", "盲算")
#: 审批卡摘要里每个指标最多列几条差异
SUMMARY_DIFFS = 3
#: 一次 SQL 比较最多等多久:解析是纯计算,放在线程里,超时就给兜底说明
DIFF_TIMEOUT_S = 15.0
#: 交给盲算 agent 的数据目录最多这么多字(表清单、各表的说明分开算),再多就请它自己用 db_schema 查
_INDEX_CHARS = 3_000
_NOTES_CHARS = 6_000
#: 交给盲算 agent 的同一行其他列、运行输入最多几项、每个值最多几个字
_DIMS = 6
_DIM_CHARS = 40
_INPUTS = 12
#: 几个盲算同时进行:串行时几个慢的输入就能把运行这一段的时长上限(max_run_seconds)耗光
BLIND_CONCURRENCY = 3
#: 交给盲算的取值像日期、月份时换个说法:「2026-09-02 这一天」,不说是哪一列
_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_MONTH = re.compile(r"^\d{4}-\d{2}$")
#: 原流程结果的列名里出现这些,就带着原流程的取舍(按哪天归属、排没排除、去没去重),不交给盲算
_LOADED_NAME = ("date", "day", "time", "dt", "_at", "month", "week", "year", "paid", "pay", "order", "refund",
"cancel", "distinct", "dedup", "unique", "net", "gross", "valid", "test", "日", "月", "周", "年",
"时间", "下单", "支付", "付款", "退款", "去重", "剔除", "排除", "不含", "含", "有效", "净", "毛")
#: 卡上给容器输入记的大概(metrics._brief):「[12 项]」「{3 个字段}」,写进计算式时不当成字面量
_BRIEF = re.compile(r"^(?:\[\d+ 项\]|\{\d+ 个字段\})$")
SKIP_DISABLED = "双盲复算已在设置中关闭,本节点直接放行"
SKIP_NODE_OFF = "本节点已关闭双盲复算,直接放行"
SKIP_NO_INPUTS = "口径卡上没有输入来自查询的指标,无需双盲复算,直接放行"
#: 两条 SQL 规范化后一致、算出的值却不同:差在数据(两次取数之间数据变了、结果被截断),不在写法
SAME_SQL = "两条 SQL 规范化后一致,差异可能来自两次取数之间数据有变化或取数结果被截断"
SAME_SQL_ASPECT = "data"
DIFF_TIMEOUT = "两条 SQL 比较超时,未能归纳成具体差异,请对照原文"
DIFF_FAILED = "两条 SQL 比较时出错,未能归纳成具体差异,请对照原文"
NO_SQL = "有一边没有可比较的 SQL,请对照原文"
#: 交给成果节点的缺口。不带节点名,也不再说「双盲复算」:出具契约统一加「双盲复算「节点名」:」(没改名的节点只加
#: 「双盲复算:」),正文再说一遍就成了「双盲复算:指标「X」双盲复算不一致……」
GAP_BLIND = "指标「{name}」两路结果不一致,审核人选择了盲算结果"
GAP_NEITHER = "指标「{name}」两路结果不一致,审核人判断两边都不对"
GAP_UNANSWERED = "指标「{name}」两路结果不一致,审核人没有作出选择"
GAP_REJECTED = "指标「{name}」两路结果不一致,审核人驳回了裁决"
GAP_UNCHECKED = "指标「{name}」无法复算:{reason}"
#: 运行时长上限将至(engine/deadline.py):没来得及开始的盲算、时长按剩余时间收紧后到了点的盲算,都按无法复算记
LATE = "本次运行即将达到 {seconds:g} 秒的时长上限,为保证运行按时完成,未进行盲算"
SQUEEZED = "本次运行即将达到 {seconds:g} 秒的时长上限,盲算时长已按剩余时间收紧,到时未能完成"
#: 这几个指标另起的那条 dual_check_unchecked(带 cause)的原话
LATE_UNCHECKED = ("本次运行即将达到 {seconds:g} 秒的时长上限,为保证运行按时完成,双盲复算有 {count} 个指标未能复算:"
"{names},按无法复算计入缺口")
BLIND_SYSTEM = (
"你在做双盲复算:另一条流程已经算出了口径卡上的一个指标,你要在看不到它的 SQL 和结果的情况下,按业务定义独立算出"
"计算式里的一个数,交给人和原流程比对。\n"
"- 业务含义只按下面给的指标名称、口径说明和数据目录理解。数据目录里人工确认过的说明(业务日期按哪一列、有效记录"
"条件、去重规则)要遵守,标「推断,未确认」的谨慎使用\n"
"- 时间范围(统计期看本次运行的输入)、要排除哪些行、要不要去重,都按口径说明来;口径说明没写到的,按数据目录和"
"最常见的业务理解,并在理由里写明你的取舍\n"
"- 尽量用一条 SQL 直接算出要交的那个数(聚合放在 SQL 里做),最后交出它所在的格子"
)
BLIND_TASK_PROMPT = (
"指标:{name}{unit}\n"
"口径说明:{definition}\n"
"计算式:{formula}\n"
"你要算的是计算式里的 {label}{whole}{hint}。{dims}只交这一个数。\n"
"数据源:{source}\n"
"{inputs}\n"
"{catalog}"
)
BLIND_UNIT_PROMPT = "(单位:{unit})"
BLIND_NO_DEFINITION_PROMPT = "(未提供,按指标名称和数据目录理解)"
BLIND_WHOLE_PROMPT = "(这个指标就是它本身)"
BLIND_HINT_PROMPT = "(原流程的结果里这一项叫「{column}」)"
BLIND_DIMS_PROMPT = "原流程的结果分了组,这个数属于对应 {dims} 的那一组;按哪一列分组、怎么归属,请按口径说明和数据目录判断。"
BLIND_DAY_PROMPT = "{value} 这一天"
BLIND_MONTH_PROMPT = "{value} 这个月"
BLIND_INPUTS_PROMPT = "本次运行的输入(统计期之类的参数在这里):{inputs}\n"
BLIND_INDEX_PROMPT = "数据源里的表(表名|中文名|粒度):\n{index}"
BLIND_NOTES_PROMPT = "数据目录(标「推断,未确认」的项是推断,使用前请核实):\n{notes}"
BLIND_NO_CATALOG_PROMPT = "这个数据源还没有数据目录,请用 db_schema 工具查看表结构。"
BLIND_MORE_PROMPT = "\n…(其余的请用 db_schema 工具查看)"
#: 盲算交回的结构:一个数(交给模型时换成格子引用,由代码取值),verify_agent 另加理由
BLIND_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"value": {"type": "number", "description": "要你算的那个数"}},
"required": ["value"],
}
@dataclass(frozen=True)
class _Options:
"""这次复算按什么来:节点配置优先,没写的取发起时的核查设置快照。"""
tolerance: float
on_reject: str
metrics: list[str] | None
definitions: dict[str, str]
max_queries: int | None
timeout_s: float | None
daily_max_usd: float | None
async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
settings = await _settings(ctx)
part = settings.get("dual_check") if isinstance(settings.get("dual_check"), dict) else {}
switch = ctx.config.get("enabled")
if switch is False or (switch is not True and not part.get("enabled", True)):
await once(ctx, EventType.LOG, level="info", code="dual_check_skipped", reason="disabled",
message=SKIP_NODE_OFF if switch is False else SKIP_DISABLED)
return _skipped(ctx, "disabled")
card_id, card = _card(state, ctx)
options = _options(ctx, part, settings)
targets = _targets(card, options, ctx) if card is not None else []
if not targets:
await once(ctx, EventType.LOG, level="info", code="dual_check_skipped", reason="no_inputs",
message=SKIP_NO_INPUTS)
return _skipped(ctx, "no_inputs")
checked = await _step("dual_check_blind", lambda: _check(state, ctx, targets, options, settings))
rows = checked["rows"]
disagreed = [r for r in rows if r["status"] == DIFFERENT]
if not disagreed:
agreed = sum(1 for r in rows if r["status"] == SAME)
# agreed 只说「全部一致」:一个都没核成、只核成一部分的另记,时间线和证据面板才不会把降档的出具说成一致
status = AGREED if agreed == len(rows) else PARTIAL if agreed else NONE_CHECKED
return _finish(state, ctx, card_id, card, checked, status=status, gaps=_unchecked_gaps(rows))
review = await _step("dual_check_review", lambda: _review(state, ctx, checked, options))
response = await ask(ctx, review["payload"], **review["payload"])
# 谁裁决的跟着事件走,和人工审批节点一样
await once(ctx, EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor())
try:
choices, note = _choices(ctx, response, disagreed)
except NodeRejected as e:
# 审核人驳回了裁决。接了驳回出口时(engine/compiler._rejected_exit)这一步照常结束:两路不一致的那几个指标照样记成
# 出具缺口,台账照记一条结论为驳回的 dual_check——驳回不是认可,下游的出具不能把这组数完整出具(不比选「两边
# 都不对」宽)。没接出口的运行收成被驳回,这些用不上
done = _finish(state, ctx, card_id, card, checked, status=REJECTED, gaps=_rejected_gaps(rows), choices={},
note=e.note, artifact=review["card_artifact"])
e.exit_updates = {"payload": done["nodes"][ctx.node.id], "evidence": done.get("evidence"),
"usage": done.get("usage")}
raise
gaps = _gaps(rows, choices)
await once(ctx, EventType.LOG, level="info", code="dual_check_resolved", choices=choices, gaps=gaps,
message=_resolved_text(disagreed, choices))
candidates = [{**review["facts"][r["metric_id"]], "choice": choices[r["metric_id"]]}
for r in disagreed if choices.get(r["metric_id"]) in ("a", "b") and r["metric_id"] in review["facts"]]
catalog = await _step("dual_check_catalog", lambda: _catalog(ctx, candidates)) if candidates else None
rejected = [r for r in disagreed if choices.get(r["metric_id"]) != "a"]
if rejected and options.on_reject == "fail":
# 目录照样写了:审核人的选择是对数据的判断,和这次运行停不停无关
names = "、".join(f"「{r['name']}」" for r in rejected[:5])
raise NodeError(ctx.node.id, f"双盲复算不一致,审核人没有采用原流程的结果(指标{names})。"
f"「{DUAL_CHECK_FIELD_LABEL['on_reject']}」设为「{DUAL_CHECK_ON_REJECT_LABEL['fail']}」,运行已停止;"
"请按审核意见修正取数或口径后重新运行")
return _finish(state, ctx, card_id, card, checked, status=RESOLVED, gaps=gaps, choices=choices, note=note,
artifact=review["card_artifact"], catalog=catalog)
# --------------------------------------------------------------------------
# 设置、口径卡、要复算的指标
# --------------------------------------------------------------------------
async def _step(name: str, work: Callable[[], Awaitable[Any]]) -> Any:
"""在一个 @task 里做 work:审批恢复、接着运行时节点从头重放,结果直接取 checkpoint 里的,不再做一遍;work 里发的
事件也就只发一次。不在图里执行(单独调用执行器)时直接做。"""
if not _in_graph():
return await work()
@task(name=name)
async def step() -> Any:
return await work()
return await step()
async def _settings(ctx: NodeContext) -> dict[str, Any]:
"""这次运行的核查设置:发起时的快照(run.options["verify"])。发起时没快照的(不经 launch_run 发起、子工作流里的
节点)读当前设置,读一次记进 checkpoint:重放时不再读,审批停下期间改了设置也不会让重放走上另一条路。"""
from app.api.settings import VERIFY_GROUP, verify_settings, verify_values
from app.engine.runner import RUN_OPTIONS_KEY
options = ctx.run.extra.get(RUN_OPTIONS_KEY)
snapshot = options.get(VERIFY_GROUP) if isinstance(options, dict) else None
if isinstance(snapshot, dict):
return verify_values(snapshot)
async def read() -> dict[str, Any]:
async with SessionLocal() as session:
return await verify_settings(session)
return await _step("dual_check_settings", read)
def _card(state: GraphState, ctx: NodeContext) -> tuple[str, dict[str, Any] | None]:
"""要复算的口径卡:(节点 id, 它这次的产出)。配置不对(发起前的校验已经拦下,这里兜底)报节点错误;卡没执行到
(在没走到的分支上)产出为 None。"""
card_id, problem = dual_check_card(ctx.run.spec, ctx.node)
if problem or not card_id:
raise NodeError(ctx.node.id, problem or f"「{DUAL_CHECK_FIELD_LABEL['card']}」需要选择一个口径卡节点")
node = ctx.run.spec.node_map().get(card_id)
if node is None or node.type != NodeType.METRICS:
raise NodeError(ctx.node.id, f"「{DUAL_CHECK_FIELD_LABEL['card']}」选择的「{card_id}」不是口径卡节点,"
"请重新选择")
output = (state.get("nodes") or {}).get(card_id)
return card_id, output if isinstance(output, dict) and output.get("kind") == "metric_set" else None
def _options(ctx: NodeContext, part: Mapping[str, Any], settings: Mapping[str, Any]) -> _Options:
cfg = ctx.config
tolerance = cfg.get("tolerance")
if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or not math.isfinite(tolerance) \
or tolerance < 0:
tolerance = part.get("tolerance")
if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or tolerance < 0:
tolerance = 0.0
wanted = cfg.get("metrics")
definitions = cfg.get("definitions") if isinstance(cfg.get("definitions"), dict) else {}
return _Options(
tolerance=float(tolerance),
on_reject=cfg.get("on_reject") if cfg.get("on_reject") in DUAL_CHECK_ON_REJECT else "degrade",
metrics=[str(m) for m in wanted if isinstance(m, str) and m.strip()] if isinstance(wanted, list) else None,
definitions={str(k): str(v) for k, v in definitions.items() if isinstance(v, str) and v.strip()},
max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"),
daily_max_usd=settings.get("daily_max_usd"),
)
def _from_query(item: Any) -> bool:
return isinstance(item, dict) and item.get("via") in QUERY_VIAS and isinstance(item.get("artifact"), str) \
and bool(item["artifact"]) and isinstance(item.get("path"), str)
def _number(value: Any) -> bool:
if not is_number(value):
return False
try:
return math.isfinite(value)
except OverflowError:
return False
def _targets(card: dict[str, Any], options: _Options, ctx: NodeContext) -> list[dict[str, Any]]:
"""要复算的指标,按配置的先后(没配就按卡上的先后):原值是数、至少一个输入来自查询快照。配置里写了卡上没有的
指标 ID 报节点错误(口径卡引用自别的工作流时,画图时查不出来)。"""
metrics = [m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") is not None]
by_id = {str(m["id"]): m for m in metrics}
if options.metrics:
if unknown := [m for m in options.metrics if m not in by_id]:
raise NodeError(ctx.node.id, f"「{DUAL_CHECK_FIELD_LABEL['metrics']}」中的「{'、'.join(unknown[:5])}」"
f"不是口径卡上的指标 ID,可选的指标 ID:{'、'.join(list(by_id)[:12])}")
chosen = [by_id[m] for m in dict.fromkeys(options.metrics)]
else:
chosen = metrics
return [m for m in chosen if _number(m.get("value")) and any(_from_query(i) for i in m.get("inputs") or [])]
def _skipped(ctx: NodeContext, reason: str) -> dict[str, Any]:
return {"nodes": {ctx.node.id: {"status": SKIPPED, "reason": reason}}}
# --------------------------------------------------------------------------
# 盲算与比对(一个 @task)
# --------------------------------------------------------------------------
class _Sources:
"""一次复算里要读的查询快照、数据源和快照要遮的列:同一份只读一次、只算一次。view 是这次运行的目录视图
(data/catalog_view.py):交给盲算的目录、比较两条 SQL 用的中文名都按它读,和运行时工具给模型看的是同一份。"""
def __init__(self, view: Any = None) -> None:
self.view = catalog_view.CatalogView.parse(view)
self.snapshots: dict[str, tuple[Any, str | None]] = {}
self.rows: dict[str, DataSource | None] = {}
self.masks: dict[tuple[str, str | None], frozenset[str]] = {}
self._source_masks: Any = None
def snapshot(self, artifact: str) -> tuple[dict[str, Any] | None, str | None]:
if artifact not in self.snapshots:
try:
content = artifact_store.load(artifact)
self.snapshots[artifact] = (content, None if content is not None else "查询快照无法读取")
except ValueError:
self.snapshots[artifact] = (None, "查询快照与哈希不一致,疑似被修改")
content, why = self.snapshots[artifact]
if why is None and not (isinstance(content, dict) and isinstance(content.get("rows"), list)):
return None, "查询快照无法读取"
return content, why
async def source(self, name: str) -> DataSource | None:
if name not in self.rows:
async with SessionLocal() as session:
self.rows[name] = (await session.execute(
select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
return self.rows[name]
async def hidden(self, artifact: str, source: str | None = None) -> frozenset[str]:
"""这份快照要遮的列(小写),和证据面板同一套(api/evidence.py 的 _InputMasks._hidden):查询当时记下的,加上
数据源现在设的;合并查询按它合并的那几个数据源算,换了名字的结果列按来历遮。快照读不出来时按 source(取值链上
那份结果里带着的数据源名)现在设的算:遮罩不能因为快照坏了就失效。宁可多遮。"""
from app.api.evidence import _Masks, _merge_masks, _recorded_masks # api.evidence 导入了 engine 里的模块
from app.engine.merge_query import MERGE_SOURCE
key = (artifact, source)
if key not in self.masks:
if self._source_masks is None:
self._source_masks = _Masks()
snap, _ = self.snapshot(artifact)
if snap is not None and snap.get("source") == MERGE_SOURCE:
hidden, _ = await _merge_masks(snap, self._source_masks)
else:
current, _ = await self._source_masks.of((snap or {}).get("source") or source)
hidden = set(current) | (_recorded_masks(snap) if snap is not None else set())
self.masks[key] = frozenset(hidden)
return self.masks[key]
def _loader(state: GraphState) -> tuple[Callable[[str], Any], dict[str, str]]:
"""重算要的取工件函数和节点产出:nodes.* 的容器输入从这次运行的状态里取(口径卡执行时上游的产出就是这些)。"""
prefix = "state:"
outputs = {str(nid): f"{prefix}{nid}" for nid in state.get("nodes") or {}}
def load(key: str) -> Any:
if key.startswith(prefix):
return (state.get("nodes") or {}).get(key[len(prefix):])
return artifact_store.load(key)
return load, outputs
async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, Any]], options: _Options,
settings: Mapping[str, Any]) -> dict[str, Any]:
"""逐个指标认出能复算的输入、按输入盲算、代回原式比对。返回 {rows, results, model, same_model, usage}。
rows 每个指标一项:{metric_id, name, unit, decimals, status, reason?, original: {value, rendered}, blind: {value,
rendered}, delta_pct, inputs: [{path, key, value, sql, source, alias, artifact, row, column, node_id, via}],
differing};results 是每个盲算(按格子去重)的结果,见 _slim。
"""
sources = _Sources()
load, outputs = _loader(state)
aliases = _original_aliases(state)
inputs = _run_inputs(state)
rows: list[dict[str, Any]] = []
jobs: dict[str, dict[str, Any]] = {}
sharers: dict[str, list[dict[str, str]]] = {}
for metric in targets:
row, metric_jobs = await _plan_metric(state, metric, options, sources, aliases, load, outputs)
rows.append(row)
for key, job in metric_jobs.items():
job["inputs"] = inputs
sharers.setdefault(key, []).append({"id": row["metric_id"], "name": row["name"]})
# 两个指标用到同一格只盲算一次,按先出现的那个指标说清要算什么;先出现的没写口径说明、后一个写了,用后一个
if key not in jobs or (not jobs[key]["definition"] and job["definition"]):
jobs[key] = job
for key, job in jobs.items():
job["metrics"] = sharers[key]
results, model, same, usage = await _blind(ctx, jobs, options, settings, _producer_models(state, rows))
for row, metric in zip(rows, targets):
masks, queries = row.pop("_masks")
if row["status"] != UNCHECKED:
_compare(row, metric, results, options, load, outputs, masks, queries)
agreed = sum(1 for r in rows if r["status"] == SAME)
if agreed and not any(r["status"] == DIFFERENT for r in rows):
ctx.emit(EventType.LOG, level="info", code="dual_check_agreed", metrics=agreed,
message=f"双盲复算一致:{agreed} 个指标两边的结果相同,已放行")
if unchecked := [r for r in rows if r["status"] == UNCHECKED and not r.get("cause")]:
# 结构上没法单独复算的(合并查询、遮罩列)以前一条 dual_check_* 日志都没有,时间线上看不出为什么没核成
parts = ";".join(f"「{r['name']}」({r.get('reason') or '原因不明'})" for r in unchecked[:5])
ctx.emit(EventType.LOG, level="warn", code="dual_check_unchecked",
metrics=[{"id": r["metric_id"], "name": r["name"], "reason": r.get("reason") or "原因不明"}
for r in unchecked],
message=f"双盲复算未能核对 {len(unchecked)} 个指标:{parts}"
+ ("等" if len(unchecked) > 5 else "") + ",按无法复算计入缺口")
if late := [r for r in rows if r["status"] == UNCHECKED and r.get("cause")]:
# 运行时长上限将至没核成的(_blind 记下 cause)另起一条:界面据此说原因、给下一步,调高每项的上限只会更快撞线
seconds = late[0].get("run_seconds")
ctx.emit(EventType.LOG, level="warn", code="dual_check_unchecked", cause=late[0]["cause"], run_seconds=seconds,
metrics=[{"id": r["metric_id"], "name": r["name"], "reason": r.get("reason") or "原因不明"}
for r in late],
message=LATE_UNCHECKED.format(seconds=seconds or 0, count=len(late),
names="".join(f"「{r['name']}」" for r in late[:5])
+ ("等" if len(late) > 5 else "")))
return {"rows": rows, "results": results, "model": model, "same_model": same, "usage": usage}
def _run_inputs(state: GraphState) -> list[str]:
"""这次运行的输入里的标量(统计期、地区这类参数):原流程的 SQL 常常是拿它们拼出来的,盲算 agent 不知道统计期
就算不出同一个数。只给参数,不给 SQL。"""
out: list[str] = []
for key, value in (state.get("input") or {}).items():
if isinstance(key, str) and key and (value is None or isinstance(value, (str, int, float, bool))):
text = str(value).replace("\n", " ")
out.append(f"{key} = {text[:_DIM_CHARS]}")
if len(out) >= _INPUTS:
break
return out
def _original_aliases(state: GraphState) -> dict[str, str]:
"""原流程的查询快照在这次运行的证据台账里的编号(Q1、Q2…,和报告取全部上游查询时的编号一致)。"""
ledger = [e for e in state.get("evidence") or [] if isinstance(e, dict)]
catalog = build_catalog(nodes={}, ledger=ledger)
return {str(entry.get("artifact")): alias for alias, entry in catalog.items()
if entry.get("kind") == "query" and entry.get("artifact")}
async def _plan_metric(state: GraphState, metric: dict[str, Any], options: _Options, sources: _Sources,
aliases: dict[str, str], load: Callable[[str], Any], outputs: dict[str, str]
) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
"""一个指标:认出来自查询的输入能不能单独复算,能的话给每个输入建一个盲算任务。"""
row: dict[str, Any] = {
"metric_id": str(metric["id"]), "name": str(metric.get("name") or metric["id"]),
"unit": str(metric.get("unit") or ""), "decimals": metric.get("decimals"),
"status": "pending", "original": {"value": metric.get("value"), "rendered": metric.get("rendered")},
"blind": {"value": None, "rendered": None}, "delta_pct": None, "inputs": [], "differing": [],
}
tops = _tops(metric)
# masks 是每份用到的快照要遮的列(cell() 的、按位置取的格落在的),queries 只是 cell() 用到的那几份:重算时容器
# 输入按内容对快照只对这几份。按位置取的格落在的快照算遮罩只为认这一格,不当成指标用到的结果——放进去的话,同一个
# 指标里装着它整份行的容器(计算式里写成「其他输入」,值不交给盲算)会对上它,它有一列遮罩就整个挡下
masks: dict[str, frozenset[str]] = {}
queries: list[str] = []
withheld: set[str] = set()
problem: str | None = None
for item in metric.get("inputs") or []:
if not _from_query(item):
if isinstance(item, dict):
why, shown = await _positional_masks(item, sources, load, outputs, masks)
problem = problem or why
if not shown:
withheld.add(str(item.get("path")))
continue
info, why = await _query_input(item, sources, aliases, tops)
masks[item["artifact"]] = await sources.hidden(item["artifact"])
queries.append(item["artifact"])
if why:
problem = problem or why
continue
row["inputs"].append(info)
row["_masks"] = (masks, tuple(dict.fromkeys(queries)))
if problem is None:
# 什么都不改先重算一遍:和卡上记的对不上(输入还原不了、用到了遮罩列),换了盲算的输入也算不准
check = recompute.recompute(metric, recompute.Changes(), recompute.Sources(
load=load, outputs=outputs, masked=masks, queries=row["_masks"][1]))
if check["status"] != "ok" or check["changed"]:
problem = f"无法按原表达式重算这个指标({check.get('reason') or '重算结果与口径卡不一致'})"
if problem:
row.update(status=UNCHECKED, reason=problem)
return row, {}
labels = {inp["path"]: f"输入{i}" for i, inp in enumerate(row["inputs"], 1)}
formula = _formula(metric, labels, withheld)
definition = options.definitions.get(row["metric_id"]) or ""
jobs: dict[str, dict[str, Any]] = {}
for inp in row["inputs"]:
snap, _ = sources.snapshot(inp["artifact"])
hidden = await _value_hidden(snap or {}, masks.get(inp["artifact"]) or frozenset(), sources) \
| await _constant_columns(snap or {}, sources)
# 列名是原流程的取舍:只有一项来自查询、或者写了口径说明时用不着;几项又没口径说明的,看不出取舍才给
hint = inp["column"] if len(row["inputs"]) > 1 and not definition and _neutral(inp["column"]) else None
jobs[inp["key"]] = {
"key": inp["key"], "metric_id": row["metric_id"], "metric": row["name"], "unit": row["unit"],
"definition": definition, "formula": formula, "label": labels[inp["path"]], "hint": hint,
"source": inp["source"], "dims": _dims(snap or {}, inp["row"], inp["column"], hidden),
}
return row, jobs
async def _positional_masks(item: dict[str, Any], sources: _Sources, load: Callable[[str], Any],
outputs: dict[str, str], masks: dict[str, frozenset[str]]) -> tuple[str | None, bool]:
"""不是 cell()、Agent 字段的输入里,按位置取自一份查询结果的一格(vars.res.rows[0][2]):(为什么不能复算, 卡上记的值
能不能写进交给盲算的计算式)。认出它取自哪份快照的哪一列(recompute.input_origin,证据面板、推演、可验证报告同一个
认法),按需算那份快照要遮的列记进 masks。只算 cell() 用到的几份快照时,这一格落在另一份快照上就认不出,遮罩值会当
字面量写进交给盲算的计算式。
取自遮罩列的直接交回点名的原因,和 cell() 取自遮罩列的说法一致(指标有好几个输入时审核人才找得到是哪一格)。那份
快照读不出来、数据源现在设的遮罩又没遮这一列时,查询当时记下的遮罩无从核对:交回原因,按无法复算记,宁可多遮。
取值链上的产出读不出来、那份结果没存快照只带着数据源名的(遮没遮核对不了),重算的预检自己会拦下,这里不管。
伸进查询结果的行、却认不出取自哪一列的(下标不是字面量;数据整形改过形状,结果既没带快照的 id 也没带数据源名),
照常复算,只是值不写进计算式(写成「其他输入」):认不出就核对不了遮没遮。"""
def output(node_id: Any) -> Any:
key = outputs.get(str(node_id or ""))
return load(key) if key else None
path = str(item.get("path") or "")
origin = recompute.input_origin(item, output)
if origin is None:
return None, not _reaches_rows(path)
if origin.unverified is not None or not origin.artifact:
return None, False
hidden = await sources.hidden(origin.artifact, origin.source)
masks[origin.artifact] = hidden
if any(c.lower() in hidden for c in origin.columns):
return f"输入 {path} 取自设置了遮罩的列,不能用于复算", False
_, why = sources.snapshot(origin.artifact)
if why:
return f"输入 {path} 所在的{why},无法核对这一格是否取自设置了遮罩的列", False
return None, True
def _reaches_rows(path: str) -> bool:
"""取值路径伸进了一份查询结果的行(vars.res.rows[vars.i][0]、nodes.parse['rows'][1]):按位置取的一格。"""
try:
node = ast.parse(path, mode="eval").body
except SyntaxError:
return False
while isinstance(node, (ast.Attribute, ast.Subscript)):
inner = node.value
if isinstance(node, ast.Subscript) and (
(isinstance(inner, ast.Attribute) and inner.attr == "rows")
or (isinstance(inner, ast.Subscript) and isinstance(inner.slice, ast.Constant)
and inner.slice.value == "rows")):
return True
node = inner
return False
async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[str, str], tops: list[str]
) -> tuple[dict[str, Any], str | None]:
"""一个来自查询快照的输入:(盲算要用的信息, None) 或 (_, 为什么不能单独复算)。"""
from app.engine.merge_query import MERGE_SOURCE
artifact = item["artifact"]
snap, why = sources.snapshot(artifact)
if why:
return {}, f"输入 {item['path']} 的{why}"
source = snap.get("source")
if source == MERGE_SOURCE:
return {}, f"输入 {item['path']} 来自合并查询(跨数据源合并的结果),盲算只能在单个数据源里查询"
if not isinstance(source, str) or not source:
return {}, f"输入 {item['path']} 的查询快照没有记下数据源"
locator = item.get("locator") if isinstance(item.get("locator"), dict) else {}
row, column = locator.get("row"), locator.get("column")
if isinstance(row, bool) or not isinstance(row, int) or row < 0 or not isinstance(column, str):
return {}, f"输入 {item['path']} 取的是一整段查询结果,盲算只能复核单个格子"
if item["path"] not in tops:
return {}, f"输入 {item['path']} 决定取哪一格,不能单独复算"
# 先认遮罩、再认是不是数:遮罩列上的号码、名称多半是文本,说「不是数值」会让审核人以为改成数就能复算
hidden = await sources.hidden(artifact)
if column.lower() in hidden:
return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算"
if (gap := no_data_of(snap, column)) is not None:
# 原值所在的那一格落在「查询区间里这张表没有数据」的列上(data/coverage.py):那个 0 不代表真的是 0,盲算查到 0
# 也说明不了一致,不白派一次盲算,按无法复算记
return {}, f"{NO_DATA_SHORT}(输入 {item['path']}:{gap.get('message') or ''})"
value = cell_value(item.get("value"))
if not _number(value):
return {}, f"输入 {item['path']} 不是数值"
found = await sources.source(source)
derived = derived_columns(snap, set(hidden), found.kind if found is not None else None) if hidden else None
if derived and column.lower() in derived:
# 改了名字、套了函数或汇总的遮罩列(SUM(amount) AS gmv):盲算的查询在工具边界按同一套认法遮掉(engine/masking),
# 交回的格子注定取不到,不白派一次盲算。SQL 解析不了的认不准,照常派
return {}, f"输入 {item['path']} 由设置了遮罩的列算出(经改名、函数或汇总),盲算时会被遮罩,不能用于复算"
if found is None or not found.enabled:
return {}, f"数据源「{source}」不存在或已停用"
return {"path": item["path"], "key": f"{artifact}|{row}|{column}", "value": value,
"sql": str(snap.get("sql") or ""), "source": source, "alias": aliases.get(artifact),
"artifact": artifact, "row": row, "column": column, "node_id": item.get("node_id"),
"via": item["via"]}, None
def _tops(metric: dict[str, Any]) -> list[str]:
"""表达式最外层的引用:只有它们的值直接参与计算,嵌在下标、cell() 行列里的决定取哪一格。"""
try:
tree, _, _ = parse_expression(str(metric.get("expression") or ""))
except ExpressionError:
return []
tops, _ = recompute._refs(tree)
return tops
class _Swap(ast.NodeTransformer):
"""把最外层的引用换成给人(给盲算 agent)看的写法:取值链、整个 cell() 调用,认法同 recompute._Bind。"""
def __init__(self, shown: Callable[[str], ast.AST | None]) -> None:
self.shown = shown
def visit_Call(self, node: ast.Call) -> ast.AST:
if _cell_call(node) and (swapped := self.shown(ast.unparse(node))) is not None:
return ast.copy_location(swapped, node)
return self.generic_visit(node)
def _chain(self, node: ast.AST) -> ast.AST:
if _chain_root(node) is None:
return self.generic_visit(node)
swapped = self.shown(ast.unparse(node))
return ast.copy_location(swapped, node) if swapped is not None else node
visit_Attribute = visit_Subscript = visit_Name = _chain
def _formula(metric: dict[str, Any], labels: dict[str, str], withheld: set[str] | frozenset[str] = frozenset()) -> str:
"""交给盲算 agent 的计算式:来自查询的输入写成「输入1」「输入2」,别的输入写当时代进去的值(容器写「其他输入」)。
不露出取数节点的 id、cell() 的行列:那是原流程的取法,不是业务定义。withheld 里的输入(按位置取的一格认不出取自哪
一列,遮没遮核对不了,见 _positional_masks)也写「其他输入」。"""
expr = str(metric.get("expression") or "")
try:
tree, _, _ = parse_expression(expr)
except ExpressionError:
return "、".join(labels.values())
values = {i.get("path"): i.get("value") for i in metric.get("inputs") or [] if isinstance(i, dict)}
def shown(path: str) -> ast.AST | None:
if path in labels:
return ast.Name(id=labels[path], ctx=ast.Load())
if path in withheld:
return ast.Name(id="其他输入", ctx=ast.Load())
value = values.get(path)
if value is None or isinstance(value, (bool, int, float)) or (
isinstance(value, str) and len(value) <= _DIM_CHARS and not _BRIEF.match(value)):
return ast.Constant(value=value)
return ast.Name(id="其他输入", ctx=ast.Load())
swapped = _Swap(shown).visit(copy.deepcopy(tree))
return ast.unparse(ast.fix_missing_locations(swapped))
def _row_pairs(snapshot: Mapping[str, Any], row: int, column: str) -> list[tuple[str, Any]]:
"""原流程那一格所在行的其他列里不是数的那些:(列名, 取值)。数不算(同一行别的指标值)。"""
rows = snapshot.get("rows") or []
if not isinstance(row, int) or not 0 <= row < len(rows):
return []
record = rows[row]
columns = [str(c) for c in snapshot.get("columns") or []]
pairs = list(record.items()) if isinstance(record, dict) else list(zip(columns, record))
out: list[tuple[str, Any]] = []
for name, value in pairs:
name = str(name)
if name == column or value is None:
continue
kind = column_kind(snapshot, name)
if kind == "number" or (kind is None and (is_number(value) or numeric_text(value) is not None)):
continue
out.append((name, value))
return out
def _dims(snapshot: Mapping[str, Any], row: int, column: str, hidden: frozenset[str]) -> list[str]:
"""结果分了组时,交给盲算 agent 的这一行的分组取值(地区、日期这类维度):它要知道算的是哪一组。只给取值、不给
列名(原流程按哪一列分的组正是要它自己判断的),取自遮罩列的结果列(_value_hidden)、原 SQL 里写死的文字列(hidden,
小写)不给。"""
out: list[str] = []
for name, value in _row_pairs(snapshot, row, column):
if name.lower() in hidden:
continue
out.append(str(value).replace("\n", " ")[:_DIM_CHARS])
if len(out) >= _DIMS:
break
return out
def _dim_text(value: str) -> str:
"""一个分组取值给盲算看的说法:日期、月份说「2026-09-02 这一天」,别的加「」。"""
if _DAY.match(value):
return BLIND_DAY_PROMPT.format(value=value)
if _MONTH.match(value):
return BLIND_MONTH_PROMPT.format(value=value)
return f"「{value}」"
def _neutral(column: str) -> bool:
"""原流程结果的列名看不出取舍(按哪天归属、排没排除、去没去重):可以交给盲算区分计算式里的几项。"""
lowered = column.lower()
return bool(column.strip()) and not any(word in lowered for word in _LOADED_NAME)
async def _value_hidden(snapshot: Mapping[str, Any], hidden: frozenset[str], sources: _Sources) -> frozenset[str]:
"""这份快照里取值来自遮罩列的名字(小写):遮罩列本身,加上 SQL 里由它改名、套函数、经子查询或 WITH 转一道得来的
结果列和 SQL 里的名字(region AS area 的 area)。按列名遮只认得出前一种,后一种的取值照样是遮罩列的:交给盲算的
分组取值(_dims)、收窄时补的等值条件(_narrowed)都按这一组挡。和审批质询认改了名的遮罩列同一个认法
(inquiry._derived_hidden):SQL 解析不了、里面又写着遮罩列的,整份快照的列都算。hidden 为空(没设遮罩)时为空。"""
from app.engine.inquiry import _derived_hidden # inquiry 导入了 engine.nodes 里的模块
if not hidden:
return frozenset()
name = snapshot.get("source")
found = await sources.source(name) if isinstance(name, str) and name else None
columns, names = _derived_hidden(dict(snapshot), set(hidden), found.kind if found is not None else None)
return frozenset(hidden) | frozenset(c.lower() for c in (*columns, *names))
async def _constant_columns(snapshot: Mapping[str, Any], sources: _Sources) -> frozenset[str]:
"""原 SQL 最外层写死的文字列('下单日' AS basis)的列名(小写):那是原流程自己的标注,不是分组。解析不了为空。"""
import sqlglot
from sqlglot import exp
from app.data.sqlcheck import dialect_of
sql = snapshot.get("sql")
name = snapshot.get("source")
if not isinstance(sql, str) or not sql.strip():
return frozenset()
found = await sources.source(name) if isinstance(name, str) and name else None
read = dialect_of(found.kind) if found is not None else None
try:
tree = sqlglot.parse_one(sql, read=read)
except Exception: # noqa: BLE001 - 解析不了就当没有:交给盲算的只是取值,最坏多给一个
return frozenset()
out: set[str] = set()
for select in [tree] if isinstance(tree, exp.Select) else list(tree.find_all(exp.Select))[:1]:
for item in select.expressions:
inner = item.this if isinstance(item, exp.Alias) else item
if isinstance(inner, (exp.Literal, exp.Boolean, exp.Null)):
out.add(_output_name(item, read).lower())
return frozenset(out)
async def _catalog_text(sources: _Sources, name: str) -> str:
"""交给盲算 agent 的数据目录:表清单(表名、中文名、粒度)和有目录的表的业务说明(业务日期、有效记录条件、去重、
字段含义、码值、关联关系)。太长的截断,其余请它用 db_schema 查。读哪一份由这次运行的目录视图定(sources.view),
和运行时工具给模型看的是同一份。"""
from app.data import catalog
row = await sources.source(name)
if row is None:
return BLIND_NO_CATALOG_PROMPT
entries = await catalog_view.read(sources.view, row)
cache = row.schema_cache if isinstance(row.schema_cache, dict) else {}
tables = cache.get("tables") or {}
notes = {table: entry.notes for table, entry in entries.items()}
parts: list[str] = []
if index := catalog.render_table_index(cache, notes):
parts.append(BLIND_INDEX_PROMPT.format(index=_clip(index, _INDEX_CHARS)))
blocks = []
for table, body in notes.items():
block = catalog.render_table_notes(body, meta=tables.get(table), system_notes=catalog.system_notes_source(row))
# 每张表的说明去掉标题行,整段只在前面说一次「推断,未确认」的意思
lines = block.split("\n", 1)[1] if "\n" in block else ""
if lines:
blocks.append(f"表 {table}:\n{lines}")
if blocks:
parts.append(BLIND_NOTES_PROMPT.format(notes=_clip("\n".join(blocks), _NOTES_CHARS)))
return "\n\n".join(parts) or BLIND_NO_CATALOG_PROMPT
def _clip(text: str, limit: int) -> str:
return text if len(text) <= limit else text[:limit].rsplit("\n", 1)[0] + BLIND_MORE_PROMPT
def _task_text(job: dict[str, Any], catalog: str) -> str:
dims = "、".join(_dim_text(v) for v in job["dims"])
return BLIND_TASK_PROMPT.format(
name=job["metric"], unit=BLIND_UNIT_PROMPT.format(unit=job["unit"]) if job["unit"] else "",
definition=job["definition"] or BLIND_NO_DEFINITION_PROMPT, formula=job["formula"], label=job["label"],
whole=BLIND_WHOLE_PROMPT if job["formula"].strip() == job["label"] else "",
hint=BLIND_HINT_PROMPT.format(column=job["hint"]) if job.get("hint") else "",
dims=BLIND_DIMS_PROMPT.format(dims=dims) if dims else "",
source=job["source"], catalog=catalog,
inputs=BLIND_INPUTS_PROMPT.format(inputs=";".join(job["inputs"])) if job.get("inputs") else "")
async def _blind(ctx: NodeContext, jobs: dict[str, dict[str, Any]], options: _Options, settings: Mapping[str, Any],
producers: set[str]) -> tuple[dict[str, Any], str | None, bool, dict[str, Any]]:
"""盲算:(各格子的结果, 盲算模型, 是否和产出原数据的模型相同, 用量)。producers 是产出原数据的模型。
先单独做第一个:核查模型本身用不了(没配好、接入出错)时,后面的一样用不了,照这一次的原因记,不再一个个去试。
其余的同时进行,最多 BLIND_CONCURRENCY 个:串行时几个慢的输入(每个最多 timeout_s 加收尾的宽限)就能把运行这一段
的时长上限耗光,整段盲算没有 checkpoint,接着运行时全部重做、再花一次钱。指标多了并行也一样耗得光,所以每项开始
之前还要看这一段还剩多少时间(engine/deadline.py):不够就不再开始、按无法复算记,够的话时长上限按剩余时间收紧。
结果按格子写回,日志按任务的先后发,和完成的先后无关。"""
from app.api.settings import verify_model_spec
from app.engine import verify_agent
from app.engine.verify_agent import VerifyOutcome, run_verify_agent
from app.tools.datasource import run_versions
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_usd": 0.0, "calls": 0}
if not jobs:
return {}, None, False, usage
async with SessionLocal() as session:
spec = await verify_model_spec(session, ctx.config, settings=dict(settings))
sources = _Sources(catalog_view.of_run(ctx.run))
catalogs: dict[str, str] = {}
for job in jobs.values():
if job["source"] not in catalogs:
catalogs[job["source"]] = await _catalog_text(sources, job["source"])
outcomes: dict[str, Any] = {}
squeezed: dict[str, float] = {} # 时长按剩余时间收紧过的盲算 → 这一段执行的总时长(到了点时说明里写)
late: dict[str, float] = {} # 运行时长上限将至、没开始的盲算 → 同上
# 下游这一段里还要调模型的节点(写报告、反方审稿……):各多留一份余量(engine/deadline.py)
after = deadline.models_after(ctx.run.spec, ctx.node.id)
async def one(key: str, job: dict[str, Any]) -> None:
# 给下游留出余量后不够再做一项:不再开始,交回一个没有模型的结果,按无法复算记——第一项就不够时,和核查模型
# 用不了一样,后面的不再一个个去试。在 @task 里定,重放时不再重新定
allot = deadline.allot(options.timeout_s, grace=verify_agent.SETTLE_GRACE_S, after=after)
if not allot.start:
late[key] = allot.seconds
outcomes[key] = VerifyOutcome(result=None, cells=[], queries=[], rationale=None, usage={},
error=LATE.format(seconds=allot.seconds))
return
if allot.tightened:
squeezed[key] = allot.seconds
# 收紧过的:核查 agent 到点自己发的 verify_limit 改说运行时长上限将至(deadline.relabel),不写收紧后的秒数
outcomes[key] = await run_verify_agent(
spec=spec, system=BLIND_SYSTEM, task=_task_text(job, catalogs[job["source"]]), sources=[job["source"]],
output_schema=BLIND_SCHEMA, max_queries=options.max_queries, timeout_s=allot.timeout_s,
daily_max_usd=options.daily_max_usd, emit=deadline.relabel(ctx.emit, allot), run_id=ctx.run.run_id,
node_id=ctx.node.id, data_versions=run_versions(ctx.run), catalog_view=sources.view)
keys = list(jobs)
await one(keys[0], jobs[keys[0]])
first = outcomes[keys[0]]
if len(keys) > 1 and not (first.error and not first.model):
gate = asyncio.Semaphore(BLIND_CONCURRENCY)
async def gated(key: str) -> None:
async with gate:
await one(key, jobs[key])
await asyncio.gather(*(gated(key) for key in keys[1:]))
results: dict[str, Any] = {}
model: str | None = None
same = False
for key in keys:
outcome = outcomes.get(key)
if outcome is None:
results[key] = dict(results[keys[0]])
continue
results[key] = _slim(outcome)
if key in late:
# 没开始的:带上 cause 和这一段的总时长,指标按它记无法复算时时间线另起一条(_check)
results[key].update(cause=deadline.RUN_DEADLINE, run_seconds=late[key])
elif key in squeezed and results[key]["limit"] == "timeout_s":
# 收紧过的时长到了点:照样按无法复算记(同触了每项时长上限),原因说运行时长上限将至。不留 limit,免得发
# dual_check_limit——它叫人调高每项的上限,调高了只会更快撞线
results[key].update(value=None, limit=None, reason=SQUEEZED.format(seconds=squeezed[key]),
cause=deadline.RUN_DEADLINE, run_seconds=squeezed[key])
for k in usage:
usage[k] += outcome.usage.get(k) or 0
if outcome.model and model is None:
model = outcome.model
if model is not None and model in producers:
same = True
ctx.emit(EventType.LOG, level="warn", code="dual_check_same_model", model=model,
message=f"盲算模型与产出原数据的模型相同(「{model}」),难以发现原流程自身的错误。"
"请在「设置 → 偏好设置 → 核查」中选择另一个核查模型,"
f"或在节点的「{DUAL_CHECK_FIELD_LABEL['model']}」中指定")
for key in keys:
found = results[key]
if found.get("limit") in LIMIT_CODES and not _settled(found):
# 几个指标共用这一格:每个指标都按无法复算记,各发一条,时间线上不会只说先出现的那个
for metric in jobs[key]["metrics"]:
ctx.emit(EventType.LOG, level="warn", code="dual_check_limit", limit=found["limit"],
metric=metric["id"],
message=f"指标「{metric['name']}」的盲算{_limit_text(found['limit'], options)},按无法复算处理")
usage["cost_usd"] = round(usage["cost_usd"], 6)
return results, model, same, usage
def _producer_models(state: GraphState, rows: list[dict[str, Any]]) -> set[str]:
"""产出原数据的模型:要复算的输入是 Agent 经 cite_fields 交来的,那个 Agent 用的模型(节点产出里的 model)。调用
工具节点写死的 SQL 没有模型参与,不算。"""
outputs = state.get("nodes") or {}
out: set[str] = set()
for row in rows:
for inp in row["inputs"]:
found = outputs.get(inp.get("node_id")) if inp.get("via") == "agent_field" else None
if isinstance(found, dict) and isinstance(found.get("model"), str) and found["model"]:
out.add(found["model"])
return out
def _slim(outcome: Any) -> dict[str, Any]:
"""一次盲算的结果里要留进 checkpoint 的部分。"""
cell = next((c for c in outcome.cells if c.get("path") == "value"), None) or {}
query = next((q for q in outcome.queries if q.get("alias") == cell.get("alias")), None) or {}
value = cell.get("value") if cell.get("status") == "resolved" else None
reason = None
if outcome.error:
reason = outcome.error
elif cell.get("status") == "missing":
reason = "盲算没有查到这个数"
elif cell.get("status") == "unresolved":
reason = f"盲算交回的格子引用不可用({cell.get('reason') or '没有交这个数'})"
elif value is None:
reason = "盲算取到的那一格是空值"
elif _cell_masked(cell):
value, reason = None, "盲算取的是设置了遮罩的列,不采用"
return {
"value": value, "reason": reason, "limit": outcome.limit, "model": outcome.model,
"alias": cell.get("alias"), "ref": cell.get("ref"), "artifact": cell.get("artifact"),
"row": cell.get("row"), "column": cell.get("column"), "eid": cell.get("eid"),
"sql": query.get("sql"), "source": query.get("source"),
"rationale": (outcome.result or {}).get("rationale") or "",
# 理由组装成的文档(evidence.compose_doc):数字有出处的挂着盲算自己查询的格子,裸数字已标出。审批卡照它画理由
"rationale_doc": outcome.rationale if isinstance(outcome.rationale, dict) else None,
"queries": [{k: q.get(k) for k in ("alias", "artifact", "sql", "row_count", "truncated", "source")}
for q in outcome.queries],
"notes": list(outcome.notes),
}
def _cell_masked(cell: dict[str, Any]) -> bool:
"""盲算交的那一格在遮罩列上(查询当时数据源设的遮罩记在它自己的快照里):值会进审批卡和对照工件,不能用。"""
if not cell.get("artifact"):
return False
try:
snap = artifact_store.load(str(cell["artifact"]))
except (ValueError, OSError):
return True
recorded = snap.get("mask_columns") if isinstance(snap, dict) else None
column = str(cell.get("column") or "").lower()
return isinstance(recorded, list) and any(isinstance(c, str) and c.lower() == column for c in recorded)
def _settled(found: Mapping[str, Any]) -> bool:
"""触了查询次数的上限,但交回的那一格是真查出来的:照常比对。verify_agent 查满 max_queries 次就记触顶(哪怕最后
一次正是答案),按无法复算处理的话,「每项最多查询次数」设成 1 时盲算永远核不成。时长、金额触顶的是半路被掐断,
照旧按无法复算。"""
return found.get("limit") == "max_queries" and found.get("value") is not None
#: 无法复算的原因里带上盲算交回的理由时最多截多少字(理由是渲染后的正文,已经过遮罩)
RATIONALE_CHARS = 160
def _rationale_tail(found: Mapping[str, Any]) -> str:
"""盲算触顶、按无法复算记时(_limit_text 那一路),它交回的理由截一段接在原因后面:审批卡、缺口说明上看得到它查到了
什么。没有理由的交回空串(原因照旧)。"""
text = " ".join(str(found.get("rationale") or "").split())
if not text:
return ""
return f";盲算交回的理由:{text[:RATIONALE_CHARS]}{'…' if len(text) > RATIONALE_CHARS else ''}"
def _no_data_cell(artifact: Any, column: Any, load: Callable[[str], Any]) -> dict[str, Any] | None:
"""这一格所在的列「查询区间里这张表没有数据」(查询快照的 no_data,data/coverage.py):那一条,不是为 None。"""
if not isinstance(artifact, str) or not artifact or not isinstance(column, str):
return None
try:
snapshot = load(artifact)
except (ValueError, OSError):
return None
return no_data_of(snapshot, column)
def _limit_text(limit: str | None, options: _Options) -> str:
"""盲算为什么停下,接在「盲算」后面读:「盲算已达每项最多查询次数(6 次)」。"""
if limit == "max_queries" and options.max_queries is not None:
return f"已达每项最多查询次数({options.max_queries} 次)"
if limit == "timeout_s" and options.timeout_s is not None:
return f"已达每项时长上限({options.timeout_s:g} 秒)"
if limit == "daily_max_usd" and options.daily_max_usd is not None:
return f"已达核查每日金额上限(${float(options.daily_max_usd):g})"
if limit in LIMIT_CODES:
return "已达核查的上限"
return "轮数已用完"
def _compare(row: dict[str, Any], metric: dict[str, Any], results: dict[str, Any], options: _Options,
load: Callable[[str], Any], outputs: dict[str, str], masks: dict[str, frozenset[str]],
queries: tuple[str, ...]) -> None:
"""盲算的输入代回原式,和原值比。row 就地改成一致 / 不一致 / 无法复算。masks、queries 同预检(_plan_metric)。"""
values: dict[str, Any] = {}
for inp in row["inputs"]:
found = results.get(inp["key"])
if found is None:
row.update(status=UNCHECKED, reason="盲算没有执行")
return
if found.get("limit") and not _settled(found):
row.update(status=UNCHECKED, reason="盲算" + _limit_text(found["limit"], options) + _rationale_tail(found))
return
if found.get("value") is None:
row.update(status=UNCHECKED, reason=found.get("reason") or "盲算没有交回可用的数值",
**{k: found[k] for k in ("cause", "run_seconds") if k in found}) # 运行时长上限将至(_blind)
return
if gap := _no_data_cell(found.get("artifact"), found.get("column"), load):
# 盲算交回的那一格落在「查询区间里这张表没有数据」的列上(data/coverage.py):那个 0 不代表真的是 0,拿它和原值
# 比一致不一致都说明不了什么,按无法复算记
row.update(status=UNCHECKED, reason=f"{NO_DATA_SHORT}(盲算查到的那一格:{gap.get('message') or ''})")
return
values[inp["path"]] = found["value"]
outcome = recompute.recompute(metric, recompute.Changes(inputs=values), recompute.Sources(
load=load, outputs=outputs, masked=masks, queries=queries))
if outcome["status"] != "ok":
row.update(status=UNCHECKED, reason=f"用盲算的输入按原表达式算不出结果({outcome.get('reason') or '无法计算'})")
return
original = row["original"]["value"]
blind = outcome["new"]
row["blind"] = {"value": blind, "rendered": outcome.get("rendered_new")}
row["delta_pct"] = _delta_pct(original, blind)
if _agree(original, blind, options.tolerance):
row["status"] = SAME
return
row["status"] = DIFFERENT
# 对不上的输入:超出误差的;都没超出(误差经算式放大了)就取值不同的,再不行就全部
differing = [i["key"] for i in row["inputs"] if not _agree(i["value"], values[i["path"]], options.tolerance)]
differing = differing or [i["key"] for i in row["inputs"] if i["value"] != values[i["path"]]]
row["differing"] = differing or [i["key"] for i in row["inputs"]]
def _rejected_gaps(rows: list[dict[str, Any]]) -> list[str]:
"""裁决被驳回时的出具缺口:不一致的写审核人驳回了裁决,无法复算的照旧写原因。"""
return [GAP_REJECTED.format(name=r["name"]) if r["status"] == DIFFERENT
else GAP_UNCHECKED.format(name=r["name"], reason=r.get("reason") or "原因不明")
for r in rows if r["status"] in (DIFFERENT, UNCHECKED)]
def _unchecked_gaps(rows: list[dict[str, Any]]) -> list[str]:
return [GAP_UNCHECKED.format(name=r["name"], reason=r.get("reason") or "原因不明")
for r in rows if r["status"] == UNCHECKED]
# --------------------------------------------------------------------------
# 不一致:差异说明、对照工件、审批(一个 @task)
# --------------------------------------------------------------------------
async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], options: _Options) -> dict[str, Any]:
"""不一致的指标逐个说清差在哪,完整对照落成工件,组好审批的 payload。返回 {payload, facts, card_artifact}。
两条 SQL 先收窄到这一格再比(_narrowed):原流程一条查询算好几列、分组后取一行时,整条拿去比会混进「选出的列」
「分组」这类和这一格无关的差别,审批卡上只列前几条,审核人看到的多半是噪声,只差一个方面的也就写不进目录。两边
各按自己那份快照要遮的列收窄(原流程的、盲算的快照各记着查询当时的遮罩,再加数据源现在设的)。
facts 是每个不一致指标写数据目录要用的东西(dual_check_facts.derive 的候选,少一个 choice):只有一个输入对不上时是
那一处完整的差异条目(带两边比较的写法和涉及的表、列),几个输入都对不上时只说「不止一处」;scope 是这个指标自己的
范围(口径说明、所在分组、运行输入、分组的列),有效记录条件据此排除指标自己的筛选范围。scope 只在节点里用、不进
payload 和工件(所在分组的取值里可能有遮罩列的)。
payload 在约定的几项之外带 on_reject(实际生效的处置),摘要里每个指标带 catalog(选 a、选 b 各会往数据目录里写
什么,dual_check_facts.preview):审核人在卡上要看得出没采用原流程会不会让运行失败、选了会不会记成人工确认的目录项。
"""
rows = [r for r in checked["rows"] if r["status"] == DIFFERENT]
results = checked["results"]
sources = _Sources(catalog_view.of_run(ctx.run))
infos: dict[str, dict[str, Any]] = {}
metrics: list[dict[str, Any]] = []
summary: list[dict[str, Any]] = []
facts: dict[str, dict[str, Any]] = {}
inputs = _run_inputs(state)
for row in rows:
per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = []
compared: list[tuple[str, str]] = []
scope_texts = [options.definitions.get(row["metric_id"]) or "", *inputs]
scope_columns: set[str] = set()
for inp in row["inputs"]:
if inp["source"] not in infos:
infos[inp["source"]] = await _source_info(sources, inp["source"])
kind = infos[inp["source"]].get("kind")
blind = results.get(inp["key"]) or {}
original_snap, _ = sources.snapshot(inp["artifact"])
pairs = _row_pairs(original_snap or {}, inp["row"], inp["column"])
scope_texts += [str(v) for _, v in pairs]
scope_columns |= {name.lower() for name, _ in pairs} | _group_columns(inp["sql"], kind) \
| _group_columns(blind.get("sql"), kind)
if inp["key"] not in row["differing"]:
continue
blind_snap, _ = sources.snapshot(str(blind["artifact"])) if blind.get("artifact") else (None, None)
blind_hidden = await sources.hidden(str(blind["artifact"])) if blind.get("artifact") else frozenset()
# 收窄按取值来自遮罩列的名字挡(含改了名的):分组值写进 SQL 就跟着差异说明进了审批卡
a_hidden = await _value_hidden(original_snap or {}, await sources.hidden(inp["artifact"]), sources)
b_hidden = await _value_hidden(blind_snap or {}, blind_hidden, sources)
a_sql = _narrowed(inp["sql"], inp["column"], _record(original_snap, inp["row"]), kind, a_hidden) \
or inp["sql"]
b_sql = _narrowed(blind.get("sql"), blind.get("column"), _record(blind_snap, blind.get("row")), kind,
b_hidden) or blind.get("sql")
diff = await _explain(a_sql, b_sql, infos[inp["source"]])
per_input.append((inp, blind, diff))
compared.append((a_sql, str(b_sql or "")))
merged = _merge(per_input)
metrics.append({
"metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], "decimals": row["decimals"],
"original": {**row["original"], "inputs": [
{"path": i["path"], "value": i["value"], "sql": i["sql"], "source": i["source"], "alias": i["alias"]}
for i in row["inputs"]]},
"blind": {**row["blind"], "inputs": [
{"path": i["path"], "value": (results.get(i["key"]) or {}).get("value"),
"sql": (results.get(i["key"]) or {}).get("sql"), "source": (results.get(i["key"]) or {}).get("source"),
"alias": (results.get(i["key"]) or {}).get("alias"),
"rationale": (results.get(i["key"]) or {}).get("rationale") or "",
"rationale_doc": (results.get(i["key"]) or {}).get("rationale_doc")}
for i in row["inputs"]]},
"diff": {"equivalent": merged["equivalent"],
"items": [{"aspect": i.get("aspect"), "text": i.get("text")} for i in merged["items"]],
"fallback": merged["fallback"], "text_diff": merged["text_diff"]},
})
first = per_input[0][0] if per_input else (row["inputs"][0] if row["inputs"] else {})
single = compared[0] if len(per_input) == 1 else None
info = infos.get(str(first.get("source"))) or {}
facts[row["metric_id"]] = {
"metric_id": row["metric_id"], "metric": row["name"], "source": first.get("source"),
"dialect": info.get("kind"), "uploaded": bool(info.get("uploaded")),
"upload_dates": info.get("upload_dates") or {},
"sql": {"a": single[0], "b": single[1]} if single else {},
"diff": {"equivalent": merged["equivalent"], "fallback": merged["fallback"] or single is None,
"items": merged["items"]},
"scope": {"texts": [t for t in dict.fromkeys(scope_texts) if t], "columns": sorted(scope_columns)},
}
summary.append({
"metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"],
"original_rendered": row["original"]["rendered"], "blind_rendered": row["blind"]["rendered"],
"delta_pct": row["delta_pct"], "diff": [str(i.get("text")) for i in merged["items"][:SUMMARY_DIFFS]],
"fallback": not any(i.get("aspect") not in ("other", "parse") for i in merged["items"]),
"catalog": preview(facts[row["metric_id"]], info.get("schema"), info.get("notes")),
})
content = {"metrics": metrics, "model": checked.get("model"), "same_model": bool(checked.get("same_model"))}
# 同一次裁决里几个指标落到数据目录的同一项上时,按各自的选择合起来写(dual_check_facts._merged):每个指标单独的
# 预告说不准,把每种组合的结果都算好放进对照工件(payload 里放不下,审批卡打开时就取工件),卡上按当前的选择取
catalogs = {name: (info.get("schema"), info.get("notes") or {}) for name, info in infos.items()}
joint = await asyncio.to_thread(preview_joint, [facts[r["metric_id"]] for r in rows if r["metric_id"] in facts],
catalogs)
if joint:
content["catalog_joint"] = joint
try:
artifact = await artifact_store.put_json(content, kind="dual_check", run_id=ctx.run.run_id,
node_id=ctx.node.id, meta={"metrics": len(metrics)})
except Exception as e: # noqa: BLE001 - 对照存不下,审核人就看不到完整的两条 SQL:不能让人凭摘要裁决
raise NodeError(ctx.node.id, f"双盲复算的对照结果保存失败({describe_exception(e)}),请稍后接着运行") from e
payload = {
"kind": "dual_check", "node_id": ctx.node.id, "mode": "input",
"title": f"双盲复算:{len(rows)} 个指标两边结果不一致,请选择采用哪一边的结果",
"card_artifact": artifact, "summary": summary, "on_reject": options.on_reject,
}
names = "、".join(f"「{r['name']}」" for r in rows[:5]) + (f"等 {len(rows)} 个" if len(rows) > 5 else "")
ctx.emit(EventType.LOG, level="warn", code="dual_check_disagreed",
metrics=[{"id": r["metric_id"], "name": r["name"]} for r in rows], card_artifact=artifact,
message=f"双盲复算不一致:指标{names}两边的结果对不上,已暂停等待审核")
return {"payload": payload, "facts": facts, "card_artifact": artifact}
async def _source_info(sources: _Sources, name: str) -> dict[str, Any]:
"""比较两条 SQL、预告目录写入要用的:方言、表结构、列的中文名、整份数据目录,以及是不是上传表格建的数据源
(uploaded:导入时日期一律存成文字)和它导入时认作日期的列(upload_dates,按当前版本的配方读,见
dual_check_facts.upload_dates)——业务日期据此认上传表格的日期列,认不出的原因照实说(_dated_problem)。两项都进
候选:审批卡的预告按发起审批时读的这一份;裁决后的真写按写入时的版本重读 upload_dates,和现读的表结构一起以写入时
为准(dual_check_facts.record_choices)。数据源没了就都不给,照样比原文。目录按这次运行的目录视图读(sources.view),
比较两条 SQL 用到的中文名和运行时给模型看的是同一份。"""
from app.data.sql_semantics import catalog_labels
row = await sources.source(name)
if row is None:
return {"kind": None, "schema": None, "names": {}, "notes": {}, "uploaded": False, "upload_dates": {}}
entries = await catalog_view.read(sources.view, row)
async with SessionLocal() as session:
dates = await upload_dates(session, row)
notes = {table: entry.notes for table, entry in entries.items()}
return {"kind": row.kind, "schema": row.schema_cache if isinstance(row.schema_cache, dict) else None,
"names": catalog_labels(notes), "notes": notes, "uploaded": (row.origin or "manual") == "upload",
"upload_dates": dates}
async def _explain(original: Any, blind: Any, info: dict[str, Any]) -> dict[str, Any]:
"""两条 SQL 的差异说明(SqlDiff.as_dict 的形状,条目带两边的写法和 detail)。规范化后一致的换成「差在数据」的
一句:值不同,写法却一样,具体差异一条都说不出。"""
from app.data.sql_semantics import explain_sql_diff
if not isinstance(original, str) or not original.strip() or not isinstance(blind, str) or not blind.strip():
return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": NO_SQL}],
"fallback": True, "text_diff": []}
try:
diff = await asyncio.wait_for(asyncio.to_thread(
explain_sql_diff, original, blind, info.get("kind"), schema=info.get("schema"), names=info.get("names"),
labels=SIDES), timeout=DIFF_TIMEOUT_S)
except Exception as e: # noqa: BLE001 - 比较本身出了意外(超时、规范化的缺陷):给兜底说明,审核人对照原文
text = DIFF_TIMEOUT if isinstance(e, asyncio.TimeoutError) else DIFF_FAILED
return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": text}],
"fallback": True, "text_diff": [f"- {original.strip()}", f"+ {blind.strip()}"]}
out = diff.as_dict()
if out["equivalent"]:
return {"equivalent": True, "items": [{"aspect": SAME_SQL_ASPECT, "a": None, "b": None, "text": SAME_SQL}],
"fallback": False, "text_diff": []}
return out
def _record(snapshot: Mapping[str, Any] | None, row: Any) -> dict[str, Any] | None:
"""快照里第 row 行,写成 {列名: 值};没有这一行为 None。"""
rows = (snapshot or {}).get("rows") or []
if isinstance(row, bool) or not isinstance(row, int) or not 0 <= row < len(rows):
return None
record = rows[row]
if isinstance(record, dict):
return {str(k): v for k, v in record.items()}
columns = [str(c) for c in (snapshot or {}).get("columns") or []]
return dict(zip(columns, record)) if isinstance(record, (list, tuple)) else None
def _output_name(item: Any, read: str | None) -> str:
"""最外层 SELECT 一项的结果列名:有别名用别名,裸列用列名,别的是驱动按原文起的名。"""
from sqlglot import exp
if isinstance(item, exp.Alias):
return item.alias
if isinstance(item, exp.Column):
return item.name
return item.sql(dialect=read)
def _same_column(a: str, b: str) -> bool:
return "".join(a.split()).lower() == "".join(b.split()).lower()
def _group_projection(key: Any, projections: list[Any]) -> int | None:
"""分组键是最外层 SELECT 的第几项:GROUP BY 1、GROUP BY 原样的表达式、GROUP BY 别名都认,认不出为 None。"""
from sqlglot import exp
if isinstance(key, exp.Literal) and not key.is_string and str(key.this).isdigit():
index = int(key.this) - 1
return index if 0 <= index < len(projections) else None
for i, item in enumerate(projections):
if (item.this if isinstance(item, exp.Alias) else item) == key:
return i
if isinstance(key, exp.Column) and not key.table:
hits = [i for i, item in enumerate(projections) if isinstance(item, exp.Alias)
and item.alias.lower() == key.name.lower()]
return hits[0] if len(hits) == 1 else None
return None
def _sql_literal(value: Any) -> Any:
"""快照里的取值写回 SQL 常量;写不回(容器、非有限数)为 None。"""
from sqlglot import exp
if isinstance(value, bool):
return exp.Boolean(this=value)
if isinstance(value, (int, float)):
return exp.Literal.number(value) if math.isfinite(value) else None
if isinstance(value, str):
return exp.Literal.string(value)
return None
def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str | None,
hidden: frozenset[str] = frozenset()) -> str | None:
"""把一条查询收窄到这一格:最外层 SELECT 只留算出这一列的那一项;分组的去掉分组,按这一行的分组值补上等值条件
(华东那一行 → WHERE … AND region = '华东'),HAVING、排序、取行数跟着去掉——这一行既然在结果里,它所在的组就
满足 HAVING,留着只会多出一条和这一格无关的差异。
差异说明拿收窄后的两条比,才只说这一格是怎么算出来的。认不准的(SELECT *、DISTINCT、窗口函数、集合运算、分组键
对不上结果列、这一行没有分组值)返回 None,调用方照整条比。hidden 是这份快照里取值来自遮罩列的名字(小写,
_value_hidden:遮罩列,加上 SQL 里由它改名、经子查询或 WITH 转一道得来的):分组键的结果列、或者它用到的列在里面
时也返回 None——分组值写进 SQL 就跟着差异说明进了审批卡、对照工件和目录日志,遮罩列的值不能出现在那些地方。只按
遮罩列名比的话,FROM (SELECT region AS area …) GROUP BY area 外层只看得到 area,差异说明还会把别名还原成遮罩列名。"""
import sqlglot
from sqlglot import exp
from app.data.sqlcheck import dialect_of
if not isinstance(sql, str) or not sql.strip() or not isinstance(column, str) or not column.strip():
return None
read = dialect_of(kind)
try:
tree = sqlglot.parse_one(sql, read=read)
except Exception: # noqa: BLE001 - 解析不了:照整条比,比较本身会说清
return None
if not isinstance(tree, exp.Select) or tree.args.get("distinct"):
return None
projections = list(tree.expressions)
if not projections or any(isinstance(p, exp.Star) or (isinstance(p, exp.Column) and isinstance(p.this, exp.Star))
or p.find(exp.Window) for p in projections):
return None
hits = [i for i, p in enumerate(projections) if _same_column(_output_name(p, read), column)]
if len(hits) != 1:
return None
target = projections[hits[0]]
group = tree.args.get("group")
if group is not None:
if record is None or any(group.args.get(k) for k in ("rollup", "cube", "grouping_sets", "totals")):
return None
values = {k.lower(): v for k, v in record.items()}
conditions = []
for key in group.expressions:
index = _group_projection(key, projections)
name = _output_name(projections[index], read).lower() if index is not None else None
if name is None or name not in values:
return None
expr = projections[index].this if isinstance(projections[index], exp.Alias) else projections[index]
if name in hidden or any(col.name.lower() in hidden for col in expr.find_all(exp.Column)):
return None
if values[name] is None:
conditions.append(exp.Is(this=expr.copy(), expression=exp.Null()))
continue
literal = _sql_literal(values[name])
if literal is None:
return None
conditions.append(exp.EQ(this=expr.copy(), expression=literal))
for arg in ("group", "having", "order", "limit", "offset"):
tree.set(arg, None)
tree = tree.where(*conditions, copy=False)
elif tree.args.get("limit") is None and tree.args.get("offset") is None:
# 不取前几行时,排序不影响算出来的数
tree.set("order", None)
tree.set("expressions", [target])
try:
return tree.sql(dialect=read)
except Exception: # noqa: BLE001
return None
def _group_columns(sql: Any, kind: str | None) -> set[str]:
"""一条查询分组用到的列(小写):有效记录条件不收分组列上的条件(那是指标自己的筛选范围)。"""
import sqlglot
from sqlglot import exp
from app.data.sqlcheck import dialect_of
if not isinstance(sql, str) or not sql.strip():
return set()
read = dialect_of(kind)
try:
tree = sqlglot.parse_one(sql, read=read)
except Exception: # noqa: BLE001
return set()
out: set[str] = set()
for group in tree.find_all(exp.Group):
select = group.parent if isinstance(group.parent, exp.Select) else None
for key in group.expressions:
index = _group_projection(key, list(select.expressions)) if select is not None else None
node = select.expressions[index] if index is not None else key
out |= {col.name.lower() for col in node.find_all(exp.Column) if col.name}
return out
def _merge(per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]) -> dict[str, Any]:
"""一个指标几个对不上的输入的差异合成一份:一个输入时就是它的;几个时每条前面写明是哪一列的输入。"""
if not per_input:
return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": NO_SQL}],
"fallback": True, "text_diff": []}
if len(per_input) == 1:
diff = per_input[0][2]
return {"equivalent": bool(diff["equivalent"]), "items": list(diff["items"]),
"fallback": bool(diff["fallback"]), "text_diff": list(diff.get("text_diff") or [])}
items: list[dict[str, Any]] = []
lines: list[str] = []
for inp, _, diff in per_input:
items += [{**i, "text": f"输入「{inp['column']}」:{i.get('text')}"} for i in diff["items"]]
lines += list(diff.get("text_diff") or [])
return {"equivalent": all(d["equivalent"] for _, _, d in per_input), "items": items,
"fallback": any(d["fallback"] for _, _, d in per_input), "text_diff": lines[:40]}
# --------------------------------------------------------------------------
# 恢复之后:审核人的选择、缺口、数据目录
# --------------------------------------------------------------------------
def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tuple[dict[str, str], str]:
"""审核人的回复 → ({指标 id: a | b | neither}, 备注)。回复的 value 是 {choices, note};某个指标没选、选了认不出的
不进 choices:照实记成「没有作出选择」,不替人说「两边都不对」,也不替人认可任何一边(缺口照样记、出具照样降档)。
通用的输入卡送来的是一句话,所有指标都按没选记。明确驳回(approved 为否)的抛 NodeRejected,和人工审批节点的
填写模式一样:画布上接了「驳回」出口的沿它往下走(engine/compiler),没接的运行收成被驳回。"""
decision = read_decision(response)
if isinstance(response, dict) and decision.rejected:
raise NodeRejected.of(ctx, f"人工驳回:{decision.note or '未说明原因'}", kind="dual_check", decision=decision)
value = decision.value if isinstance(decision.value, dict) else response if isinstance(response, dict) else {}
raw = value.get("choices") if isinstance(value.get("choices"), dict) else {}
choices = {r["metric_id"]: raw[r["metric_id"]] for r in rows if raw.get(r["metric_id"]) in CHOICES}
note = value.get("note") if isinstance(value.get("note"), str) and value.get("note").strip() else decision.note
return choices, str(note or "").strip()
def _gaps(rows: list[dict[str, Any]], choices: dict[str, str]) -> list[str]:
out: list[str] = []
for row in rows:
choice = choices.get(row["metric_id"])
if row["status"] == UNCHECKED:
out.append(GAP_UNCHECKED.format(name=row["name"], reason=row.get("reason") or "原因不明"))
elif row["status"] == DIFFERENT and choice != "a":
text = GAP_BLIND if choice == "b" else GAP_NEITHER if choice == "neither" else GAP_UNANSWERED
out.append(text.format(name=row["name"]))
return out
_CHOICE_TEXT = {"a": "采用原流程", "b": "采用盲算", "neither": "两边都不对"}
def _resolved_text(rows: list[dict[str, Any]], choices: dict[str, str]) -> str:
parts = ";".join(f"「{r['name']}」{_CHOICE_TEXT.get(choices.get(r['metric_id']), '没有作出选择')}"
for r in rows[:8])
return f"审核人已裁决 {len(rows)} 个不一致的指标:{parts}" + ("等" if len(rows) > 8 else "")
async def _catalog(ctx: NodeContext, candidates: list[dict[str, Any]]) -> dict[str, Any]:
from app.data.catalog import TABLE_FIELD_LABEL
result = await record_choices(candidates, run_id=ctx.run.run_id, node_title=ctx.node.title, actor=ctx.actor())
written, skipped = result["written"], result["skipped"]
parts = []
if written:
parts.append("已写入数据目录:" + "、".join(
f"「{w['table']}」的{TABLE_FIELD_LABEL.get(w['item'], w['item'])}" for w in written))
if skipped:
parts.append("未写入数据目录:" + ";".join(s["reason"] for s in skipped[:3])
+ (f"等 {len(skipped)} 条" if len(skipped) > 3 else ""))
ctx.emit(EventType.LOG, level="info", code="dual_check_catalog", written=written, skipped=skipped,
message="。".join(parts))
return result
# --------------------------------------------------------------------------
# 产出
# --------------------------------------------------------------------------
def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, Any] | None, checked: dict[str, Any],
*, status: str, gaps: list[str], choices: dict[str, str] | None = None, note: str = "",
artifact: str | None = None, catalog: dict[str, Any] | None = None) -> dict[str, Any]:
rows = checked["rows"]
picked = choices or {}
counts = {"agreed": sum(1 for r in rows if r["status"] == SAME),
"disagreed": sum(1 for r in rows if r["status"] == DIFFERENT),
"unchecked": sum(1 for r in rows if r["status"] == UNCHECKED)}
metrics = [{
"metric_id": r["metric_id"], "name": r["name"], "unit": r["unit"], "status": r["status"],
"original_rendered": r["original"]["rendered"], "blind_rendered": r["blind"]["rendered"],
"delta_pct": r["delta_pct"],
**({"reason": r["reason"]} if r.get("reason") else {}),
**({"choice": picked[r["metric_id"]]} if r["metric_id"] in picked else {}),
} for r in rows]
result: dict[str, Any] = {"status": status, "counts": counts, "metrics": metrics, "model": checked.get("model"),
"same_model": bool(checked.get("same_model"))}
if artifact:
result["card_artifact"] = artifact
if choices is not None:
result.update(choices=choices, note=note)
if catalog is not None:
result["catalog"] = catalog
if gaps:
result["issuance_gaps"] = gaps
entry: dict[str, Any] = {
"kind": "dual_check", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), "card": card_id,
"status": status, "counts": counts,
"metrics": [{"id": m["metric_id"], "status": m["status"], **({"choice": m["choice"]} if "choice" in m else {})}
for m in metrics],
**({"metric_set": card["artifact"]} if card and isinstance(card.get("artifact"), str) else {}),
**({"artifact": artifact} if artifact else {}),
}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}, "evidence": [entry]}
usage = checked.get("usage") or {}
if usage.get("calls"):
updates["usage"] = dict(usage)
return updates
__all__ = ["AGREED", "CHOICES", "NONE_CHECKED", "PARTIAL", "REJECTED", "RESOLVED", "SKIPPED", "run_dual_check"]