合入期 0 核查工具箱:SQL 语义(规范化、方面、差异说明)、只读核查 agent、核查每日花费账、核查设置组、数据源查询工具的强制只读

This commit is contained in:
ThearchyHelios
2026-10-05 02:52:16 +08:00
10 changed files with 4756 additions and 16 deletions
+172 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import math
import os
from typing import Any
@@ -14,9 +15,11 @@ from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
from app.db.base import get_session
from app.db.models import Provider, Setting
from app.engine.judge import JUDGE_DEFAULTS, SETTING_GROUP as JUDGE_GROUP, SPEND_KEY as JUDGE_SPEND
from app.engine.judge import daily_spend, judge_settings
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS as JUDGE_MAX_TOKENS
from app.engine.judge import SETTING_GROUP as JUDGE_GROUP, SPEND_KEY as JUDGE_SPEND
from app.engine.judge import daily_spend, judge_model_spec, judge_settings
from app.engine.judge import sanitize_settings as judge_values, settings_problem as judge_problem
from app.engine.spend import SPEND_KEY as VERIFY_SPEND, daily_spend as verify_daily_spend
from app.providers import catalog
from app.providers.factory import ModelSpec, ProviderNotConfigured, _resolve_key, build_chat_model
@@ -412,6 +415,153 @@ async def probe_provider_models(
return {"ok": True, "models": models, "url": url}
# --------------------------------------------------------------------------
# 核查:双盲复算、反方审稿、审批质询共用的一组设置
# --------------------------------------------------------------------------
#: 设置里 verify 这一组。三项都是让一个只读 agent 查库核对(engine/verify_agent.py),共用核查模型和每日金额上限;
#: 各自的开关和上限分开放。上限写 null 表示不限(和裁判一组的约定一样)
VERIFY_GROUP = "verify"
VERIFY_DEFAULTS: dict[str, Any] = {
"provider": None, # 核查模型:空着沿用证据裁判模型的取值链(verify_model_spec)
"model": None,
# 双盲复算:tolerance 是允许的相对误差,两边的值差在这个比例以内算一致
"dual_check": {"enabled": True, "tolerance": 1e-6, "max_queries": 6, "timeout_s": 120},
# 反方审稿:max_claims 是每份报告最多挑几句关键结论来审,查询次数、时长按每句算
"challenge": {"enabled": True, "max_claims": 5, "max_queries": 4, "timeout_s": 90},
# 审批质询:查询次数、时长按每次提问算
"inquiry": {"enabled": True, "max_queries": 6, "timeout_s": 120},
"daily_max_usd": 2.0, # 全局每日核查金额上限(按本地日期),和裁判的每日上限分开记账(engine/spend.py)
}
#: 三项各自那一组的键
_VERIFY_PARTS = ("dual_check", "challenge", "inquiry")
_VERIFY_COUNTS = frozenset({"max_queries", "max_claims"})
#: 设置页「核查」一节各项的叫法:报错时写它,不写键名。嵌套的项写成「组.项」
VERIFY_SETTING_LABEL = {
"provider": "核查模型 · 接入", "model": "核查模型 · 模型",
"daily_max_usd": "核查每日金额上限(美元)",
"dual_check": "双盲复算", "dual_check.enabled": "双盲复算 · 启用",
"dual_check.tolerance": "双盲复算 · 允许的相对误差", "dual_check.max_queries": "双盲复算 · 每项最多查询次数",
"dual_check.timeout_s": "双盲复算 · 每项时长上限(秒)",
"challenge": "反方审稿", "challenge.enabled": "反方审稿 · 启用",
"challenge.max_claims": "反方审稿 · 每份报告最多审几句", "challenge.max_queries": "反方审稿 · 每句最多查询次数",
"challenge.timeout_s": "反方审稿 · 每句时长上限(秒)",
"inquiry": "审批质询", "inquiry.enabled": "审批质询 · 启用",
"inquiry.max_queries": "审批质询 · 每次最多查询次数", "inquiry.timeout_s": "审批质询 · 每次时长上限(秒)",
}
def _positive(value: Any, *, whole: bool) -> bool:
"""上限:null(不限),或大于 0 的有限数;whole 时还得是整数(次数、句数)。True / False 不算数。"""
if value is None:
return True
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
return False
return not (whole and int(value) != value)
def _tolerance_ok(value: Any) -> bool:
"""允许的相对误差:不小于 0 的有限数(0 表示必须完全一致)。不能是 null:没有「不限」的说法。"""
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) and value >= 0
def _verify_item_ok(key: str, value: Any) -> bool:
if key == "enabled":
return isinstance(value, bool)
if key == "tolerance":
return _tolerance_ok(value)
return _positive(value, whole=key in _VERIFY_COUNTS)
def _verify_item_problem(part: str, key: str, value: Any) -> str | None:
label = VERIFY_SETTING_LABEL[f"{part}.{key}"]
if _verify_item_ok(key, value):
return None
if key == "enabled":
return f"「{label}」需要选择开启或关闭,当前为「{value}」"
if key == "tolerance":
return f"「{label}」需要填写不小于 0 的数(例如 0.000001 表示百万分之一),当前为「{value}」"
words = "正整数(次数)" if key == "max_queries" else "正整数(句数)" if key == "max_claims" else "大于 0 的秒数"
return f"「{label}」需要填写{words},留空表示不限,当前为「{value}」"
def verify_problem(value: Any) -> str | None:
"""设置页写进来的 verify 这一组有什么问题,没问题返回 None。说明写设置页上的叫法。"""
if not isinstance(value, dict):
return f"「核查」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in VERIFY_DEFAULTS:
return (f"「核查」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{VERIFY_SETTING_LABEL[k]}」" for k in VERIFY_DEFAULTS))
label = VERIFY_SETTING_LABEL[key]
if key in ("provider", "model"):
if item is not None and not isinstance(item, str):
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示沿用证据裁判模型,当前为「{item}」"
elif key == "daily_max_usd":
if not _positive(item, whole=False):
return f"「{label}」需要填写大于 0 的金额(美元),留空表示不限,当前为「{item}」"
elif not isinstance(item, dict):
return f"「{label}」设置格式有误,需要是一个对象,当前为「{item}」"
else:
allowed = VERIFY_DEFAULTS[key]
for sub, sub_value in item.items():
if sub not in allowed:
return (f"「{label}」设置中不支持「{sub}」,可设置的项:"
+ "、".join(f"「{VERIFY_SETTING_LABEL[f'{key}.{k}']}」" for k in allowed))
if problem := _verify_item_problem(key, sub, sub_value):
return problem
return None
def verify_values(stored: Any) -> dict[str, Any]:
"""库里存的 verify 一组 → 补齐默认的完整设置。存坏了的项(旧版本、手改)按默认,存了 null 的上限就是不限;
三项各自那一组逐项补,只存了一项的其余照默认。"""
stored = stored if isinstance(stored, dict) else {}
out: dict[str, Any] = {k: dict(v) if isinstance(v, dict) else v for k, v in VERIFY_DEFAULTS.items()}
for key in ("provider", "model"):
value = stored.get(key)
out[key] = value.strip() or None if isinstance(value, str) else None
if "daily_max_usd" in stored and _positive(stored["daily_max_usd"], whole=False):
out["daily_max_usd"] = stored["daily_max_usd"]
for part in _VERIFY_PARTS:
saved = stored.get(part)
if not isinstance(saved, dict):
continue
for key in VERIFY_DEFAULTS[part]:
if key in saved and _verify_item_ok(key, saved[key]):
out[part][key] = saved[key]
return out
async def verify_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 verify 一组,没存过的键补默认(读法同 judge_settings)。节点、质询接口都从这里读。"""
row = await session.get(Setting, VERIFY_GROUP)
return verify_values(row.value if row else None)
def _model_text(value: Any) -> str | None:
return value.strip() or None if isinstance(value, str) else None
async def verify_model_spec(session: AsyncSession, node_config: dict[str, Any] | None = None, *,
settings: dict[str, Any] | None = None) -> ModelSpec:
"""核查用哪个模型:节点配置里的 provider / model > 设置里的核查模型 > 证据裁判模型的取值链(裁判设置 >
助手模型 > 默认接入,见 judge.judge_model_spec)。哪一级写了就整组用哪一级,不跨级拼接入和模型。
和裁判一样关掉 thinking、max_tokens 4096:核查最后要按结构交结果(强制工具调用),和思考模式不兼容。
建议和被核查的模型不同,否则等于自己核自己;比较、提醒由调用方做(它知道被核查的是哪个模型)。
"""
node = node_config if isinstance(node_config, dict) else {}
provider, model = _model_text(node.get("provider")), _model_text(node.get("model"))
if not (provider or model):
chosen = settings if settings is not None else await verify_settings(session)
provider, model = _model_text(chosen.get("provider")), _model_text(chosen.get("model"))
if not (provider or model):
return await judge_model_spec(session, None)
return ModelSpec(provider=provider, model=model, max_tokens=JUDGE_MAX_TOKENS, thinking="off")
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
@@ -440,6 +590,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
# 结论句裁判(engine/judge.py):证据裁判模型和各项上限,上限存 null 表示不限。不放进 limits:
# 那一组是环境变量定的、只读,这一组是用户自己定的
JUDGE_GROUP: dict(JUDGE_DEFAULTS),
# 核查(双盲复算、反方审稿、审批质询):核查模型、三项的开关和上限、每日金额上限。和裁判一组一样由用户定
VERIFY_GROUP: verify_values(None),
}
@@ -488,11 +640,13 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
# limits 是服务端配置(环境变量),只读:库里可能存着旧版设置页写进去的一份
#(见过 max_agent_steps: 25),它从来不生效,却会盖住真实的上限,界面照着错的数许诺
stored.pop("limits", None)
# 裁判的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零
# 裁判、核查的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零
stored.pop(JUDGE_SPEND, None)
stored.pop(VERIFY_SPEND, None)
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
# 裁判设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
# 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP))
for key, value in stored.items():
merged.setdefault(key, value)
return merged
@@ -508,14 +662,16 @@ async def put_settings(
) -> dict[str, Any]:
from app.tools.trust import SETTING_KEY as TRUST_KEY
# 先查后写:一组里有一项不对就整个请求不落库,免得存下半组
if JUDGE_GROUP in payload.values and (problem := judge_problem(payload.values[JUDGE_GROUP])):
# 先查后写:一组里有一项不对就整个请求不落库,免得存下半组
raise HTTPException(422, problem)
if VERIFY_GROUP in payload.values and (problem := verify_problem(payload.values[VERIFY_GROUP])):
raise HTTPException(422, problem)
for key, value in payload.values.items():
if key in (TRUST_KEY, "limits", JUDGE_SPEND):
if key in (TRUST_KEY, "limits", JUDGE_SPEND, VERIFY_SPEND):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的,写进库也不生效。
# 裁判的每日计数是引擎记的账,只有裁判自己写
# 裁判、核查的每日计数是引擎记的账,只有引擎自己写
continue
row = await session.get(Setting, key)
if row:
@@ -536,6 +692,15 @@ async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[
return {**await daily_spend(), "daily_max_usd": (await judge_settings(session))["daily_max_usd"]}
@router.get("/settings/verify/spend")
async def get_verify_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)核查(双盲复算、反方审稿、审批质询)花了多少,设置页放在核查的每日上限旁边。只读。
形状同 /settings/judge/spend:{date, usd, calls, unpriced_calls, daily_max_usd},daily_max_usd 为 null 表示不限。
"""
return {**await verify_daily_spend(), "daily_max_usd": (await verify_settings(session))["daily_max_usd"]}
@router.get("/system")
async def system_info() -> dict[str, Any]:
"""给设置页顶部展示的运行环境概览。"""
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""核查花费账:双盲复算、反方审稿、审批质询里的模型调用,按本地日期记一本每日账。
照结论句裁判的每日计数做(judge.py 的 SPEND_KEY、_charge、_settle):调用前按目录价估算、先预留,加上这笔会超过
每日上限就不记;调用后按实际用量找补差额。账落在设置表里,按本地日期滚动。
**为什么和裁判分开记。** 核查是一个 agent 查库、来回好几轮,一次花的钱比裁判判一份报告多得多。记在同一本账上,
核查一多,当天的裁判就全因为「每日上限」判不了——报告的出具会因此降档,而降档的原因和这份报告毫无关系。两本账
各管各的上限,互不挤占。
目录里没有价格的模型估不出金额:只计次数(unpriced_calls),金额上限对它不起作用,由调用方照实说明。
"""
from __future__ import annotations
import asyncio
import weakref
from datetime import datetime
from typing import Any
from app.db.base import SessionLocal
from app.db.models import Setting
#: 每日计数存在设置表的这一行:{date: 本地日期, usd, calls, unpriced_calls}。不从设置接口进出(api/settings.py)
SPEND_KEY = "verify_spend"
_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
def _lock() -> asyncio.Lock:
"""读改写这一行时持的锁,按事件循环各一把(asyncio.Lock 不能跨循环用)。
几个核查并行时各自预留:不串行的话两边读到同一个余额,会一起越过上限。
"""
loop = asyncio.get_running_loop()
lock = _locks.get(loop)
if lock is None:
lock = _locks[loop] = asyncio.Lock()
return lock
def _today() -> str:
"""本地日期:每日上限按它滚动。"""
return datetime.now().astimezone().date().isoformat()
def _spend_of(row: Any) -> dict[str, Any]:
value = row.value if row is not None and isinstance(row.value, dict) else {}
today = _today()
if value.get("date") != today:
return {"date": today, "usd": 0.0, "calls": 0, "unpriced_calls": 0}
return {"date": today, "usd": float(value.get("usd") or 0.0), "calls": int(value.get("calls") or 0),
"unpriced_calls": int(value.get("unpriced_calls") or 0)}
async def daily_spend() -> dict[str, Any]:
"""今天(本地日期)核查已经花了多少:{date, usd, calls, unpriced_calls}。估不出金额的调用只计次数。"""
async with SessionLocal() as session:
return _spend_of(await session.get(Setting, SPEND_KEY))
async def charge(amount: float, *, limit: float | None, priced: bool) -> str | None:
"""记一次调用、先按估算预留金额。加上这笔会超过每日上限就不记,返回 None;记下了返回记在哪天(找补时要用)。
limit 为 None 表示不限。估不出金额的调用(priced=False)照记次数,金额记 0,调用方应传 limit=None:
拿 0 去比上限,等于说它不花钱。
"""
async with _lock():
async with SessionLocal() as session:
row = await session.get(Setting, SPEND_KEY)
spend = _spend_of(row)
if limit is not None and spend["usd"] + amount > limit + 1e-12:
return None
spend["usd"] = round(spend["usd"] + amount, 8)
spend["calls"] += 1
spend["unpriced_calls"] += 0 if priced else 1
if row is None:
session.add(Setting(key=SPEND_KEY, value=spend))
else:
row.value = spend
await session.commit()
return spend["date"]
async def settle(date: str, delta: float) -> None:
"""调用结束后按实际用量找补预留的差额。跨了天就不找补:预留记在前一天,今天从零算。"""
if not delta:
return
async with _lock():
async with SessionLocal() as session:
row = await session.get(Setting, SPEND_KEY)
spend = _spend_of(row)
if row is None or spend["date"] != date:
return
spend["usd"] = max(0.0, round(spend["usd"] + delta, 8))
row.value = spend
await session.commit()
__all__ = ["SPEND_KEY", "charge", "daily_spend", "settle"]
+982
View File
@@ -0,0 +1,982 @@
"""核查用的只读 agent:查库,交回有出处的结果。双盲复算、反方审稿、审批质询共用这一个。
三处要的是同一件事:另起一个模型,只给它几个数据源的只读查询工具,让它自己写 SQL 去核对一个数、一句结论、一个
提问,最后交回结构化的结果——而且结果里的数要能核对。
**只读。** 工具只建给定数据源的 db_schema / db_query,建的时候 force_readonly:可写源上的写语句也被守卫拒绝
(tools/datasource.py)。这一路没有审批关卡,不能指望人在半路拦下一条 DELETE。
**数从快照取。** 每次成功的查询是一份查询快照(工具自己落的 put_json),按这个 agent 自己的顺序编 Q1、Q2…。
最后交结果时,调用方结构里的数值字段一律改成格子引用 {alias, row, column},由代码按引用从快照里取值:模型自己
写的数字不采信,取不到就是 None,不兜底成 0。数值字段的各种写法都认(type 列表、anyOf 里的数、$ref 引用进来的、
allOf 包着的);认不出的结构下模型写的数一律丢掉、照记下来,宁可少一个数,不让它绕过格子引用。理由文字里的数字
要写成 [[v:Qn.rX.列]],用本 agent 自己的台账(evidence.build_catalog)组装成文档(evidence.compose_doc),裸数字
会被标出来。
**不进写作目录。** 节点里调用时(给了 emit)每次工具调用发 tool.start / tool.end,查询的 tool.end 带
query_artifact 和 role: verify——随运行封存、证据面板能下钻。但这里绝不往 state["evidence"] 写 kind: query:
报告的写作目录按台账里的 query 编号,核查的查询混进去,写作者就会把它们当成可以引用的证据。调用方只拿到结果,
台账条目由它按自己的种类记(dual_check / challenge / inquiry)。
**上限。** 查询次数、时长、金额(这一次、全局每日)任何一项到顶就停止查询,按已经查到的结果收尾:收尾那次
调用总是要做的(时长到顶时再给 SETTLE_GRACE_S 的宽限),所以每一轮都按收尾最贵的样子给它留出钱——收尾时要把
查到的结果交给模型,结果有多长事先不知道,按 _digest 的上限算。留过钱的收尾不再被上限拦下:某一轮的实际用量
超出了估算、或者并行的核查先用掉了当天的余额时,宁可超出上限一次收尾调用的钱,也不让已经查到的结果白查。
每日上限不传就按设置里的「核查每日金额上限」,明确传 None 才是不限。模型不在价格目录里时估不出金额,金额上限
对它无效,照裁判的说法记一条说明。每次调用都记进核查的每日账(engine/spend.py)。
永远不抛:模型没配好、调用失败、格式不对、触顶、库写不进去,都落在返回值里(error / limit)。模型调用都经
get_chat_model,测试换成剧本。
"""
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import time
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, ToolMessage
from app.core import artifact_store
from app.core.errors import describe_exception, not_configured
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine import spend
from app.engine.evidence import build_catalog, cell_eid, compose_doc, query_entry_fields, schema_ledger_entry
from app.engine.expressions import CellError, column_kind, locate_cell, numeric_text
from app.engine.judge import _json_in, _tokens, _usd
from app.engine.nodes.llm import _DIGEST_BUDGET, _digest, _split_structured, _typed, explain_model_error
from app.engine.state import message_text
from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded
from app.providers import catalog as pricing
from app.providers.factory import ModelSpec, ProviderNotConfigured, bind_tools_safely, get_chat_model
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX, build_datasource_tools
from app.tools.registry import ToolArgsError, ToolContext, Versions, args_model_of, prepare_args
logger = logging.getLogger(__name__)
#: 时长到顶之后,收尾那次调用(交结构化结果)最多再等这么久。不给宽限的话,到点时手里的查询全白查了
SETTLE_GRACE_S = 30.0
#: 估算金额时每一轮、收尾那次的输出 token 数(回复里带了用量就按实际找补)
_OUT_STEP = 512
_OUT_FINAL = 1024
#: 工具结果交给模型时最多这么多字:完整结果在查询快照里,收尾时另按 _digest 列出
_TOOL_CHARS = 12_000
#: 收尾时带上的模型最后那段说明最多这么多字。收尾那次调用的长度有了上限,循环里才能按它把钱留够
_SAID_CHARS = 2_000
#: 收尾那次调用里查询结果最多占多少字:_digest 的上限,加上它截断时末尾那句说明
_DIGEST_ROOM = _DIGEST_BUDGET + 64
#: 留钱时给「已达上限(…)」那句话留的字数
_LIMIT_ROOM = 40
#: 本地 $ref 最多展开这么多层
_REF_DEPTH = 8
#: 结果里自动加上的理由字段(调用方的结构里已经有同名的字符串字段就沿用它)
RATIONALE = "rationale"
#: 触顶原因
LIMITS = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd", "max_steps")
VERIFY_SYSTEM = (
"你是数据核查员:只读地查库,用查询结果完成交给你的任务。\n"
"- 先用 db_schema 工具看表结构,确认表名、字段名,再用 db_query 工具写只读 SQL。不能改数据,写语句会被拒绝\n"
"- 每次成功的查询前面都标着编号【证据 Qn】,最后交结果时用它说明每个数取自哪次查询的哪一格\n"
"- {limit}先想清楚要查什么,不要重复同样的查询\n"
"- 不要编造数字,查不到就照实说"
)
VERIFY_FINAL_PROMPT = (
"现在交结果,不能再查询。按给定结构填写:\n"
"- 数值字段一律写格子引用,形如 {\"alias\": \"Q1\", \"row\": 0, \"column\": \"列名\"}:alias 是【证据 Qn】里的编号,"
"row 是行号(从 0 数),column 是列名。系统按引用从查询结果里取值,你自己写的数字不会被采用;查不到就写 null,"
"不要估算\n"
"- rationale 写理由,简短说清依据。理由里出现的每个数字都写成 [[v:Q1.r0.列名]] 这样的引用,系统会换成查询结果"
"里的值;不要直接写数字,提到某次查询时写 [[see:Q1]]\n"
"- 其余字段按各自的说明填写"
)
_CELL_REF: dict[str, Any] = {
"type": "object",
"description": "格子引用:alias 是【证据 Qn】里的编号,row 是行号(从 0 数),column 是列名",
"properties": {"alias": {"type": "string"}, "row": {"type": "integer"}, "column": {"type": "string"}},
"required": ["alias", "row", "column"],
}
_RATIONALE_FIELD: dict[str, Any] = {
"type": "string",
"description": "理由:简短说清依据,里面的每个数字都写成 [[v:Q1.r0.列名]] 这样的引用",
}
Emit = Callable[..., Any]
class Default(Enum):
"""run_verify_agent 的 daily_max_usd 缺省值:按设置里的「核查每日金额上限」(api/settings.py 的
verify_settings)。节点、质询接口不必各自记得去读;明确传 None 才是不限。"""
SETTINGS = "settings"
@dataclass
class VerifyOutcome:
"""一次核查的结果。
result:调用方结构的形状,数值字段是从快照取到的值(取不到为 None),另有 rationale(理由渲染后的正文);
没交成结果时为 None,error 说明为什么。cells:每个数值字段的出处 {path, status, ref, alias, row, column,
artifact, eid, value},status 是 resolved(value 是那一格的值,那一格本身是 NULL 时为 None)/ missing(模型
交了 null:查不到)/ unresolved(reason 写原因:没交这个字段、引用指不到、指到的不是数;模型直接写了数的记
model_value,非数值字段里写的数也照此记下)。queries:成功的查询 {alias, artifact, sql, row_count, truncated,
source, tool, call_id}。
rationale:理由组装成的报告文档(evidence.compose_doc 的形状,violations 里有裸数字)。usage:{input_tokens,
output_tokens, total_tokens, cost_usd, calls, unpriced_calls}。limit:触顶原因(LIMITS 之一),没触顶为 None。
answered:模型是不是自己停下来的(没被上限掐断)。
"""
result: dict[str, Any] | None
cells: list[dict[str, Any]]
queries: list[dict[str, Any]]
rationale: dict[str, Any] | None
usage: dict[str, Any]
limit: str | None = None
error: str | None = None
model: str | None = None
answered: bool = False
notes: list[str] = field(default_factory=list)
duration_ms: int = 0
def as_dict(self) -> dict[str, Any]:
return asdict(self)
# --------------------------------------------------------------------------
# 结构:数值字段换成格子引用
# --------------------------------------------------------------------------
def _types(schema: Any) -> set[str]:
declared = schema.get("type") if isinstance(schema, dict) else None
return {declared} if isinstance(declared, str) else set(declared) if isinstance(declared, list) else set()
def _is_object(schema: Any) -> bool:
return isinstance(schema, dict) and ("object" in _types(schema) or "properties" in schema)
def _is_array(schema: Any) -> bool:
return isinstance(schema, dict) and ("array" in _types(schema) or "items" in schema)
def _numeric_types(schema: Any) -> set[str] | None:
"""数值字段允许的类型(number / integer,连同写在一起的 null、string);不是数值字段返回 None。
type 写成 "number"、["number", "null"],或者 anyOf / oneOf 里有一支是数(可为 null 的数一般这么写,
Pydantic 的 Optional[float] 就是)都算:有一支能放数,整个字段就得是格子引用,不然数从那一支漏过去。
"""
if not isinstance(schema, dict):
return None
types = set(_types(schema))
for key in ("anyOf", "oneOf"):
for branch in schema.get(key) or ():
if isinstance(branch, dict) and not _is_object(branch) and not _is_array(branch):
types |= _numeric_types(branch) or _types(branch)
return types if types & {"number", "integer"} else None
def _has_numbers(schema: Any, depth: int = 0) -> bool:
"""结构里有没有数值字段(嵌套的也算)。"""
if not isinstance(schema, dict) or depth > 32:
return False
if _numeric_types(schema) is not None:
return True
subs = [*(schema.get("properties") or {}).values(), schema.get("items"), schema.get("additionalProperties"),
*(schema.get("anyOf") or ()), *(schema.get("oneOf") or ())]
return any(_has_numbers(sub, depth + 1) for sub in subs if isinstance(sub, dict))
#: 值是「名字 → 结构」的键、值是一个结构的键、值是一串结构的键:展开引用时往里走;其余的键(enum、description…)原样
_SCHEMA_MAPS = ("properties", "patternProperties")
_SCHEMA_ONE = ("items", "additionalProperties", "contains", "not")
_SCHEMA_LIST = ("anyOf", "oneOf", "prefixItems")
def _merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
"""allOf 的两支并成一个节点:属性合在一起,必填合在一起,类型取并集(只用来认是不是数、是不是对象)。"""
out = dict(a)
for key, value in b.items():
if key == "properties" and isinstance(value, dict):
out["properties"] = {**(out.get("properties") or {}), **value}
elif key == "required" and isinstance(value, list):
out["required"] = list(dict.fromkeys([*(out.get("required") or []), *value]))
elif key == "type":
merged = _types(out) | _types(b)
out["type"] = next(iter(merged)) if len(merged) == 1 else sorted(merged)
else:
out.setdefault(key, value)
return out
def _inline(schema: Any) -> dict[str, Any]:
"""展开调用方结构里的本地引用($ref: #/$defs/…、#/definitions/…),allOf 并成一个节点,去掉 $defs。
后面换格子引用、取值只认 type / properties / items / anyOf 这几种写法:引用不展开,引用进来的数值字段就绕过了
格子引用这一关(Pydantic 生成的结构里,嵌套的对象全是 $ref)。引用不到的、绕回自己的(树形结构第二次遇到同一个
引用)就地断开,当作认不出的结构:模型在那里写的数照样不采信。
"""
root = schema if isinstance(schema, dict) else {}
def target(ref: Any) -> dict[str, Any] | None:
if not isinstance(ref, str) or not ref.startswith("#/"):
return None
node: Any = root
for part in ref[2:].split("/"):
node = node.get(part.replace("~1", "/").replace("~0", "~")) if isinstance(node, dict) else None
return node if isinstance(node, dict) else None
def expand(node: Any, trail: tuple[str, ...]) -> Any:
while isinstance(node, dict) and "$ref" in node:
ref, rest = node["$ref"], {k: v for k, v in node.items() if k != "$ref"}
found = target(ref)
if found is None or ref in trail or len(trail) >= _REF_DEPTH:
node = rest
break
node, trail = {**found, **rest}, (*trail, ref)
if not isinstance(node, dict):
return node
out: dict[str, Any] = {}
for key, value in node.items():
if key in ("$defs", "definitions", "allOf"):
continue
if key in _SCHEMA_MAPS and isinstance(value, dict):
out[key] = {k: expand(v, trail) for k, v in value.items()}
elif key in _SCHEMA_ONE and isinstance(value, dict):
out[key] = expand(value, trail)
elif key in _SCHEMA_LIST and isinstance(value, list):
out[key] = [expand(v, trail) for v in value]
else:
out[key] = value
for part in node.get("allOf") or ():
if isinstance(part, dict):
out = _merge(out, expand(part, trail))
return out
return expand(root, ())
def result_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""交给模型的结构:调用方结构里的数值字段(见 _numeric_types;嵌套的对象、数组、anyOf 的分支里的也算,引用先
展开)一律换成可为 null 的格子引用,另加一个理由字段(已有同名的字符串字段就沿用)。其余字段原样。"""
def rewrite(node: Any) -> Any:
if not isinstance(node, dict):
return node
if _numeric_types(node) is not None:
out: dict[str, Any] = {"anyOf": [_CELL_REF, {"type": "null"}]}
if node.get("description"):
out["description"] = f"{node['description']}(写格子引用,查不到写 null)"
return out
out = dict(node)
if isinstance(node.get("properties"), dict):
out["properties"] = {k: rewrite(v) for k, v in node["properties"].items()}
out.setdefault("type", "object")
for key in ("items", "additionalProperties"):
if isinstance(node.get(key), dict):
out[key] = rewrite(node[key])
for key in ("anyOf", "oneOf"):
if isinstance(node.get(key), list):
out[key] = [rewrite(b) for b in node[key]]
return out
inlined = _inline(schema)
root = rewrite(inlined if _is_object(inlined) else {"type": "object", "properties": {}})
props = dict(root.get("properties") or {})
props.setdefault(RATIONALE, _RATIONALE_FIELD)
required = list(dict.fromkeys([*(root.get("required") or []), RATIONALE]))
# 走工具调用的结构化输出要求函数名只含字母、数字、下划线
return {**root, "title": "verify_result", "type": "object", "properties": props, "required": required}
@dataclass(frozen=True)
class _Gap:
"""模型交的结果里这个位置没有值。reason 说为什么:没交这个字段、上一层的格式不对。"""
reason: str
_LEFT_OUT = _Gap("模型没有交这个字段")
def _fits(branch: dict[str, Any], value: Any) -> bool:
"""anyOf / oneOf 的这一支收不收这个值(按形状认)。"""
if isinstance(value, _Gap):
return _has_numbers(branch)
if isinstance(value, dict):
return _is_object(branch)
if isinstance(value, list):
return _is_array(branch)
if value is None:
return "null" in _types(branch)
if isinstance(value, bool):
return "boolean" in _types(branch)
if isinstance(value, str):
return "string" in _types(branch) or "enum" in branch or "const" in branch
# 数:能放数的分支已经整个换成了格子引用(_numeric_types),这里只剩码值里的数。True == 1,真假不算数
choices = [*(branch.get("enum") or ()), *([branch["const"]] if "const" in branch else [])]
return any(c == value and not isinstance(c, bool) for c in choices)
def _decoded(value: Any, kind: type) -> Any:
"""有的模型把嵌套的对象、数组写成一段 JSON 文本(裁判见过同样的事,judge._items_of):解得开、形状对就用解开的。"""
if isinstance(value, str) and isinstance(found := _json_in(value), kind):
return found
return value
class _Cells:
"""按格子引用从查询快照里取值:快照经工件库读(复验哈希),同一份只读一次。"""
def __init__(self, queries: list[dict[str, Any]]) -> None:
self.by_alias = {q["alias"]: q for q in queries}
self.memo: dict[str, tuple[Any, str | None]] = {}
self.cells: list[dict[str, Any]] = []
def snapshot(self, artifact: str) -> tuple[Any, str | None]:
if artifact not in self.memo:
try:
content = artifact_store.load(artifact)
self.memo[artifact] = (content, None if content is not None else "查询快照无法读取")
except ValueError:
self.memo[artifact] = (None, "查询快照与哈希不一致,可能已被修改")
return self.memo[artifact]
def take(self, value: Any, path: str, types: set[str]) -> Any:
"""一个数值字段:按引用取值,记下出处,返回值(取不到为 None)。types 是字段允许的类型:不许文本的,
引用的格子不是数(日期、名称、真假)就不采信——拿它和原流程的数比,比较本身就会出错。"""
if isinstance(value, _Gap):
self.cells.append({"path": path, "status": "unresolved", "reason": value.reason})
return None
if value is None:
self.cells.append({"path": path, "status": "missing"})
return None
value = _decoded(value, dict)
if not isinstance(value, dict):
# 模型直接写了数:不采信。照记下来,界面上能看到它想填什么
number = isinstance(value, (int, float)) or numeric_text(value, loose=True) is not None
self.cells.append({"path": path, "status": "unresolved", "model_value": value,
"reason": "模型直接写了数值,没有给出格子引用,不采信" if number
else "格子引用的格式不对(应写成 {alias, row, column}),不采信"})
return None
alias = str(value.get("alias") or value.get("call") or "").strip()
row, column = value.get("row"), value.get("column")
if isinstance(row, str) and row.strip().isdigit():
row = int(row) # 有的模型把整数写成 "0":意思没有歧义
elif isinstance(row, float) and row.is_integer():
row = int(row)
query = self.by_alias.get(alias)
if query is None:
self.cells.append({"path": path, "status": "unresolved", "alias": alias or None,
"reason": f"没有编号为 {alias or '(空)'} 的查询,可用的查询:"
+ ("、".join(self.by_alias) or "(无)")})
return None
content, why = self.snapshot(query["artifact"])
if why:
self.cells.append({"path": path, "status": "unresolved", "alias": alias, "artifact": query["artifact"],
"reason": f"{alias} 的{why}"})
return None
try:
raw, name = locate_cell(content, row, column)
except CellError as e:
self.cells.append({"path": path, "status": "unresolved", "alias": alias, "artifact": query["artifact"],
"reason": f"{alias} {e}"})
return None
truth = _typed(raw, {"type": sorted(types)}, column_kind(content, name))
cited = {"ref": f"{alias}.r{row}.{name}", "alias": alias, "row": row, "column": name,
"artifact": query["artifact"], "eid": cell_eid(query["artifact"], row, name), "value": truth}
if truth is not None and "string" not in types and (isinstance(truth, bool)
or not isinstance(truth, (int, float))):
self.cells.append({"path": path, "status": "unresolved", **cited,
"reason": f"{alias} 第 {row} 行「{name}」的值是「{truth}」,不是数值,不采信"})
return None
self.cells.append({"path": path, "status": "resolved", **cited})
return truth
def walk(self, schema: Any, value: Any, path: str = "") -> Any:
"""按调用方的结构(_inline 展开过的)走一遍模型交的结果:数值字段换成从快照取到的值,其余原样。"""
types = _numeric_types(schema)
if types is not None:
return self.take(value, path, types)
if not isinstance(schema, dict):
return self.scrub({}, value, path)
branches = [b for key in ("anyOf", "oneOf") for b in schema.get(key) or () if isinstance(b, dict)]
if branches:
rest = {k: v for k, v in schema.items() if k not in ("anyOf", "oneOf")}
if any(_is_object(b) for b in branches):
value = _decoded(value, dict)
if any(_is_array(b) for b in branches):
value = _decoded(value, list)
if value is None and any("null" in _types(b) for b in branches):
return None
chosen = next((b for b in branches if _fits(b, value)), None)
return self.walk({**rest, **chosen} if chosen is not None else rest, value, path)
if _is_object(schema):
return self._object(schema, value, path)
if _is_array(schema):
return self._array(schema, value, path)
return self.scrub(schema, value, path)
def _object(self, schema: dict[str, Any], value: Any, path: str) -> dict[str, Any]:
given = _decoded(value, dict)
if isinstance(given, dict):
def child(key: str) -> Any:
return given[key] if key in given else _LEFT_OUT
else:
gap = given if given is None or isinstance(given, _Gap) else _Gap("模型交的格式不对:这里应是一个对象")
def child(key: str) -> Any:
return gap
props = schema.get("properties") or {}
out = {k: self.walk(sub, child(k), f"{path}.{k}" if path else k) for k, sub in props.items()}
extra = schema.get("additionalProperties")
for k, v in (given.items() if isinstance(given, dict) else ()):
if k in props:
continue
if isinstance(extra, dict):
out[k] = self.walk(extra, v, f"{path}.{k}" if path else k)
elif extra is True or (not props and extra is not False):
# 没写属性的对象(任意键)照收,里面的数照样不采信;写了属性的,没声明的键不要
out[k] = self.scrub({}, v, f"{path}.{k}" if path else k)
return out
def _array(self, schema: dict[str, Any], value: Any, path: str) -> list[Any] | None:
items = _decoded(value, list)
if isinstance(items, list):
sub = schema.get("items") if isinstance(schema.get("items"), dict) else {}
return [self.walk(sub, v, f"{path}[{i}]") for i, v in enumerate(items)]
if _has_numbers(schema):
if items is None:
self.cells.append({"path": path, "status": "missing"})
elif isinstance(items, _Gap):
self.cells.append({"path": path, "status": "unresolved", "reason": items.reason})
else:
self.cells.append({"path": path, "status": "unresolved", "model_value": items,
"reason": "模型交的格式不对:这里应是一个数组"})
elif items is not None and not isinstance(items, _Gap):
self.scrub({}, items, path)
return None
def scrub(self, schema: Any, value: Any, path: str) -> Any:
"""认不出的结构(没写类型、文本字段、没写 items 的数组……)下的值:文本、真假、null 原样,数一律不采信、
照记下来(码值里的数是调用方给的选项,不是查出来的数,照收),对象、数组逐项照此处理。"""
if isinstance(value, _Gap):
return None
if value is None or isinstance(value, (bool, str)):
return value
if isinstance(value, (int, float)):
if isinstance(schema, dict) and _fits(schema, value):
return value
self.cells.append({"path": path, "status": "unresolved", "model_value": value,
"reason": "这里不是数值字段,模型却写了数值,不采信"})
return None
if isinstance(value, dict):
return {k: self.scrub({}, v, f"{path}.{k}" if path else k) for k, v in value.items()}
if isinstance(value, list):
return [self.scrub({}, v, f"{path}[{i}]") for i, v in enumerate(value)]
return None
# --------------------------------------------------------------------------
# 花费
# --------------------------------------------------------------------------
class _Budget:
"""金额上限(这一次、全局每日)和核查的每日账。调用前按估算预留,调用后按实际用量找补。"""
def __init__(self, model_id: str, max_cost_usd: float | None, daily_max_usd: float | None) -> None:
self.model_id = model_id
self.priced = pricing.find_pricing(model_id) is not None
self.max_cost_usd = max_cost_usd
self.daily_max_usd = daily_max_usd
self.spent = 0.0
def estimate(self, text: str, out: int, *, room: int = 0) -> float:
"""按文本估一次调用的金额。room 是还不知道内容、按最坏情况(一个字一个 token)算的输入字数。"""
return pricing.estimate_cost(self.model_id, _tokens(text) + room, out) if self.priced else 0.0
async def reserve(self, estimate: float, *, keep: float = 0.0, kept: bool = False
) -> tuple[str | None, str | None]:
"""(记在哪天, None) 或 (None, 拦下的上限)。
keep:要额外留出的钱(收尾那次调用最贵的样子),只用来判断、不预留。kept:这就是循环里一直给它留着钱的
那次收尾,不再按上限拦,照记账:每一轮都留够了,还超出只能是某一轮实际用得比估算多,或者并行的核查先用掉了
当天的余额(每日余额只是比了一下、没有真的预留)。这时宁可超出上限一次收尾调用的钱,也不让查到的结果白查。
"""
cap = self.daily_max_usd if self.priced and not kept else None
if not kept:
if self.priced and self.max_cost_usd is not None and \
self.spent + estimate + keep > self.max_cost_usd + 1e-12:
return None, "max_cost_usd"
if cap is not None and keep and (await spend.daily_spend())["usd"] + estimate + keep > cap + 1e-12:
return None, "daily_max_usd"
day = await spend.charge(estimate, limit=cap, priced=self.priced)
return (day, None) if day is not None else (None, "daily_max_usd")
async def settle(self, day: str, estimate: float, inp: int, out: int) -> float:
"""按实际用量找补,返回这一次的金额。回复没带用量(有的网关不给、调用超时)就按估算记:宁可多记。"""
actual = (pricing.estimate_cost(self.model_id, inp, out) if inp or out else estimate) if self.priced else 0.0
await spend.settle(day, actual - estimate)
self.spent += actual
return actual
# --------------------------------------------------------------------------
# 主循环
# --------------------------------------------------------------------------
async def _announce(emit: Emit | None, kind: EventType, **data: Any) -> None:
"""经 emit 发一条事件(同步、协程函数都认);没给 emit 就不发。发不出去不影响核查本身。"""
if emit is None:
return
try:
out = emit(kind, **data)
if inspect.isawaitable(out):
await out
except Exception: # noqa: BLE001 - 事件只是留痕
logger.warning("核查的事件没发出去:%s", kind, exc_info=True)
async def _bounded(job: Any, seconds: float | None) -> Any:
if seconds is None:
return await job
return await asyncio.wait_for(job, timeout=max(seconds, 0.001))
def _usage(message: Any) -> tuple[int, int]:
meta = getattr(message, "usage_metadata", None) or {}
return int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0)
def _limit_words(reason: str, *, max_queries: int | None, timeout_s: float | None, max_cost_usd: float | None,
daily_max_usd: float | None, steps: int) -> str:
if reason == "max_queries":
return f"最多查询 {max_queries} 次"
if reason == "timeout_s":
return f"时长上限 {timeout_s:g} 秒"
if reason == "max_cost_usd":
return f"这次核查的金额上限 {_usd(max_cost_usd)}"
if reason == "daily_max_usd":
return f"全局每日核查金额上限 {_usd(daily_max_usd)}"
return f"最多 {steps} 轮"
def _text_of(messages: list[BaseMessage]) -> str:
return "\n".join(str(m.content) if isinstance(m.content, str) else json.dumps(m.content, ensure_ascii=False)
for m in messages)
def _final_messages(system: str, task: str, queries: list[dict[str, Any]], said: str, limit_words: str | None
) -> list[BaseMessage]:
"""收尾那次调用的消息:任务、查到的结果(每次查询列出行号)、模型最后说的话,以及交结果的规则。
limit_words:触顶时是哪个上限(「最多查询 6 次」),告诉模型为什么不能再查。
长度有上限(任务和规则之外,查询结果按 _digest 的上限、说明按 _SAID_CHARS):循环里按这个上限给收尾留钱。
"""
parts = [f"任务:\n{task.strip()}"]
if limit_words:
parts.append(f"已达上限({limit_words}),不能再查询,按已经查到的结果交结果。")
parts.append(f"你查到的结果:\n{_digest(queries) if queries else '(没有成功的查询)'}")
parts.append(f"你最后的说明(只作参考,数以查询结果为准):\n{said.strip()[:_SAID_CHARS] or '(没有)'}")
parts.append(VERIFY_FINAL_PROMPT)
return [SystemMessage(content=system), HumanMessage(content="\n\n".join(parts))]
def _plain_messages(final: list[BaseMessage], schema: dict[str, Any]) -> list[BaseMessage]:
"""普通调用(不走结构化输出)的消息:结构不在请求里了,要写进消息,不然模型不知道要交哪些字段、字段叫什么,
交回来的字段对不上,全成了没交。"""
*head, last = final
text = (f"{last.content}\n\n只交一个 JSON 对象,不要写别的内容。对象的结构(JSON Schema):\n"
f"```json\n{json.dumps(schema, ensure_ascii=False)}\n```")
return [*head, HumanMessage(content=text)]
def _parsed(value: Any) -> dict[str, Any] | None:
"""结构化结果:对象;有的模型把整个对象写成一段 JSON 文本,也认。"""
if isinstance(value, str):
value = _json_in(value)
return value if isinstance(value, dict) else None
@dataclass
class _State:
"""一次核查过程中攒下的东西:出意外时也照这些交回,用了多少、查了什么不丢。"""
started: float
usage: dict[str, Any] = field(default_factory=lambda: {
"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_usd": 0.0, "calls": 0, "unpriced_calls": 0})
notes: list[str] = field(default_factory=list)
queries: list[dict[str, Any]] = field(default_factory=list)
ledger: list[dict[str, Any]] = field(default_factory=list)
model: str | None = None
def outcome(self, **fields: Any) -> VerifyOutcome:
usage = self.usage
usage["total_tokens"] = usage["input_tokens"] + usage["output_tokens"]
usage["cost_usd"] = round(usage["cost_usd"], 6)
shown = [{k: q[k] for k in ("alias", "artifact", "sql", "row_count", "truncated", "source", "tool", "call_id")}
for q in self.queries]
base: dict[str, Any] = {"result": None, "cells": [], "queries": shown, "rationale": None, "usage": usage,
"notes": self.notes, "model": self.model,
"duration_ms": int((time.monotonic() - self.started) * 1000)}
return VerifyOutcome(**{**base, **fields})
async def run_verify_agent(
*,
spec: ModelSpec,
system: str,
task: str,
sources: Iterable[str],
output_schema: dict[str, Any],
max_queries: int | None = 6,
timeout_s: float | None = 120,
max_cost_usd: float | None = None,
daily_max_usd: float | None | Default = Default.SETTINGS,
max_steps: int | None = None,
emit: Emit | None = None,
run_id: str = "",
node_id: str = "",
data_versions: dict[str, Any] | Versions | None = Versions.UNSET,
) -> VerifyOutcome:
"""让一个只读 agent 查库,按 output_schema 交回有出处的结果。永远不抛。
- spec:用哪个模型(一般是 api/settings.py 的 verify_model_spec)。system / task:交给模型的角色说明和任务。
- sources:可以查的数据源名;不存在、停用了的照实记进 notes。
- output_schema:调用方要的结果结构(JSON Schema 对象,可以带 $defs / $ref)。数值字段会换成格子引用交给模型、
由代码取值。
- 上限:max_queries(db_query 的执行次数,被拒的语句也算)、timeout_s(整个循环的时长,收尾另给宽限)、
max_cost_usd(这一次)、daily_max_usd(全局每日,和裁判分开记账;缺省按设置里的「核查每日金额上限」);
None 表示不限。max_steps 是模型最多想几轮,缺省按查询次数推。
- emit:节点里调用时传 NodeContext.emit,每次模型调用发 llm.start / llm.end、每次工具调用发 tool.start /
tool.end(role: verify,查询带 query_artifact);不在节点里(质询接口)就不传,什么都不发。
- run_id / node_id:查询快照、表结构快照记在谁名下。data_versions:上传表格用哪一版(运行固定的那份
data_versions;不在运行里传 Versions.CURRENT;缺省 UNSET 时上传源一律报错,不拿当前版本顶上)。
"""
state = _State(started=time.monotonic())
try:
return await _verify(state, spec=spec, system=system, task=task, sources=sources,
output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s,
max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit,
run_id=run_id, node_id=node_id, data_versions=data_versions)
except Exception as e: # noqa: BLE001 - 永远不抛:库写不进去、建工具出错这类意外也落在返回值里
logger.warning("核查出错", exc_info=True)
return state.outcome(error=f"核查出错({describe_exception(e)})")
async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sources: Iterable[str],
output_schema: dict[str, Any], max_queries: int | None, timeout_s: float | None,
max_cost_usd: float | None, daily_max_usd: float | None | Default, max_steps: int | None,
emit: Emit | None, run_id: str, node_id: str,
data_versions: dict[str, Any] | Versions | None) -> VerifyOutcome:
deadline = state.started + timeout_s if timeout_s is not None else None
usage, notes, queries, ledger = state.usage, state.notes, state.queries, state.ledger
outcome = state.outcome
steps = max_steps if max_steps is not None else max(4, (max_queries if max_queries is not None else 6) * 2 + 2)
owner = node_id or "verify"
def left() -> float | None:
return None if deadline is None else deadline - time.monotonic()
# ---- 模型
try:
async with SessionLocal() as session:
base_model, model_id = await get_chat_model(session, spec)
except Exception as e: # noqa: BLE001 - 核查模型没配好:这回核不了,照实交回
why = not_configured(e) if isinstance(e, ProviderNotConfigured) else describe_exception(e)
return outcome(error=f"核查模型不可用({why})")
state.model = model_id
if daily_max_usd is Default.SETTINGS:
from app.api.settings import verify_settings # 在这里导入:api.settings 导入了 engine 里的模块
async with SessionLocal() as session:
daily_max_usd = (await verify_settings(session))["daily_max_usd"]
budget = _Budget(model_id, max_cost_usd, daily_max_usd)
if not budget.priced and (max_cost_usd is not None or daily_max_usd is not None):
note = (f"模型「{model_id}」不在价格目录中,无法按 token 数估算金额:金额上限(这次核查、每日)对其无效,"
"费用只受查询次数和时长上限约束")
notes.append(note)
await _announce(emit, EventType.LOG, level="warn", code="verify_unpriced", message=note)
# ---- 工具:只读的 db_schema / db_query
wanted = list(dict.fromkeys(s for s in sources if isinstance(s, str) and s))
ctx = ToolContext(run_id=run_id, node_id=node_id, data_versions=data_versions, emit=emit)
async with SessionLocal() as session:
tools = await build_datasource_tools([f"{p}{s}" for s in wanted for p in (SCHEMA_PREFIX, QUERY_PREFIX)], ctx,
session, force_readonly=True)
tool_map = {t.name: t for t in tools}
if lost := [s for s in wanted if f"{QUERY_PREFIX}{s}" not in tool_map]:
notes.append("数据源" + "、".join(f"「{s}」" for s in lost) + "不存在或已停用,核查没有查它")
model = bind_tools_safely(base_model, tools, parallel=False)
limit_note = f"查询次数有上限,本次最多 {max_queries} 次,用完就不能再查。" if max_queries is not None else ""
head = "\n\n".join(p for p in (system.strip(), VERIFY_SYSTEM.format(limit=limit_note)) if p)
messages: list[BaseMessage] = [SystemMessage(content=head), HumanMessage(content=task)]
tool_text = "\n".join(f"{t.name}:{t.description}" for t in tools)
resolved = _inline(output_schema)
schema = result_schema(resolved)
# 结构也算输入:结构化输出时它是工具定义,退回普通调用时写在消息里
schema_text = json.dumps(schema, ensure_ascii=False)
# 收尾那次最贵的样子:查询结果、最后的说明都按上限、按一个字一个 token 算。查询结果要到查完才知道多长,
# 按眼下的消息估,等最后一条查询的结果进来就不够了(大段中文的结果尤其如此)
keep = budget.estimate(_text_of(_final_messages(head, task, [], "", "上" * _LIMIT_ROOM)) + schema_text,
_OUT_FINAL, room=_DIGEST_ROOM + _SAID_CHARS)
async def ask(payload: list[BaseMessage], runner: Any, estimate: float, day: str, purpose: str,
seconds: float | None) -> tuple[Any, BaseMessage | None, BaseException | None]:
"""调一次模型并记账。返回 (结果, 原始回复, 异常);超时的异常是 asyncio.TimeoutError。"""
await _announce(emit, EventType.LLM_START, model=model_id, purpose=purpose, role="verify",
message_count=len(payload))
began = time.perf_counter()
got: Any = None
raw: BaseMessage | None = None
failure: BaseException | None = None
try:
got = await _bounded(runner(payload), seconds)
raw = got if isinstance(got, BaseMessage) else _split_structured(got)[0]
except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001 - 调用失败落在返回值里
failure = e
inp, out = _usage(raw)
cost = await budget.settle(day, estimate, inp, out)
usage["input_tokens"] += inp
usage["output_tokens"] += out
usage["cost_usd"] += cost
usage["calls"] += 1
usage["unpriced_calls"] += 0 if budget.priced else 1
end = {"model": model_id, "purpose": purpose, "role": "verify",
"duration_ms": int((time.perf_counter() - began) * 1000), "input_tokens": inp, "output_tokens": out,
"total_tokens": inp + out, "cost_usd": round(cost, 6), "calls": 1}
if failure is not None:
end["error"] = "超过核查的时长上限,已放弃这次调用" if isinstance(failure, asyncio.TimeoutError) \
else explain_model_error(failure, model_id)
await _announce(emit, EventType.LLM_END, **end)
return got, raw, failure
# ---- 查库的循环
stopped: str | None = None
answered = False
kept = False # 有一轮按收尾最贵的样子留过钱:收尾不再按金额上限拦
said = ""
attempts = 0
done: dict[str, str] = {} # 成功过的查询(工具名 + 参数)→ 编号:一模一样的不再执行
for _ in range(steps):
remaining = left()
if remaining is not None and remaining <= 0:
stopped = "timeout_s"
break
estimate = budget.estimate(_text_of(messages) + tool_text, _OUT_STEP)
day, refused = await budget.reserve(estimate, keep=keep)
if refused:
stopped = refused
break
kept = True
response, _, failure = await ask(messages, model.ainvoke, estimate, day or "", "verify", remaining)
if isinstance(failure, asyncio.TimeoutError):
stopped = "timeout_s"
break
if failure is not None or not isinstance(response, BaseMessage):
return outcome(error=explain_model_error(failure, model_id) if failure else "核查模型没有给出回复")
messages.append(response)
calls = getattr(response, "tool_calls", None) or []
if not calls:
answered, said = True, message_text(response)
break
said = message_text(response) or said
for call in calls:
name, args = str(call.get("name") or ""), dict(call.get("args") or {})
call_id = str(call.get("id") or f"verify-{len(messages)}-{name}")
if stopped:
messages.append(ToolMessage(content=f"未执行:已达上限({stopped}),不能再调用工具。", tool_call_id=call_id))
continue
tool = tool_map.get(name)
if tool is None:
messages.append(ToolMessage(content=f"错误:没有名为 {name} 的工具,可用的工具:"
+ ("、".join(tool_map) or "(无)"), tool_call_id=call_id))
continue
args_schema = args_model_of(tool)
if args_schema is not None:
try:
args, _ = prepare_args(args_schema, args)
except ToolArgsError as e:
await _announce(emit, EventType.TOOL_ERROR, tool=name, call_id=call_id, error=str(e), role="verify")
messages.append(ToolMessage(content=str(e), tool_call_id=call_id))
continue
is_query = name.startswith(QUERY_PREFIX)
fingerprint = f"{name}\n{json.dumps(args, ensure_ascii=False, sort_keys=True, default=str)}"
if is_query and fingerprint in done:
alias = done[fingerprint]
messages.append(ToolMessage(content=f"这条查询和 {alias} 完全相同,结果见 {alias},没有再执行。",
tool_call_id=call_id))
continue
if is_query and max_queries is not None and attempts >= max_queries:
stopped = "max_queries"
messages.append(ToolMessage(content=f"未执行:已达查询次数上限({max_queries} 次),不能再查询。",
tool_call_id=call_id))
continue
remaining = left()
if remaining is not None and remaining <= 0:
stopped = "timeout_s"
messages.append(ToolMessage(content="未执行:已达核查的时长上限,不能再查询。", tool_call_id=call_id))
continue
content = await _run_tool(tool, name, args, call_id, remaining, emit, queries, ledger, owner)
if content is None:
stopped = "timeout_s"
content = "查询因核查的时长上限被中断,没有结果。"
if is_query:
attempts += 1
if queries and queries[-1]["call_id"] == call_id:
done[fingerprint] = queries[-1]["alias"]
messages.append(ToolMessage(content=content, tool_call_id=call_id))
if not stopped and max_queries is not None and attempts >= max_queries:
stopped = "max_queries"
if stopped:
break
else:
stopped = "max_steps"
def words_of(reason: str) -> str:
return _limit_words(reason, max_queries=max_queries, timeout_s=timeout_s, max_cost_usd=max_cost_usd,
daily_max_usd=daily_max_usd, steps=steps)
words = words_of(stopped) if stopped else None
if words:
await _announce(emit, EventType.LOG, level="warn", code="verify_limit", limit=stopped,
message=f"核查已达上限({words}),已停止查询,按已有结果收尾")
# ---- 收尾:按结构交结果。关掉 thinking:结构化输出靠强制工具调用,和思考模式不兼容
try:
async with SessionLocal() as session:
settler, _ = await get_chat_model(session, spec.model_copy(update={"thinking": "off"}))
except Exception as e: # noqa: BLE001
return outcome(limit=stopped, answered=answered, error=f"核查模型不可用({describe_exception(e)})")
final = _final_messages(head, task, queries, said, words)
try:
structured = settler.with_structured_output(schema, include_raw=True)
except Exception: # noqa: BLE001 - 模型不支持结构化输出:直接走普通调用,不白算一次调用
structured = None
remaining = left()
grace = None if remaining is None else max(remaining, SETTLE_GRACE_S)
finish = None if grace is None else time.monotonic() + grace
def until_finish() -> float | None:
return None if finish is None else finish - time.monotonic()
parsed: dict[str, Any] | None = None
failure: BaseException | None = None
if structured is not None:
estimate = budget.estimate(_text_of(final) + schema_text, _OUT_FINAL)
day, refused = await budget.reserve(estimate, kept=kept)
if refused:
return outcome(limit=stopped or refused, answered=answered, error=f"已达{words_of(refused)},没能交结果")
got, raw, failure = await ask(final, structured.ainvoke, estimate, day or "", "verify_result", until_finish())
parsed = None if failure is not None else _parsed(_split_structured(got)[1])
if parsed is None and raw is not None:
# 有的网关把 JSON 写在正文里、或者塞进了一个没按结构解析出来的工具调用
for candidate in [message_text(raw), *(c.get("args") for c in getattr(raw, "tool_calls", None) or [])]:
if (parsed := _parsed(candidate)) is not None:
break
if parsed is None and not isinstance(failure, asyncio.TimeoutError):
# 不支持结构化输出的模型:普通调用一次,结构写进消息,从回复里把 JSON 抠出来。结构化那次已经做过的,
# 这一次是补救,照常按上限拦;没做过的,这一次就是收尾,循环里留过钱
plain = _plain_messages(final, schema)
remaining = until_finish()
if remaining is None or remaining > 0:
estimate = budget.estimate(_text_of(plain), _OUT_FINAL)
day, refused = await budget.reserve(estimate, kept=kept and structured is None)
if refused and structured is None:
return outcome(limit=stopped or refused, answered=answered,
error=f"已达{words_of(refused)},没能交结果")
if not refused:
got, _, failure = await ask(plain, settler.ainvoke, estimate, day or "", "verify_result", remaining)
parsed = None if failure is not None else _parsed(message_text(got) if got is not None else None)
if parsed is None:
why = "收尾时超过时长上限,没能交结果" if isinstance(failure, asyncio.TimeoutError) else \
f"核查模型没有按结构交结果({explain_model_error(failure, model_id)})" if failure is not None else \
"核查模型没有按结构交结果"
return outcome(limit=stopped, answered=answered, error=why)
declared = list((resolved.get("properties") or {}) if _is_object(resolved) else {})
if declared and not any(k in parsed for k in declared):
# 一个要求的字段都没交:不是「查不到」,是没按结构交。不这么分,字段名对不上的回复会被当成什么都没查到
return outcome(limit=stopped, answered=answered,
error="核查模型没有按结构交结果(交回的内容里没有要求的字段:"
+ "、".join(declared[:8]) + ("等" if len(declared) > 8 else "") + ")")
# ---- 取值、组装理由
cells = _Cells(queries)
result = cells.walk(resolved, parsed) if _is_object(resolved) else {}
rationale = parsed.get(RATIONALE)
doc = None
if isinstance(rationale, str) and rationale.strip():
doc = compose_doc(rationale, build_catalog(nodes={}, ledger=ledger), node_id=owner, run_id=run_id or None)
result[RATIONALE] = doc["markdown"] if doc else ""
return outcome(result=result, cells=cells.cells, rationale=doc, limit=stopped, answered=answered)
async def _run_tool(tool: Any, name: str, args: dict[str, Any], call_id: str, remaining: float | None,
emit: Emit | None, queries: list[dict[str, Any]], ledger: list[dict[str, Any]],
owner: str) -> str | None:
"""执行一次工具调用,返回交给模型的内容;整个核查的时长到点、这次被中断时返回 None。
成功的查询编号(Q1、Q2…)、记进本 agent 自己的台账和查询清单,内容前面加「【证据 Qn】」。
"""
limit = limit_of(tool, name, args)
await _announce(emit, EventType.TOOL_START, tool=name, args=args, call_id=call_id, role="verify",
**limit_fields(limit))
began = time.perf_counter()
try:
result = await _bounded(run_bounded(tool.ainvoke(args), limit, name), remaining)
content = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str)
except ToolTimeout as e:
# 这条查询自己的时限到了:交回原因让模型改写,核查照常往下走。要排在下一条前面:ToolTimeout 也是
# TimeoutError(asyncio.TimeoutError 就是它),排在后面就被当成整个核查的时长到了
await _announce(emit, EventType.TOOL_ERROR, tool=name, call_id=call_id, role="verify", timed_out=True,
duration_ms=int((time.perf_counter() - began) * 1000), error=str(e))
return str(e)
except asyncio.TimeoutError:
await _announce(emit, EventType.TOOL_ERROR, tool=name, call_id=call_id, role="verify", timed_out=True,
duration_ms=int((time.perf_counter() - began) * 1000),
error="查询因核查的时长上限被中断")
return None
except Exception as e: # noqa: BLE001 - 工具失败交回模型,让它自己改
message = f"工具执行失败:{describe_exception(e)}"
await _announce(emit, EventType.TOOL_ERROR, tool=name, call_id=call_id, role="verify",
duration_ms=int((time.perf_counter() - began) * 1000), error=message[:300])
return message
elapsed = int((time.perf_counter() - began) * 1000)
fields = query_entry_fields(content) if name.startswith(QUERY_PREFIX) else None
extra: dict[str, Any] = {}
if fields is not None:
alias = f"Q{len(queries) + 1}"
data = json.loads(content)
schema = fields.get("schema_artifact")
seen = schema and any(e.get("kind") == "schema" and e.get("artifact") == schema for e in ledger)
frozen = schema_ledger_entry(schema, node_id=owner, exec_no=1) if schema and not seen else None
shaped = {"schema_artifact": schema, "tables": fields["tables"]} if seen or frozen else {}
ledger.append({"kind": "query", "node_id": owner, "exec": 1, "artifact": fields["artifact"],
"call_id": call_id, "tool": name, "source": fields["source"], "columns": fields["columns"],
"rows": fields["rows"], "truncated": fields["truncated"], **shaped})
if frozen:
ledger.append(frozen)
queries.append({"alias": alias, "call_id": call_id, "tool": name, "source": fields["source"],
"sql": str(data.get("sql") or args.get("sql") or ""), "artifact": fields["artifact"],
"columns": fields["columns"], "rows": data.get("rows") or [], "row_count": fields["rows"],
"truncated": fields["truncated"]})
extra = {"query_artifact": fields["artifact"], "alias": alias,
**({"schema_artifact": schema} if schema else {}),
**({"checks": data["checks"]} if isinstance(data.get("checks"), list) and data["checks"] else {})}
content = f"【证据 {alias}】\n{content}"
await _announce(emit, EventType.TOOL_END, tool=name, call_id=call_id, duration_ms=elapsed, preview=content[:2000],
role="verify", **extra)
if len(content) > _TOOL_CHARS:
content = content[:_TOOL_CHARS] + "\n…(结果太长,后面的省略了;完整结果在查询快照里,交结果时会按编号列出)"
return content
__all__ = ["LIMITS", "RATIONALE", "SETTLE_GRACE_S", "Default", "VerifyOutcome", "result_schema", "run_verify_agent"]
+32 -9
View File
@@ -35,7 +35,7 @@ from app.data import catalog as data_catalog
from app.data import introspect, table_versions
from app.data.sqlcheck import SqlChecker
from app.data.engine import SnapshotTampered, masked_columns, query_timeout, run_query
from app.data.guard import QueryLimits, SqlRejected, is_write
from app.data.guard import QueryLimits, SqlRejected, check, is_write
from app.data.tabular import UNSHAPED_NOTE
from app.db.models import DataSource
from app.tools.registry import ToolContext, Versions
@@ -57,6 +57,10 @@ LATE_PIN_CODE = "data_version_pinned"
#: 每次查询之后基于数据目录的 SQL 检查最多等这么久。正常一条 SQL 几毫秒;等不到就放弃这一次检查,
#: 查询结果照常交回——检查是附加的,不能拖慢查询
SQL_CHECK_TIMEOUT_S = 2.0
#: force_readonly 的工具拒掉写语句时交回的原因(接在「SQL 被拒绝:」后面)。不说「数据源是只读的」:源本身
#: 可能是可写的,是这次调用只许读;也不提「另建一个可写的数据源」——核查用不着写
FORCED_READONLY = ("这次查询只允许读取数据,这条语句会修改数据,不被允许。"
"只能执行不改数据的 SELECT / WITH / SHOW / DESCRIBE / EXPLAIN")
class _QueryArgs(BaseModel):
@@ -165,12 +169,13 @@ _FROZEN_SCHEMA_KEYS = ("schema", "synced_at", "truncated", "total", "import_mode
CATALOG_SNAPSHOT_KEY = "catalog"
def _query_description(source: Any) -> str:
def _query_description(source: Any, *, readonly: bool | None = None) -> str:
"""readonly 不给就按源本身;force_readonly 的工具传 True(源可写,这次调用也只许读)。"""
tables = introspect.table_names(source)
head = f"在数据源「{source.name}」上执行 SQL 查询。"
if source.description:
head += f"{source.description}。"
head += "只读。" if source.readonly else "可写(写操作需要人工审批)。"
head += "只读。" if (source.readonly if readonly is None else readonly) else "可写(写操作需要人工审批)。"
if tables:
listed = "、".join(tables[:25])
more = f" 等 {len(tables)} 张表" if len(tables) > 25 else ""
@@ -194,13 +199,16 @@ def _query_description(source: Any) -> str:
async def build_datasource_tools(
names: list[str], ctx: ToolContext, session: AsyncSession
names: list[str], ctx: ToolContext, session: AsyncSession, *, force_readonly: bool = False
) -> list[StructuredTool]:
"""按工具名反查数据源并构造工具。build_tools 的第四个来源。
上传源先按 ctx.data_versions 解析成绑定快照的视图(见 _resolve),工具描述、表结构、
查询都按那个版本来。固定的版本找不到时建一个同名的替身(_unavailable_tool),
每次调用都说明原因,不拿当前版本顶上。
force_readonly:查询工具一律按只读守卫检查,可写源上的写语句也拒绝(见 _make_query_tool)。
核查(engine/verify_agent.py)建工具时传它:那一路没有审批关卡,绝不能写。
"""
wanted: dict[str, list[str]] = {}
for name in names:
@@ -230,7 +238,7 @@ async def build_datasource_tools(
entries = await _catalog_of(row)
for prefix in prefixes:
if prefix == QUERY_PREFIX:
tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries))
tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries, force_readonly=force_readonly))
else:
tools.append(_make_schema_tool(source, fixed=fixed, catalog=entries))
return tools
@@ -441,11 +449,15 @@ def _unavailable_tool(row: DataSource, prefix: str, reason: str) -> StructuredTo
def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> StructuredTool:
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None,
force_readonly: bool = False) -> StructuredTool:
"""source 是手工源的 DataSource,或上传源绑定快照的 SourceView(字段同名)。
fixed:上传源的版本是这次运行固定的(找不到时的说法不同)。
catalog:建工具时读到的数据目录,随表结构快照冻结(_store_schema)。
force_readonly:不管源可不可写,一律按只读守卫检查,写语句交回 FORCED_READONLY、不执行。只靠守卫这一道:
连接池按源缓存、不分只读与否(engine.cache_key),可写源借不到连接层只读的连接——别为此另起一个只读池,
缓存键相同,会把这个源平时用的可写连接也换成只读的。
"""
# 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
# 引擎按它告诉界面上限是多少(metadata["timeout_s"])
@@ -463,6 +475,15 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
async def _run(sql: str, limit: int | None = None) -> str:
if gone := _vanished(source, fixed):
return f"{DATA_UNAVAILABLE}{gone}"
if force_readonly and not source.readonly:
# 只读源由 run_query 自己按只读检查(原因照旧说「数据源是只读的」);可写源在这里先按只读判一遍。
# 先过一遍和 run_query 相同的检查:多条语句、结构变更这类问题照原来的说法交回
try:
statement = check(sql, readonly=False, source_name=source.name, dialect=(source.kind or "").lower())
except SqlRejected as e:
return f"SQL 被拒绝:{e}"
if is_write(statement, dialect=(source.kind or "").lower()):
return f"SQL 被拒绝:{FORCED_READONLY}"
limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {}))
try:
result = await run_query(source, sql, limits=limits)
@@ -520,12 +541,14 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
return StructuredTool(
name=f"{QUERY_PREFIX}{source.name}",
description=_query_description(source),
description=_query_description(source, readonly=True if force_readonly else None),
args_schema=_QueryArgs,
coroutine=_run,
func=None,
# 同一个工具,危不危险看这一次的 SQL。审批关卡经 registry.call_is_dangerous 问它
metadata={"dangerous_if": lambda args: is_dangerous_call(source, str(args.get("sql") or "")),
# 同一个工具,危不危险看这一次的 SQL。审批关卡经 registry.call_is_dangerous 问它。
# force_readonly 的工具写不了,没有要人批的调用
metadata={"dangerous_if": (lambda args: False) if force_readonly
else (lambda args: is_dangerous_call(source, str(args.get("sql") or ""))),
"timeout_s": seconds},
)
@@ -0,0 +1,106 @@
"""数据源查询工具的 force_readonly:核查(双盲复算、反方审稿、审批质询)只许读数据。
核查的 agent 是另起的一路,没有审批关卡兜底;它要查的数据源却可能是可写的。所以建工具时传 force_readonly=True:
不管源本身可不可写,一律按只读守卫检查——可写源上的写语句也拒绝,原因交回模型(它据此改写),数据原样不动;
查询照常。工具描述跟着写「只读」,审批判定也不再把写语句当成要人批的危险调用(反正执行不了)。
不传的时候一切照旧:可写源上的写语句仍是要审批的危险调用。
夹具是一个现造的可写 SQLite 文件,表和数字都是虚构的。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
import pytest
from app.data import introspect
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.tools.datasource import build_datasource_tools
from app.tools.registry import ToolContext, Versions
def rows_in(path: str) -> list[tuple]:
db = sqlite3.connect(path)
try:
return db.execute("SELECT id, amount FROM orders ORDER BY id").fetchall()
finally:
db.close()
@pytest.fixture
async def writable(tmp_path):
"""一个可写的手工源:orders 两行,金额 120 和 80。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER);"
"INSERT INTO orders VALUES (1, 120), (2, 80);")
db.commit()
db.close()
name = f"shop_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=False, enabled=True)
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name, str(path)
await engines.invalidate(source_id)
async def tools(name: str, **kwargs) -> dict:
async with SessionLocal() as session:
built = await build_datasource_tools([f"db_query__{name}", f"db_schema__{name}"],
ToolContext(data_versions=Versions.CURRENT), session, **kwargs)
return {t.name: t for t in built}
@pytest.mark.parametrize("sql", [
"DELETE FROM orders",
"UPDATE orders SET amount = 0",
"INSERT INTO orders VALUES (3, 1)",
"WITH k AS (SELECT 1) DELETE FROM orders WHERE id = 1",
])
async def test_writes_are_refused_on_a_writable_source(writable, sql):
name, path = writable
query = (await tools(name, force_readonly=True))[f"db_query__{name}"]
got = await query.ainvoke({"sql": sql})
assert got.startswith("SQL 被拒绝:") and "只允许读取数据" in got, got
assert rows_in(path) == [(1, 120), (2, 80)], "数据原样不动"
async def test_structural_changes_are_refused_with_the_usual_reason(writable):
name, path = writable
query = (await tools(name, force_readonly=True))[f"db_query__{name}"]
got = await query.ainvoke({"sql": "DROP TABLE orders"})
assert got.startswith("SQL 被拒绝:") and "结构变更" in got
assert rows_in(path) == [(1, 120), (2, 80)]
async def test_queries_still_work_and_land_as_snapshots(writable):
name, _ = writable
built = await tools(name, force_readonly=True)
got = json.loads(await built[f"db_query__{name}"].ainvoke({"sql": "SELECT SUM(amount) AS total FROM orders"}))
assert got["rows"] == [[200]] and got["artifact"], "查询照常,结果照常落成查询快照"
assert "orders" in await built[f"db_schema__{name}"].ainvoke({})
async def test_the_tool_says_read_only_and_needs_no_approval(writable):
name, _ = writable
forced = (await tools(name, force_readonly=True))[f"db_query__{name}"]
assert "只读。" in forced.description and "可写" not in forced.description
assert forced.metadata["dangerous_if"]({"sql": "DELETE FROM orders"}) is False, "写语句反正执行不了,不用找人批"
async def test_without_the_flag_nothing_changes(writable):
name, path = writable
normal = (await tools(name))[f"db_query__{name}"]
assert "可写(写操作需要人工审批)" in normal.description
assert normal.metadata["dangerous_if"]({"sql": "DELETE FROM orders"}) is True
got = json.loads(await normal.ainvoke({"sql": "UPDATE orders SET amount = 90 WHERE id = 2"}))
assert got["rows"] == [[1]], "不开 force_readonly 时可写源照常能写(审批由节点把关)"
assert rows_in(path) == [(1, 120), (2, 90)]
+714
View File
@@ -0,0 +1,714 @@
"""SQL 语义(data/sql_semantics.py):两条 SQL 算的是不是一回事,不是的话差在哪。
- normalize_sql:字面量换成占位符后的规范文本 + 字面量。只有字面量不同的两条 SQL 规范文本相同(定时运行每期的日期
不同,不能算模板变了);关键字大小写、空白、注释也不算不同;解析不了的退回逐词规范化,照样认得出只改了字面量
- facets:表、连接(类型 / 关联进来的表 / ON 条件)、WHERE 合取式、时间条件(列 / 运算符 / 取值)、分组、聚合
(函数 / 参数 / 去重)、HAVING、取哪几行(各段的 LIMIT / OFFSET / 排序)。别名换成真实表名(条件里套着的子查询、
HAVING 里的别名也是),CTE、子查询按位置编号、不按起的名字比,条件里的子查询选出来的列名不算;比较的两边排好
顺序;解析不了带 parse_error,不抛
- diff_facets:每处不同一句中文:「A 按支付时间 paid_at 筛时间,B 按下单时间 created_at」「A 排除了
is_deleted = 1 的行,B 没有」「A 对 order_id 去重计数,B 没去重」……写法不同、口径相同的不报,一边多出一个排在
前面的 CTE、编号错开了也不报
夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。
"""
from __future__ import annotations
import json
import pytest
from app.data.sql_semantics import (
Facets,
catalog_labels,
diff_facets,
facets,
normalize_sql,
)
DIALECTS = ("sqlite", "mysql", "postgres")
def texts(a: str, b: str, dialect: str = "sqlite", **kwargs) -> list[str]:
return [d.text for d in diff_facets(facets(a, dialect), facets(b, dialect), **kwargs)]
def aspects(a: str, b: str, dialect: str = "sqlite") -> list[str]:
return [d.aspect for d in diff_facets(facets(a, dialect), facets(b, dialect))]
# --------------------------------------------------------------------------
# normalize_sql
# --------------------------------------------------------------------------
def test_only_literals_differ_means_the_same_template():
week1 = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND store_id = 3 LIMIT 10")
week2 = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-08' AND store_id = 7 LIMIT 10")
assert week1.same_shape(week2) and week1.text == week2.text
assert week1.text == "SELECT SUM(amount) FROM orders WHERE paid_at >= ? AND store_id = ? LIMIT ?"
# 字面量按规范文本里的先后排:语法树里 LIMIT 存在 WHERE 前面,不能因此把 10 排到第一个
assert week1.literals == ("'2026-09-01'", "3", "10")
assert week2.literals == ("'2026-09-08'", "7", "10")
assert week1.parse_error is None
def test_case_whitespace_and_comments_do_not_count():
a = normalize_sql("select Amount\nfrom ORDERS -- 本周\nwhere paid_at >= '2026-09-01'", "sqlite")
b = normalize_sql("SELECT amount FROM orders /* 上周 */ WHERE paid_at >= '2026-08-25'", "sqlite")
assert a.text == b.text and a.literals != b.literals
def test_a_structural_change_is_a_different_template():
base = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01'")
for changed in ("SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01'",
"SELECT SUM(amount) FROM orders WHERE paid_at > '2026-09-01'",
"SELECT AVG(amount) FROM orders WHERE paid_at >= '2026-09-01'",
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND is_deleted = 0"):
assert not normalize_sql(changed).same_shape(base), changed
@pytest.mark.parametrize("dialect,a,b", [
("sqlite", "SELECT strftime('%Y-%m', paid_at) AS m, SUM(amount) AS \"销售额\" FROM \"订单\" "
"WHERE \"支付日期\" BETWEEN '2026-09-01' AND '2026-09-30' GROUP BY 1",
"SELECT strftime('%Y-%m', paid_at) AS m, SUM(amount) AS \"销售额\" FROM \"订单\" "
"WHERE \"支付日期\" BETWEEN '2026-10-01' AND '2026-10-31' GROUP BY 1"),
("mysql", "SELECT SUM(o.`金额`) AS `销售额` FROM `订单` o WHERE o.paid_at >= \"2026-09-01\" LIMIT 5, 10",
"SELECT SUM(o.`金额`) AS `销售额` FROM `订单` o WHERE o.paid_at >= \"2026-09-08\" LIMIT 5, 20"),
("postgres", "SELECT SUM(amount) AS \"销售额\" FROM orders WHERE paid_at::date >= CURRENT_DATE - INTERVAL '7 days'",
"SELECT SUM(amount) AS \"销售额\" FROM orders WHERE paid_at::date >= CURRENT_DATE - INTERVAL '14 days'"),
])
def test_each_dialect_reads_its_own_syntax(dialect, a, b):
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
assert na.parse_error is None and nb.parse_error is None
assert na.text == nb.text and na.literals != nb.literals
# 带引号的中文别名原样留在规范文本里:别名不是字面量
assert "销售额" in na.text
def test_the_data_source_kind_is_accepted_as_the_dialect():
assert normalize_sql("SELECT 1 FROM t WHERE a = 'x'", "postgresql").text == \
normalize_sql("SELECT 1 FROM t WHERE a = 'y'", "postgres").text
def test_unparsable_sql_still_tells_a_literal_only_change():
a = normalize_sql("SELECT amount FROM orders WHERE paid_at >= '2026-09-01' AND AND", "sqlite")
b = normalize_sql("select amount from ORDERS where paid_at >= '2026-09-08' and and", "sqlite")
assert a.parse_error and "无法解析" in a.parse_error
assert a.text == b.text and a.literals == ("'2026-09-01'",) and b.literals == ("'2026-09-08'",)
@pytest.mark.parametrize("sql", ["", " ", None, "SELECT 1; SELECT 2"])
def test_nothing_to_normalize_never_raises(sql):
got = normalize_sql(sql, "sqlite")
assert got.parse_error
# --------------------------------------------------------------------------
# facets:各个方面
# --------------------------------------------------------------------------
def test_tables_are_the_real_tables_without_cte_names():
f = facets("WITH paid AS (SELECT * FROM orders WHERE status = 'paid') "
"SELECT s.name, SUM(p.amount) FROM paid p JOIN stores s ON s.id = p.store_id GROUP BY s.name")
assert [t.text for t in f.tables] == ["orders", "stores"] or [t.text for t in f.tables] == ["stores", "orders"]
assert "paid" not in {t.key for t in f.tables}
def test_joins_carry_kind_both_sides_and_a_normalized_condition():
f = facets("SELECT o.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id AND r.state = 'done'")
[join] = f.joins
assert join.kind == "left" and join.table.text == "refunds" and join.other == ("orders",)
# 别名换成真实表名,两边排好顺序,几个条件按 key 排序后用 AND 连起来
assert join.on.text == "orders.id = refunds.order_id AND refunds.state = 'done'"
same = facets("SELECT orders.id FROM orders LEFT OUTER JOIN refunds ON refunds.state = 'done' "
"AND orders.id = refunds.order_id")
assert same.joins[0].on.key == join.on.key and same.joins[0].kind == "left"
def test_a_comma_join_is_the_same_as_an_inner_join():
comma = facets("SELECT a.x FROM orders a, stores b WHERE a.store_id = b.id AND b.city = '虚构市'")
inner = facets("SELECT orders.x FROM orders JOIN stores ON stores.id = orders.store_id WHERE stores.city = '虚构市'")
assert comma.joins == inner.joins and comma.joins[0].kind == "inner"
assert comma.joins[0].table.text == "stores" and comma.joins[0].other == ("orders",)
cross = facets("SELECT 1 FROM orders o CROSS JOIN stores s")
assert (cross.joins[0].kind, cross.joins[0].on, cross.joins[0].other) == ("cross", None, ("orders",))
assert [p.key for p in comma.where] == [p.key for p in inner.where] == ["stores.city = '虚构市'"]
def test_where_conjuncts_are_normalized():
f = facets("SELECT 1 FROM orders o WHERE 0 = o.is_deleted AND o.status IN ('shipped', 'paid') "
"AND (o.channel = 'app' OR o.channel = 'web')")
assert [p.text for p in f.where] == ["is_deleted = 0", "status IN ('paid', 'shipped')",
"channel = 'app' OR channel = 'web'"]
assert f.where[0].columns == (("orders", "is_deleted"),)
@pytest.mark.parametrize("dialect", DIALECTS)
def test_exclusions_are_read_in_every_dialect(dialect):
f = facets("SELECT 1 FROM orders WHERE is_deleted <> 1 AND store_id NOT IN (8, 9) AND refund_id IS NOT NULL "
"AND note NOT LIKE '%测试%'", dialect)
assert [p.excludes for p in f.where] == ["is_deleted = 1", "store_id IN (8, 9)", "refund_id 为空",
"note LIKE '%测试%'"]
def test_time_predicates_have_column_operator_and_value():
f = facets("SELECT SUM(amount) FROM orders o WHERE o.paid_at >= '2026-09-01' AND '2026-10-01' > o.paid_at "
"AND o.store_id = 3")
assert [(t.column.text, t.op, t.value) for t in f.time] == [("paid_at", ">=", "'2026-09-01'"),
("paid_at", "<", "'2026-10-01'")]
between = facets("SELECT 1 FROM \"订单\" WHERE \"支付日期\" BETWEEN '2026-09-01' AND '2026-09-30'")
[t] = between.time
assert (t.column.text, t.op, t.value) == ('"支付日期"', "between", "'2026-09-01' AND '2026-09-30'")
# 列套着日期函数也认;按月份字符串筛也认
month = facets("SELECT 1 FROM orders WHERE strftime('%Y-%m', created_at) = '2026-09'")
assert [(t.column.text, t.op) for t in month.time] == [("created_at", "=")]
assert facets("SELECT 1 FROM orders WHERE store_id = 3 AND amount > 100").time == ()
def test_group_by_position_and_alias_mean_the_expression():
explicit = facets("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region")
by_position = facets("SELECT region AS r, SUM(amount) FROM orders GROUP BY 1")
by_alias = facets("SELECT region AS r, SUM(amount) FROM orders GROUP BY r")
assert [p.key for p in explicit.group_by] == [p.key for p in by_position.group_by] == \
[p.key for p in by_alias.group_by] == ["orders.region"]
def test_aggregates_have_function_argument_and_distinct():
f = facets("SELECT COUNT(*), COUNT(1), COUNT(DISTINCT o.order_id), SUM(o.amount), "
"SUM(o.amount) OVER (PARTITION BY o.region) FROM orders o")
assert [(g.func, g.arg.text, g.distinct) for g in f.aggregates] == [
("count", "*", False), ("count", "order_id", True), ("sum", "amount", False)], \
"COUNT(1) 和 COUNT(*) 是同一个;窗口函数不算聚合"
def test_having_and_limit():
f = facets("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING 100 < SUM(amount) LIMIT 10")
assert [p.text for p in f.having] == ["SUM(amount) > 100"] and f.limit == "10"
assert facets("SELECT a FROM orders LIMIT 5, 20", "mysql").limit == "20"
def test_having_reads_select_aliases():
"""HAVING 里引用 SELECT 的别名(MySQL、SQLite 的写法)换成别名指的表达式,和 GROUP BY 别名一样。"""
by_alias = facets("SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY r HAVING s > 100")
explicit = facets("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region HAVING SUM(o.amount) > 100")
assert [p.key for p in by_alias.having] == [p.key for p in explicit.having] == ["SUM(orders.amount) > 100"]
def test_limits_are_collected_per_segment_with_offset_and_order():
"""LIMIT 逐段收集(CTE、FROM 里的子查询各算一段),连同 OFFSET 和这一段的排序:只取前几行时,按什么排、
跳过几行决定了取到的是哪几行。OFFSET 0 等于没写。"""
f = facets("WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 10) "
"SELECT SUM(amount) FROM top LIMIT 1")
assert [(x.scope, x.count, x.offset) for x in f.limits] == [(None, "1", None), ("top", "10", None)]
[(key, desc)] = f.limits[1].order
assert key.text == "amount" and desc is True
mysql = facets("SELECT a FROM orders ORDER BY 1 LIMIT 5, 20", "mysql")
assert (mysql.limits[0].count, mysql.limits[0].offset) == ("20", "5")
assert [(k.text, d) for k, d in mysql.limits[0].order] == [("a", False)], "ORDER BY 1 换成 SELECT 里的第一项"
assert facets("SELECT a FROM orders LIMIT 10 OFFSET 0").limits[0].offset is None
@pytest.mark.parametrize("dialect,sql", [
("sqlite", 'SELECT SUM("金额") AS "销售额" FROM "订单" WHERE "是否作废" = 0'),
("mysql", "SELECT SUM(`金额`) AS `销售额` FROM `订单` WHERE `是否作废` = 0"),
("mysql", 'SELECT SUM(`金额`) AS "销售额" FROM `订单` WHERE `是否作废` = 0'),
("postgres", 'SELECT SUM("金额") AS "销售额" FROM "订单" WHERE "是否作废" = 0'),
])
def test_quoted_chinese_names_and_aliases(dialect, sql):
f = facets(sql, dialect)
assert f.parse_error is None
assert [t.key for t in f.tables] == ["订单"]
assert [(g.func, g.arg.key) for g in f.aggregates] == [("sum", '"订单"."金额"')] or \
[(g.func, g.arg.columns) for g in f.aggregates] == [("sum", (("订单", "金额"),))]
assert f.where[0].columns == (("订单", "是否作废"),)
def test_a_cte_that_passes_columns_through_maps_them_to_the_table():
wrapped = facets("WITH v AS (SELECT * FROM orders WHERE is_deleted = 0) SELECT SUM(v.amount) FROM v")
direct = facets("SELECT SUM(amount) FROM orders WHERE is_deleted = 0")
assert wrapped.aggregates == direct.aggregates and wrapped.where == direct.where
def test_a_correlated_subquery_reads_the_outer_alias():
"""子查询里引用外层的别名(o.id)到外层去找:写成真实表名,两张表的列互相比的条件也写出表名。"""
f = facets("SELECT o.id FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)")
assert "orders.id = refunds.order_id" in [p.text for p in f.where]
plain = facets("SELECT orders.id FROM orders WHERE NOT EXISTS "
"(SELECT 1 FROM refunds WHERE orders.id = refunds.order_id)")
assert plain.where[1].key == f.where[1].key
# 外层那个条件(NOT EXISTS 整句)也一样:子查询按它自己那一段的来源写,别名换成真实表名
assert plain.where[0].key == f.where[0].key
assert f.where[0].excludes == "EXISTS(SELECT 1 FROM refunds WHERE orders.id = refunds.order_id)"
def test_a_subquery_in_a_condition_is_written_with_its_own_tables():
f = facets("SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)")
assert f.where[0].text == "user_id IN (SELECT id FROM users WHERE vip = 1)"
assert f.where[0].key == "orders.user_id IN (SELECT users.id FROM users WHERE users.vip = 1)"
def test_the_schema_tells_which_table_an_unqualified_column_belongs_to():
sql = "SELECT SUM(amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE city = '虚构市'"
assert facets(sql).where[0].columns == ((None, "city"),), "不给表结构:几张表都可能有这一列,照写列名"
schema = {"tables": {"orders": {"columns": [{"name": "id"}, {"name": "amount"}, {"name": "store_id"}]},
"stores": {"columns": [{"name": "id"}, {"name": "city"}]}}}
assert facets(sql, schema=schema).where[0].columns == (("stores", "city"),)
@pytest.mark.parametrize("sql,words", [
("SELECT FROM WHERE", "无法解析"),
("SELECT 1; SELECT 2", "2 条"),
("UPDATE orders SET amount = 0", "不是查询"),
("", "为空"),
])
def test_unparsable_sql_carries_parse_error_and_never_raises(sql, words):
f = facets(sql, "sqlite")
assert f.parse_error and words in f.parse_error
assert (f.tables, f.joins, f.where, f.aggregates) == ((), (), (), ())
def test_facets_are_json_ready():
f = facets("SELECT COUNT(DISTINCT order_id) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY region")
data = json.loads(json.dumps(f.as_dict(), ensure_ascii=False))
assert data["aggregates"][0]["distinct"] is True and data["time"][0]["op"] == ">="
# --------------------------------------------------------------------------
# diff_facets:每个方面一对
# --------------------------------------------------------------------------
BASE = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
def test_time_column_differs_with_catalog_names():
a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"
[diff] = diff_facets(facets(a), facets(b), names={"orders.paid_at": "支付时间", "created_at": "下单时间"})
assert diff.aspect == "time"
assert diff.text == "A 按支付时间 paid_at 筛时间,B 按下单时间 created_at"
assert (diff.a, diff.b) == ("paid_at", "created_at")
assert diff.detail == {"a": [{"table": "orders", "column": "paid_at"}],
"b": [{"table": "orders", "column": "created_at"}]}
assert texts(a, b) == ["A 按 paid_at 筛时间,B 按 created_at"], "没有中文名就只写列名"
def test_time_filter_on_one_side_only():
assert texts(BASE, "SELECT SUM(amount) FROM orders") == ["A 按 paid_at 筛时间,B 没有按时间筛选"]
assert texts("SELECT SUM(amount) FROM orders", BASE) == ["A 没有按时间筛选,B 按 paid_at 筛时间"]
def test_same_time_column_different_range():
other = "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'"
[diff] = diff_facets(facets(BASE), facets(other))
assert diff.aspect == "time_range"
assert diff.text == ("A 的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01',"
"B 是 paid_at BETWEEN '2026-09-01' AND '2026-09-30'")
def test_only_literals_differ_is_still_a_different_range():
"""规范文本相同(同一个模板),但比口径时日期不同就是不同的范围:两个函数回答的是两个问题。"""
later = BASE.replace("2026-09-01", "2026-09-08").replace("2026-10-01", "2026-10-08")
assert normalize_sql(BASE).same_shape(normalize_sql(later))
assert aspects(BASE, later) == ["time_range"]
def test_an_exclusion_on_one_side():
assert texts("SELECT SUM(amount) FROM orders WHERE is_deleted <> 1", "SELECT SUM(amount) FROM orders") == \
["A 排除了 is_deleted = 1 的行,B 没有"]
assert texts("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE refund_id IS NOT NULL") == \
["B 排除了 refund_id 为空的行,A 没有"]
def test_a_kept_condition_on_one_side():
assert texts("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE status = 'paid'") == \
["B 只保留 status = 'paid' 的行,A 没有这个条件"]
def test_two_conditions_on_the_same_column_are_paired():
[diff] = diff_facets(facets("SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
facets("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')"))
assert (diff.aspect, diff.text) == ("filter", "A 按 status = 'paid' 筛选,B 按 status IN ('paid', 'shipped') 筛选")
assert (diff.a, diff.b) == ("status = 'paid'", "status IN ('paid', 'shipped')")
def test_distinct_count_versus_plain_count():
assert texts("SELECT COUNT(DISTINCT order_id) FROM orders", "SELECT COUNT(order_id) FROM orders") == \
["A 对 order_id 去重计数,B 没去重"]
assert texts("SELECT COUNT(order_id) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders") == \
["A 对 order_id 计数时没去重,B 去重计数"]
@pytest.mark.parametrize("a,b,text", [
("SELECT SUM(amount) FROM orders", "SELECT AVG(amount) FROM orders", "A 对 amount 求和,B 求平均"),
("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders", "A 对 paid_amount 求和,B 对 amount 求和"),
("SELECT COUNT(*) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders",
"A 数的是行数,B 对 order_id 去重计数"),
])
def test_other_aggregate_differences(a, b, text):
assert texts(a, b) == [text]
def test_join_differences():
left = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"
inner = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
other_key = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.user_id = o.user_id"
plain = "SELECT SUM(amount) FROM orders"
assert texts(left, inner) == ["A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN"]
assert texts(left, other_key) == ["A 关联 refunds 表的条件是 orders.id = refunds.order_id,"
"B 是 orders.user_id = refunds.user_id"]
assert texts(left, plain) == ["A 关联了 refunds 表,B 没有"]
def test_a_different_source_table_is_said_once():
"""表换了一张:说一次「查的是哪张表」就够了,同一个聚合、同一个条件不再因为表名不同各报一遍。"""
a = "SELECT SUM(amount) FROM orders WHERE is_deleted = 0 AND paid_at >= '2026-09-01'"
b = "SELECT SUM(amount) FROM orders_archive WHERE is_deleted = 0 AND paid_at >= '2026-09-01'"
[diff] = diff_facets(facets(a), facets(b))
assert (diff.aspect, diff.text) == ("tables", "A 查的是 orders 表,B 查的是 orders_archive 表")
assert aspects(a, b.replace("is_deleted = 0", "is_deleted = 1")) == ["filter", "tables"], "换表之外的不同照报"
def test_two_columns_that_read_the_same_are_told_apart():
"""各自只写列名时两边读起来一样,其实是两张表的列:改写成带表名的写法,不说「A 对 amount 求和,B 对 amount 求和」。"""
a = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
b = "SELECT SUM(r.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
assert texts(a, b) == ["A 对 orders.amount 求和,B 对 refunds.amount 求和"]
def test_group_by_difference():
assert texts("SELECT region, SUM(amount) FROM orders GROUP BY region",
"SELECT region, city, SUM(amount) FROM orders GROUP BY region, city") == \
["A 按 region 分组,B 按 region、city 分组"]
assert texts("SELECT SUM(amount) FROM orders", "SELECT region, SUM(amount) FROM orders GROUP BY region") == \
["A 没有分组,B 按 region 分组"]
def test_having_difference():
assert texts("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING SUM(amount) > 100",
"SELECT region, SUM(amount) FROM orders GROUP BY region") == \
["A 分组后只保留 SUM(amount) > 100 的组,B 没有这个条件"]
def test_limit_difference():
assert texts("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders") == ["A 只取前 10 行,B 不限行数"]
assert texts("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders LIMIT 100") == \
["A 只取前 10 行,B 只取前 100 行"]
def test_an_unparsable_side_is_reported_once_without_item_by_item_comparison():
[diff] = diff_facets(facets("SELECT FROM WHERE"), facets(BASE))
assert diff.aspect == "parse" and diff.text.startswith("A:SQL 无法解析") and "没有逐项比较" in diff.text
assert diff.b is None
@pytest.mark.parametrize("a,b", [
# 别名、比较两边的顺序、大小写
("SELECT SUM(o.amount) FROM orders o WHERE o.paid_at >= '2026-09-01' AND o.is_deleted = 0",
"select sum(amount) from orders where 0 = is_deleted and '2026-09-01' <= paid_at"),
# 有效记录条件写在 CTE 里、还是写在外层
("WITH v AS (SELECT * FROM orders WHERE is_deleted = 0) SELECT COUNT(DISTINCT v.order_id) FROM v",
"SELECT COUNT(DISTINCT order_id) FROM orders WHERE is_deleted = 0"),
# 逗号连接和 INNER JOIN
("SELECT SUM(o.amount) FROM orders o, stores s WHERE o.store_id = s.id AND s.city = '虚构市'",
"SELECT SUM(orders.amount) FROM orders INNER JOIN stores ON stores.id = orders.store_id "
"WHERE stores.city = '虚构市'"),
# 条件里套着的子查询用了别名:NOT EXISTS、IN (SELECT …)
("SELECT o.id FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)",
"SELECT orders.id FROM orders WHERE NOT EXISTS (SELECT 1 FROM refunds WHERE refunds.order_id = orders.id)"),
("SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)",
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1)"),
# HAVING 里用 SELECT 的别名
("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region HAVING SUM(o.amount) > 100",
"SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY r HAVING s > 100"),
# OFFSET 0 等于没写;取前几行时 ORDER BY 1 和写出列名是同一个排序
("SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10 OFFSET 0",
"SELECT o.amount FROM orders o ORDER BY o.amount DESC LIMIT 10"),
])
def test_the_same_meaning_written_differently_is_no_difference(a, b):
assert texts(a, b) == []
def test_a_real_difference_inside_a_subquery_is_still_reported():
a = "SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)"
b = "SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.referrer_id FROM users u WHERE u.vip = 1)"
assert texts(a, b) == ["A 按 user_id IN (SELECT id FROM users WHERE vip = 1) 筛选,"
"B 按 user_id IN (SELECT referrer_id FROM users WHERE vip = 1) 筛选"]
@pytest.mark.parametrize("dialect,a,b", [
# 条件里的子查询给选出来的列起了名字:IN、标量子查询只看取值,列名不影响口径
("sqlite",
"SELECT SUM(o.amount) FROM orders o WHERE o.amount > "
"(SELECT AVG(o2.amount) AS avg_amount FROM orders o2 WHERE o2.is_deleted = 0)",
"SELECT SUM(amount) FROM orders WHERE amount > (SELECT AVG(amount) FROM orders WHERE is_deleted = 0)"),
("sqlite",
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id AS uid FROM users u WHERE u.vip = 1)",
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1)"),
# 条件里的子查询从 FROM 里的子查询取数,两边给它起的别名不同
("sqlite",
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT s.user_id FROM "
"(SELECT user_id, SUM(amount) AS total FROM payments GROUP BY user_id) s WHERE s.total > 100)",
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT agg.user_id FROM "
"(SELECT user_id, SUM(amount) AS total FROM payments GROUP BY user_id) agg WHERE agg.total > 100)"),
# CTE 起的名字不同,外层按 CTE 算出来的列筛选
("sqlite",
"WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
"SELECT COUNT(*) FROM user_totals WHERE total > 100",
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE t.total > 100"),
# 写成 CTE 还是写成 FROM 里的子查询
("postgres",
"WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM user_totals WHERE total > 100",
"SELECT COUNT(*) FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS agg "
"WHERE agg.total > 100"),
# LATERAL 子查询的别名不同:别名挂在 LATERAL 上,不在子查询上
("postgres",
"SELECT SUM(o.amount) FROM orders o, LATERAL (SELECT COUNT(*) AS n FROM refunds r WHERE r.order_id = o.id) l "
"WHERE l.n > 0",
"SELECT SUM(o.amount) FROM orders o CROSS JOIN LATERAL (SELECT COUNT(*) AS n FROM refunds r "
"WHERE r.order_id = o.id) AS x WHERE x.n > 0"),
# 关联进来的是子查询,两边的别名不同(一边是带反引号的中文别名)
("mysql",
"SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM refunds GROUP BY order_id) r "
"ON r.order_id = o.id WHERE r.n > 1",
"SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM refunds GROUP BY order_id) "
"`退款` ON o.id = `退款`.order_id WHERE `退款`.n > 1"),
# 条件里的子查询用到 CTE,两边给 CTE 起的名字不同
("sqlite",
"WITH vip AS (SELECT id FROM users WHERE grade >= 3) "
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM vip)",
"WITH good_users AS (SELECT id FROM users WHERE grade >= 3) "
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT g.id FROM good_users g)"),
# CTE 定义在条件里的子查询里:定义连同子查询整个写进外层的条件,定义上的名字也不算
("postgres",
"SELECT SUM(amount) FROM orders WHERE user_id IN "
"(WITH t AS (SELECT id FROM users WHERE grade >= 3) SELECT id FROM t)",
"SELECT SUM(amount) FROM orders WHERE user_id IN "
"(WITH g AS (SELECT id FROM users WHERE grade >= 3) SELECT g.id FROM g)"),
# 两个 CTE 定义的先后、起的名字都不同,按用到的先后它们是同一对
("sqlite",
"WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id), "
"refunded AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) "
"SELECT COUNT(*) FROM paid p JOIN refunded r ON r.user_id = p.user_id WHERE p.total > r.total",
"WITH r AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id), "
"p AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id) "
"SELECT COUNT(*) FROM p JOIN r ON p.user_id = r.user_id WHERE p.total > r.total"),
# CTE 定义的先后和用到的先后不一致,另一边又把它们写成了 FROM 里的子查询:按用到的先后编号才对得上
("sqlite",
"WITH c AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
"d AS (SELECT user_id, COUNT(*) AS n FROM refunds GROUP BY user_id), "
"a AS (SELECT * FROM c WHERE total > 100), b AS (SELECT * FROM d WHERE n > 1) "
"SELECT COUNT(*) FROM b JOIN a ON a.user_id = b.user_id",
"SELECT COUNT(*) FROM (SELECT * FROM (SELECT user_id, COUNT(*) AS n FROM refunds GROUP BY user_id) x "
"WHERE n > 1) b JOIN (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) y "
"WHERE total > 100) a ON a.user_id = b.user_id"),
# 同上,两个 CTE 还算出了同名的列、外层拿它们互相比:不写编号的写法分不出是哪个的 total,编号得对得上
("sqlite",
"WITH c AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
"d AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id), "
"a AS (SELECT * FROM c), b AS (SELECT * FROM d) "
"SELECT COUNT(*) FROM b JOIN a ON a.user_id = b.user_id WHERE a.total > b.total",
"SELECT COUNT(*) FROM (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) x) b "
"JOIN (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) y) a "
"ON a.user_id = b.user_id WHERE a.total > b.total"),
# 先用一个 CTE 筛一遍再汇总,还是直接在汇总的 CTE 里筛:里层多一段,不挪动外层那一段的编号
("sqlite",
"WITH base AS (SELECT * FROM orders WHERE is_deleted = 0), "
"per_user AS (SELECT user_id, SUM(amount) AS total FROM base GROUP BY user_id) "
"SELECT COUNT(*) FROM per_user WHERE total > 100",
"WITH totals AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
"SELECT COUNT(*) FROM totals WHERE total > 100"),
])
def test_names_given_to_derived_tables_are_no_difference(dialect, a, b):
"""CTE 名、子查询的别名、条件里的子查询选出来的列名都是各自起的(双盲复算的盲算更是):只因起的名字不同,
不算换了口径。"""
assert texts(a, b, dialect) == []
def test_derived_tables_are_compared_by_position_and_shown_by_name():
"""派生来源(CTE、FROM 里的子查询)比较用的写法按它在语句里的位置编号,不写名字;给人看的写法照原样写名字。"""
a = facets("WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM user_totals WHERE total > 100")
b = facets("SELECT COUNT(*) FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) agg "
"WHERE agg.total > 100")
assert a.where[0].key == b.where[0].key
assert "user_totals" not in a.where[0].key and "agg" not in b.where[0].key
assert (a.where[0].text, a.where[0].full) == ("total > 100", "user_totals.total > 100")
assert (b.where[0].text, b.where[0].full) == ("total > 100", "agg.total > 100")
joined = facets("SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM refunds "
"GROUP BY order_id) r ON r.order_id = o.id")
[join] = joined.joins
assert join.table.text == "r" and join.on.text == "orders.id = refunds.order_id"
SHIFTED_A = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE total > 100 AND user_id IN (SELECT id FROM users WHERE vip = 1)")
SHIFTED_B = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM vip JOIN t ON t.user_id = vip.id WHERE t.total > 100")
def test_a_shifted_derived_number_does_not_split_one_condition_in_two():
"""编号只看位置:一边多出一个排在前面的 CTE(筛 VIP 用户),后面那个 CTE 的编号就错开了。按编号比不上的再按
不写编号的写法一对一地配,同一个条件不会被说成两边各有一个;真正的不同(VIP 用 IN 筛还是关联进来)照报。"""
found = diff_facets(facets(SHIFTED_A), facets(SHIFTED_B), labels=("原流程", "盲算"))
filters = [d.text for d in found if d.aspect == "filter"]
assert filters == ["原流程只保留 user_id IN (SELECT id FROM users WHERE vip = 1) 的行,盲算没有这个条件"]
# 错开之后的条件真不同,也照样配成一对说
other = SHIFTED_B.replace("t.total > 100", "t.total > 200")
filters = [d.text for d in diff_facets(facets(SHIFTED_A), facets(other), labels=("原流程", "盲算"))
if d.aspect == "filter"]
assert filters[0] == "原流程按 total > 100 筛选,盲算按 total > 200 筛选" and len(filters) == 2
@pytest.mark.parametrize("aspect,a,b", [
# 时间条件、分组键、聚合、HAVING、取前几行时的排序都是按 CTE 算出来的列
("time",
"WITH t AS (SELECT user_id, DATE(paid_at) AS day, amount FROM orders) "
"SELECT SUM(amount) FROM t WHERE day >= '2026-09-01' AND user_id IN (SELECT id FROM users WHERE vip = 1)",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, DATE(paid_at) AS day, amount FROM orders) "
"SELECT SUM(t.amount) FROM vip JOIN t ON t.user_id = vip.id WHERE t.day >= '2026-09-01'"),
("group_by",
"WITH t AS (SELECT user_id, SUM(amount) AS total, COUNT(*) AS n FROM orders GROUP BY user_id) "
"SELECT n, COUNT(*) FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) GROUP BY n",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total, COUNT(*) AS n FROM orders GROUP BY user_id) "
"SELECT t.n, COUNT(*) FROM vip JOIN t ON t.user_id = vip.id GROUP BY t.n"),
("aggregate",
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT AVG(total) FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1)",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT AVG(t.total) FROM vip JOIN t ON t.user_id = vip.id"),
("having",
"WITH t AS (SELECT store_id, user_id, SUM(amount) AS total FROM orders GROUP BY store_id, user_id) "
"SELECT store_id FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) GROUP BY store_id "
"HAVING MAX(total) > 100",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT store_id, user_id, SUM(amount) AS total FROM orders GROUP BY store_id, user_id) "
"SELECT t.store_id FROM vip JOIN t ON t.user_id = vip.id GROUP BY t.store_id HAVING MAX(t.total) > 100"),
("limit",
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT user_id FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) ORDER BY total DESC LIMIT 10",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT t.user_id FROM vip JOIN t ON t.user_id = vip.id ORDER BY t.total DESC LIMIT 10"),
])
def test_a_shifted_derived_number_is_no_difference_in_any_aspect(aspect, a, b):
"""编号错开之后,按 CTE 算出来的列写的时间条件、分组键、聚合、HAVING、排序也不算不同:只剩 VIP 那处真正的
不同。"""
assert not [x for x in aspects(a, b) if x.startswith(aspect)]
def test_a_shifted_number_inside_a_condition_subquery_is_no_difference():
"""条件里的子查询用到的 CTE 编号错开了:子查询整个写进外层条件的那一份,按不写编号的写法也写「@」,照样配得上。"""
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM users u WHERE u.vip = 1 AND u.id IN (SELECT user_id FROM t WHERE total > 100)")
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM vip WHERE vip.id IN (SELECT user_id FROM t WHERE total > 100)")
assert texts(a, b) == []
def test_a_shifted_derived_join_is_paired_with_its_counterpart():
"""关联进来的 CTE 编号错开了(盲算多关联了一个排在前面的 CTE):先把完全一样的关联配掉,再按关联的是哪张表配,
不会把原流程的 t 和盲算的 vip 当成同一处关联、说成「关联条件不同」。"""
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM users u JOIN t ON t.user_id = u.id WHERE t.total > 100")
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM users u JOIN vip ON vip.id = u.id JOIN t ON t.user_id = u.id WHERE t.total > 100")
assert texts(a, b, labels=("原流程", "盲算")) == ["盲算只保留 vip = 1 的行,原流程没有这个条件",
"盲算关联了 vip 表,原流程没有"]
def test_swapped_join_order_of_two_derived_tables_is_no_difference():
"""两个 CTE 关联的先后对调,编号也跟着对调:拿它们的列互相比的条件,两边先按不写编号的写法排好两边的先后,
才配得上(只按编号排,同一个比较会排成两个方向)。"""
ctes = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
"u AS (SELECT user_id, COUNT(*) AS cnt FROM refunds GROUP BY user_id) ")
assert texts(ctes + "SELECT COUNT(*) FROM t JOIN u ON u.user_id = t.user_id WHERE t.total > u.cnt",
ctes + "SELECT COUNT(*) FROM u JOIN t ON t.user_id = u.user_id WHERE t.total > u.cnt") == []
def test_a_reversed_comparison_between_two_derived_tables_is_still_reported():
"""两个 CTE 算出了同名的列:编号对得上时照样分得清是哪个 CTE 的,比较的方向反了要报出来(不写编号的写法只在
按编号比不上时才拿来配,不会把它吞掉)。"""
a = ("WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
"refunded AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) "
"SELECT COUNT(*) FROM paid p JOIN refunded r ON r.user_id = p.user_id WHERE p.total > r.total")
b = a.replace("WHERE p.total > r.total", "WHERE r.total > p.total")
assert texts(a, b) == ["A 只保留 paid.total > refunded.total 的行,B 没有这个条件",
"B 只保留 paid.total < refunded.total 的行,A 没有这个条件"]
def test_a_real_difference_next_to_renamed_derived_tables_is_reported_once():
"""派生来源起的名字不同、里面又真有不同时,只报真正不同的那一处:CTE 里少一个条件,外层按 CTE 算出来的列
筛选的条件不跟着报(不然两边各报一句「只保留 total > 100 的行」,自相矛盾);外层的条件本身不同,两边配成
一对说。"""
a = ("WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 "
"GROUP BY user_id) SELECT COUNT(*) FROM user_totals WHERE total > 100")
inner = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE total > 100")
assert texts(a, inner, labels=("原流程", "盲算")) == ["原流程只保留 is_deleted = 0 的行,盲算没有这个条件"]
outer = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE total > 200")
assert texts(a, outer, labels=("原流程", "盲算")) == ["原流程按 total > 100 筛选,盲算按 total > 200 筛选"]
@pytest.mark.parametrize("a,b,text", [
("WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 10) SELECT SUM(amount) FROM top",
"WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 100) SELECT SUM(amount) FROM top",
"A 在 top 里只取前 10 行,B 只取前 100 行"),
("SELECT amount FROM orders LIMIT 10 OFFSET 0", "SELECT amount FROM orders LIMIT 10 OFFSET 10",
"A 不跳过行,B 跳过前 10 行"),
("SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10", "SELECT amount FROM orders ORDER BY 1 ASC LIMIT 10",
"A 取行前按 amount 降序排,B 按 amount 升序排"),
("SELECT amount FROM orders ORDER BY amount DESC LIMIT 10", "SELECT amount FROM orders LIMIT 10",
"A 取行前按 amount 降序排,B 没有排序"),
])
def test_which_rows_are_taken_is_compared(a, b, text):
"""取哪几行:每一段的行数、跳过的行数,以及取前几行时的排序。排序只在有 LIMIT / OFFSET 的段里比:
没有取前几行,排序不影响算出来的数。"""
assert texts(a, b) == [text]
assert texts("SELECT SUM(amount) FROM orders ORDER BY 1 DESC", "SELECT SUM(amount) FROM orders") == []
@pytest.mark.parametrize("dialect", DIALECTS)
def test_each_dialect_gives_the_same_three_findings(dialect):
q = {"sqlite": '"', "postgres": '"', "mysql": "`"}[dialect]
a = (f"SELECT COUNT(DISTINCT o.order_id) AS {q}订单数{q} FROM orders o "
"WHERE o.paid_at >= '2026-09-01' AND o.is_deleted <> 1")
b = f"SELECT COUNT(o.order_id) AS {q}订单数{q} FROM orders o WHERE o.created_at >= '2026-09-01'"
assert texts(a, b, dialect) == ["A 按 paid_at 筛时间,B 按 created_at", "A 排除了 is_deleted = 1 的行,B 没有",
"A 对 order_id 去重计数,B 没去重"]
def test_side_labels_in_chinese_have_no_stray_spaces():
a = "SELECT COUNT(DISTINCT order_id) FROM orders WHERE paid_at >= '2026-09-01'"
b = "SELECT COUNT(order_id) FROM orders WHERE created_at >= '2026-09-01'"
assert texts(a, b, labels=("原流程", "盲算"), names={"paid_at": "支付时间"}) == [
"原流程按支付时间 paid_at 筛时间,盲算按 created_at", "原流程对 order_id 去重计数,盲算没去重"]
def test_catalog_labels_come_from_visible_catalog_items():
notes = {
"orders": {"columns": {"paid_at": {"label": {"value": "支付时间", "status": "confirmed"}},
"status": {"label": {"value": "状态", "status": "proposed"}},
"amount": {"label": {"value": "错的名字", "status": "rejected"}}}},
"refunds": {"columns": {"status": {"label": {"value": "退款状态", "status": "confirmed"}}}},
}
labels = catalog_labels(notes)
assert labels["orders.paid_at"] == labels["paid_at"] == "支付时间"
assert labels["orders.status"] == "状态" and labels["refunds.status"] == "退款状态"
assert "status" not in labels, "两张表叫法不同的列不按光列名记"
assert "orders.amount" not in labels and "amount" not in labels, "被驳回的不算"
def test_a_facets_object_compares_with_itself_cleanly():
f = facets(BASE)
assert isinstance(f, Facets) and diff_facets(f, f) == []
+794
View File
@@ -0,0 +1,794 @@
"""核查用的只读 agent(engine/verify_agent.py):查库、交回有出处的结果。双盲复算、反方审稿、审批质询共用。
- 工具只有给定数据源的 db_schema / db_query,建的时候 force_readonly:可写源上的写语句也被拒绝,数据不动
- 每次成功的查询是一份查询快照,按这个 agent 自己的顺序编 Q1、Q2…;节点里调用时发 tool.end(带 query_artifact、
role: verify),但不往证据台账里写 query:核查的查询进了报告的写作目录,写作者就会把它们当成可以引用的证据
- 最后按调用方的结构交结果:数值字段一律是格子引用 {alias, row, column},由代码从快照取值,模型自己写的数字
不采信(type 列表、anyOf、$ref、allOf 里的数也是;认不出的结构下写的数一律丢掉);没交的字段和交了 null 分开记;
理由里的数字要写成 [[v:Qn.rX.列]],用本 agent 自己的台账组装成文档,裸数字会被标出
- 查询次数、时长、金额(单次和每日)任何一项到顶就停止查询、按已有结果收尾;收尾的钱在查询之前就按最贵的样子
留好;每日上限缺省按设置;模型不在价格目录里时金额上限无效,照裁判的说法记一条说明
- 永远不抛:库写不进去、模型不支持结构化输出,都落在返回值里
- 上传表格按调用方给的数据版本查(运行固定的那一版),不拿当前版本顶上
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,表名、数字都是虚构的。
"""
from __future__ import annotations
import io
import json
import sqlite3
import time
import uuid
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import delete, select
from app.core import artifact_store
from app.core.config import settings
from app.core.events import EventType
from app.data import introspect, table_versions
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Setting
from app.engine import spend, verify_agent
from app.engine.toolcalls import Limit, ToolTimeout
from app.engine.verify_agent import result_schema, run_verify_agent
from app.providers import catalog as pricing
from app.providers.factory import ModelSpec, ProviderNotConfigured
from app.providers.mock_model import MockChatModel
from app.tools.registry import Versions
SPEC = ModelSpec(model="mock-fast")
SCHEMA = {"type": "object", "properties": {
"value": {"type": "number", "description": "未作废订单的金额合计"},
"verdict": {"type": "string", "enum": ["一致", "不一致", "无法判断"]},
}, "required": ["value", "verdict"]}
VALID_SUM = "SELECT SUM(amount) AS total FROM orders WHERE is_deleted = 0"
@pytest.fixture(autouse=True)
async def clean_spend():
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([spend.SPEND_KEY, "verify"])))
await session.commit()
yield
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key == "verify"))
await session.commit()
@pytest.fixture
async def shop(tmp_path):
"""可写的手工源:四笔订单,金额 120、80、50(已作废)、30;未作废的合计 230。返回 (源名, 文件路径)。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(
"CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER, is_deleted INTEGER, paid_at TEXT);"
"INSERT INTO orders VALUES (1, 120, 0, '2026-09-02'), (2, 80, 0, '2026-09-03'),"
" (3, 50, 1, '2026-09-03'), (4, 30, 0, '2026-09-05');")
db.commit()
db.close()
name = f"shop_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=False, enabled=True)
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name, str(path)
await engines.invalidate(source_id)
class Script:
"""核查模型的剧本。steps:循环里每一轮发的工具调用 [(工具名, 参数, 调用 id)],或者一段说明文字;剧本用完就收手。
final:交结果那一轮返回的 JSON(可以是按消息算的函数)。calls 按顺序记下每次调用的种类和收到的消息。"""
def __init__(self, monkeypatch, steps, final, *, model_id: str = "mock-fast", latency: float = 0.0):
self.calls: list[tuple[str, list]] = []
self.formats: list[dict] = []
self.specs: list[ModelSpec] = []
pending = list(steps)
def decide(model, messages):
if model.response_format:
self.calls.append(("final", list(messages)))
self.formats.append(model.response_format)
body = final(messages) if callable(final) else final
return AIMessage(content=json.dumps(body, ensure_ascii=False))
self.calls.append(("step", list(messages)))
if not pending:
return AIMessage(content="查完了。")
step = pending.pop(0)
if isinstance(step, str):
return AIMessage(content=step)
return AIMessage(content="查一下。", tool_calls=[{"name": n, "args": a, "id": i} for n, a, i in step])
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def fake_model(session, spec):
self.specs.append(spec)
return MockChatModel(model_name=model_id, latency=latency), model_id
monkeypatch.setattr(verify_agent, "get_chat_model", fake_model)
def tool_results(self) -> dict[str, str]:
"""模型在循环里看到的工具结果:{调用 id: 内容}。"""
out: dict[str, str] = {}
for _, messages in self.calls:
for m in messages:
if isinstance(m, ToolMessage):
out[m.tool_call_id] = str(m.content)
return out
@property
def kinds(self) -> list[str]:
return [k for k, _ in self.calls]
def q(name: str, sql: str, call_id: str) -> list[tuple]:
return [(f"db_query__{name}", {"sql": sql}, call_id)]
async def run(name: str, **kwargs):
base = dict(spec=SPEC, system="核对口径卡里的「销售额」。", task="算一遍未作废订单的金额合计。", sources=[name],
output_schema=SCHEMA, data_versions=Versions.CURRENT)
return await run_verify_agent(**{**base, **kwargs})
def counts(path: str) -> tuple[int, int]:
db = sqlite3.connect(path)
try:
return db.execute("SELECT COUNT(*), SUM(amount) FROM orders").fetchone()
finally:
db.close()
# --------------------------------------------------------------------------
# 正常查询、取格子
# --------------------------------------------------------------------------
async def test_cells_are_taken_from_the_snapshot(monkeypatch, shop):
name, _ = shop
script = Script(monkeypatch, [[(f"db_schema__{name}", {"table": "orders"}, "s1")], q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致",
"rationale": "未作废订单的金额合计是 [[v:Q1.r0.total]]。[[see:Q1]]"})
out = await run(name)
assert out.error is None and out.limit is None
assert out.result["value"] == 230 and out.result["verdict"] == "一致"
[cell] = out.cells
assert cell["status"] == "resolved" and cell["ref"] == "Q1.r0.total" and cell["value"] == 230
assert cell["path"] == "value" and cell["eid"].startswith("ev:cell:")
[query] = out.queries
assert query["alias"] == "Q1" and query["row_count"] == 1 and query["truncated"] is False
assert "SUM(amount)" in query["sql"]
snapshot = artifact_store.load(query["artifact"])
assert snapshot["rows"] == [[230]] and cell["artifact"] == query["artifact"]
# 理由按本 agent 自己的台账组装:引用渲染成快照里的值,没有违规
assert out.rationale["markdown"] == "未作废订单的金额合计是 230。" and out.rationale["violations"] == []
assert out.result["rationale"] == "未作废订单的金额合计是 230。"
# 结果里的数值要求写格子引用:交给模型的结构里,value 是 {alias, row, column}
assert "alias" in json.dumps(script.formats[0]) and "rationale" in script.formats[0]["properties"]
assert script.kinds == ["step", "step", "step", "final"]
assert out.usage["calls"] == 4 and out.usage["input_tokens"] > 0
assert script.tool_results()["c1"].startswith("【证据 Q1】")
async def test_events_mark_the_queries_as_verification_and_nothing_goes_to_the_ledger(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, "SELEC broken", "c2")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "见 [[see:Q1]]。"})
events: list[tuple] = []
out = await run(name, emit=lambda kind, **data: events.append((kind, data)), run_id="run-x", node_id="check")
ends = {d["call_id"]: d for k, d in events if k == EventType.TOOL_END}
starts = [d for k, d in events if k == EventType.TOOL_START]
assert [d["call_id"] for d in starts] == ["c1", "c2"], "每个工具调用一条 tool.start,和 tool.end 配对"
assert all(d["role"] == "verify" for d in starts)
end = ends["c1"]
assert end["role"] == "verify" and end["query_artifact"] == out.queries[0]["artifact"] and end["alias"] == "Q1"
# 被拒的语句照常发 tool.end(和 Agent 节点一样),运行面板按「SQL 被拒绝」开头标成失败;它没有查询快照
assert ends["c2"]["role"] == "verify" and ends["c2"]["preview"].startswith("SQL 被拒绝")
assert "query_artifact" not in ends["c2"] and "alias" not in ends["c2"]
llm_ends = [d for k, d in events if k == EventType.LLM_END]
assert len(llm_ends) == out.usage["calls"] and all(d["role"] == "verify" for d in llm_ends)
# 工件归属这次运行、这个节点
assert artifact_store.load(out.queries[0]["artifact"])["rows"] == [[230]]
# 只交回结果,不交台账:没有任何 kind: query 的条目能流进 state["evidence"]
assert "evidence" not in out.as_dict() and all("kind" not in qd for qd in out.queries)
async def test_without_emit_nothing_is_announced(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "见 [[see:Q1]]。"})
out = await run(name)
assert out.result["value"] == 230, "质询接口这类不在节点里的调用不传 emit,照常工作"
# --------------------------------------------------------------------------
# 只读
# --------------------------------------------------------------------------
async def test_writes_are_refused_on_a_writable_source(monkeypatch, shop):
name, path = shop
script = Script(monkeypatch, [q(name, "DELETE FROM orders WHERE is_deleted = 1", "w1"),
q(name, "WITH k AS (SELECT 1) UPDATE orders SET amount = 0", "w2"),
q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致",
"rationale": "见 [[see:Q1]]。"})
out = await run(name)
seen = script.tool_results()
assert seen["w1"].startswith("SQL 被拒绝:") and seen["w2"].startswith("SQL 被拒绝:")
assert counts(path) == (4, 280), "数据原样不动"
assert [qd["alias"] for qd in out.queries] == ["Q1"], "被拒的语句不编号"
assert out.result["value"] == 230
# --------------------------------------------------------------------------
# 上限
# --------------------------------------------------------------------------
async def test_the_query_limit_stops_the_loop_and_settles(monkeypatch, shop):
name, _ = shop
script = Script(monkeypatch, [q(name, "SELECT COUNT(*) AS n FROM orders", "c1"),
[(f"db_query__{name}", {"sql": "SELECT MAX(amount) AS m FROM orders"}, "c2"),
(f"db_query__{name}", {"sql": VALID_SUM}, "c3")],
q(name, "SELECT MIN(amount) AS m FROM orders", "c4")],
{"value": {"alias": "Q2", "row": 0, "column": "m"}, "verdict": "无法判断",
"rationale": "查询次数用完了,最大一笔是 [[v:Q2.r0.m]]。"})
out = await run(name, max_queries=2)
assert out.limit == "max_queries"
assert [qd["alias"] for qd in out.queries] == ["Q1", "Q2"]
assert all("is_deleted" not in qd["sql"] for qd in out.queries), "同一轮里超出上限的那条没有执行"
assert script.kinds == ["step", "step", "final"], "到顶就收尾,不再给模型下一轮"
assert out.result["value"] == 120
final_prompt = str(script.calls[-1][1][-1].content)
assert "已达上限(最多查询 2 次)" in final_prompt, "收尾时告诉模型为什么不能再查"
async def test_the_time_limit_stops_the_loop_and_still_settles(monkeypatch, shop):
name, _ = shop
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 2.0)
endless = [q(name, f"SELECT COUNT(*) AS n FROM orders WHERE id > {i}", f"c{i}") for i in range(50)]
script = Script(monkeypatch, endless,
{"value": {"alias": "Q1", "row": 0, "column": "n"}, "verdict": "无法判断", "rationale": "超时了。"},
latency=0.15)
started = time.monotonic()
out = await run(name, timeout_s=0.5, max_queries=None)
assert out.limit == "timeout_s"
assert script.kinds[-1] == "final", "到点也要收尾交结果"
assert time.monotonic() - started < 0.5 + 2.0 + 1.0
assert 1 <= len(out.queries) < 10
async def test_a_model_call_that_runs_past_the_deadline_is_cut(monkeypatch, shop):
name, _ = shop
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.3)
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": None, "verdict": "无法判断", "rationale": "没查到。"}, latency=1.0)
started = time.monotonic()
out = await run(name, timeout_s=0.2)
assert out.limit == "timeout_s" and out.queries == []
assert time.monotonic() - started < 1.5, "超时的那次调用当场放弃,收尾那次也只等宽限的时长"
assert out.result is None and out.error, "收尾那次也没赶上:照实说没交成结果"
async def test_a_query_that_hits_its_own_time_limit_is_not_the_overall_deadline(monkeypatch, shop):
"""一条查询超过它自己的时限(ToolTimeout,也是一种 TimeoutError):这条查询作罢、原因交回模型让它改,核查
照常往下走。不能当成整个核查的时长到了,把还剩的时间全扔掉。"""
name, _ = shop
real = verify_agent.run_bounded
seen = {"n": 0}
async def stuck_once(call, limit, tool_name):
seen["n"] += 1
if seen["n"] == 1:
call.close()
raise ToolTimeout(tool_name, Limit(seconds=30.0, enforce=True, kind="query"))
return await real(call, limit, tool_name)
monkeypatch.setattr(verify_agent, "run_bounded", stuck_once)
script = Script(monkeypatch, [q(name, "SELECT COUNT(*) AS n FROM orders", "c1"), q(name, VALID_SUM, "c2")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"})
events: list[tuple] = []
out = await run(name, timeout_s=600, emit=lambda kind, **data: events.append((kind, data)))
assert out.limit is None, "整个核查的时长远没到"
assert script.tool_results()["c1"].startswith("查询超过 30 秒没有返回")
assert out.result["value"] == 230 and [qd["alias"] for qd in out.queries] == ["Q1"]
[error] = [d for k, d in events if k == EventType.TOOL_ERROR]
assert error["call_id"] == "c1" and "核查的时长上限" not in error["error"]
@pytest.fixture
def priced(monkeypatch):
"""每次调用按 0.01 美元估算、按 0.01 美元结算:金额上限的算术好核对。"""
monkeypatch.setattr(pricing, "find_pricing", lambda model: {"input": 1.0, "output": 1.0})
monkeypatch.setattr(pricing, "estimate_cost", lambda model, inp, out: 0.01)
@pytest.fixture
def by_tokens(monkeypatch):
"""按 token 数估算(每个 token 0.000001 美元):消息越长估得越多,收尾时交给模型的查询结果也要算钱。"""
monkeypatch.setattr(pricing, "find_pricing", lambda model: {"input": 1.0, "output": 1.0})
monkeypatch.setattr(pricing, "estimate_cost", lambda model, inp, out: (inp + out) / 1_000_000)
@pytest.fixture
async def wordy(tmp_path):
"""200 条留言,每条一大段中文:查出来交给收尾那次调用时很占 token。返回源名。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, note TEXT)")
db.executemany("INSERT INTO notes VALUES (?, ?)", [(i, f"第{i}条留言" + "虚构的长段落文字" * 50) for i in range(200)])
db.commit()
db.close()
name = f"notes_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await engines.invalidate(source_id)
@pytest.mark.parametrize("cap", [0.003, 0.004, 0.005, 0.02, 0.05])
async def test_a_query_never_runs_without_money_left_to_settle_it(monkeypatch, wordy, by_tokens, cap):
"""收尾那次调用要把查到的结果交给模型:结果越长越贵。查询之前就要按收尾最贵的样子把钱留好,不然查完了却
付不起收尾,查到的结果白查。"""
count = {"type": "object", "properties": {"count": {"type": "integer"}}}
Script(monkeypatch, [q(wordy, "SELECT id, note FROM notes ORDER BY id", "c1")],
{"count": {"alias": "Q1", "row": 0, "column": "id"}, "rationale": "x"}, model_id="priced-model")
out = await run(wordy, output_schema=count, max_cost_usd=cap, max_queries=1)
assert out.error is None and out.result is not None, "不管上限多紧,都交得出结果"
assert out.usage["cost_usd"] <= cap, "收尾那次也在上限以内:钱是查询之前就留好的,不是收尾时硬超出去"
if out.queries:
assert out.result["count"] == 0, "查了就用上了"
else:
assert out.limit == "max_cost_usd", "钱不够查询加收尾,就不查"
async def test_a_final_answer_budgeted_in_the_loop_is_not_refused_by_the_daily_cap(monkeypatch, shop, priced):
"""查询之后、收尾之前,别的核查把当天的余额用光了(几个核查并行):收尾那次已经在循环里留过钱,照常交结果,
查到的结果不白查。"""
name, _ = shop
async def crowded(kind, **data):
if kind == EventType.TOOL_END:
async with SessionLocal() as session:
row = await session.get(Setting, spend.SPEND_KEY)
row.value = {**row.value, "usd": 1.999}
await session.commit()
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, "SELECT COUNT(*) AS n FROM orders", "c2")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"},
model_id="priced-model")
out = await run(name, daily_max_usd=2.0, emit=crowded)
assert out.limit == "daily_max_usd", "下一轮查询付不起了"
assert out.error is None and out.result["value"] == 230
async def test_the_daily_cap_comes_from_the_settings_unless_given(monkeypatch, shop, priced):
"""不传每日上限就按设置里的「核查每日金额上限」:节点、质询接口不必各自记得去读;明确传 None 才是不限。"""
name, _ = shop
async with SessionLocal() as session:
session.add(Setting(key=spend.SPEND_KEY, value={"date": spend._today(), "usd": 0.995, "calls": 3}))
session.add(Setting(key="verify", value={"daily_max_usd": 1.0}))
await session.commit()
final = {"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"}
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")], final, model_id="priced-model")
out = await run(name)
assert out.limit == "daily_max_usd" and script.calls == [] and out.result is None
assert "$1.00" in out.error
Script(monkeypatch, [q(name, VALID_SUM, "c1")], final, model_id="priced-model")
unlimited = await run(name, daily_max_usd=None)
assert unlimited.limit is None and unlimited.result["value"] == 230
async def test_the_cost_limit_keeps_room_for_the_final_answer(monkeypatch, shop, priced):
name, _ = shop
endless = [q(name, f"SELECT COUNT(*) AS n FROM orders WHERE id > {i}", f"c{i}") for i in range(10)]
script = Script(monkeypatch, endless,
{"value": {"alias": "Q1", "row": 0, "column": "n"}, "verdict": "无法判断", "rationale": "钱用完了。"},
model_id="priced-model")
out = await run(name, max_cost_usd=0.035, max_queries=None)
assert out.limit == "max_cost_usd"
assert script.kinds == ["step", "step", "final"], "每一轮都给收尾那次留出钱:两轮之后再查就付不起收尾了"
assert out.usage["cost_usd"] == pytest.approx(0.03) and out.usage["cost_usd"] <= 0.035
assert out.result["value"] == 4
async def test_the_daily_cap_is_shared_by_every_verification(monkeypatch, shop, priced):
name, _ = shop
async with SessionLocal() as session:
session.add(Setting(key=spend.SPEND_KEY, value={"date": spend._today(), "usd": 1.995, "calls": 9}))
await session.commit()
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"},
model_id="priced-model")
out = await run(name, daily_max_usd=2.0)
assert out.limit == "daily_max_usd" and script.calls == [] and out.usage["calls"] == 0
assert out.result is None and out.error
assert (await spend.daily_spend())["usd"] == pytest.approx(1.995), "没调用就不记账"
async def test_spending_goes_into_the_verify_ledger(monkeypatch, shop, priced):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "见 [[see:Q1]]。"},
model_id="priced-model")
out = await run(name, daily_max_usd=2.0)
day = await spend.daily_spend()
assert day["calls"] == out.usage["calls"] == 3 and day["usd"] == pytest.approx(out.usage["cost_usd"])
async def test_an_unpriced_model_makes_the_cost_limits_moot_and_says_so(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "见 [[see:Q1]]。"},
model_id="house-model-unpriced")
events: list[tuple] = []
out = await run(name, max_cost_usd=0.0001, daily_max_usd=0.0001,
emit=lambda kind, **data: events.append((kind, data)))
assert out.limit is None and out.result["value"] == 230, "估不出金额:金额上限拦不住,也不该拦"
assert any("不在价格目录中" in n and "金额上限" in n for n in out.notes)
assert any(k == EventType.LOG and d.get("code") == "verify_unpriced" for k, d in events)
assert out.usage["unpriced_calls"] == out.usage["calls"] == 3
assert (await spend.daily_spend())["unpriced_calls"] == 3
# --------------------------------------------------------------------------
# 数值只认格子
# --------------------------------------------------------------------------
async def test_numbers_the_model_writes_itself_are_ignored(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": 999, "verdict": "一致", "rationale": "合计是 [[v:Q1.r0.total]]。"})
out = await run(name)
assert out.result["value"] is None, "直接写的数字不采信,也不兜底成 0"
[cell] = out.cells
assert cell["status"] == "unresolved" and cell["model_value"] == 999 and "格子引用" in cell["reason"]
async def test_a_value_tucked_into_the_reference_is_ignored_too(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total", "value": 999}, "verdict": "一致", "rationale": "x"})
out = await run(name)
assert out.result["value"] == 230, "引用里夹带的值不看,按快照取"
@pytest.mark.parametrize("ref,reason", [
({"alias": "Q7", "row": 0, "column": "total"}, "Q7"),
({"alias": "Q1", "row": 5, "column": "total"}, "第 5 行"),
({"alias": "Q1", "row": 0, "column": "amount"}, "amount"),
])
async def test_a_reference_that_points_nowhere_is_unresolved(monkeypatch, shop, ref, reason):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"value": ref, "verdict": "一致", "rationale": "x"})
out = await run(name)
assert out.result["value"] is None
assert out.cells[0]["status"] == "unresolved" and reason in out.cells[0]["reason"]
async def test_bare_numbers_in_the_rationale_are_flagged(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致",
"rationale": "合计是 230,比原流程多 3 成。"})
out = await run(name)
flagged = [v["text"] for v in out.rationale["violations"] if v["code"] == "uncited_number"]
assert flagged == ["230", "3"] and out.rationale["stats"]["uncited_numbers"] == 2
bare = [s for _, u in [(b, u) for b in out.rationale["blocks"] for u in b["units"]] for s in u["segments"]
if s.get("issue") == "uncited_number"]
assert [s["text"] for s in bare] == ["230", "3"], "界面要在这几个字底下画「无出处」"
async def test_nested_numbers_and_arrays_are_references_too(monkeypatch, shop):
name, _ = shop
schema = {"type": "object", "properties": {
"totals": {"type": "array", "items": {"type": "number"}},
"detail": {"type": "object", "properties": {"count": {"type": "integer"}, "note": {"type": "string"}}},
}}
Script(monkeypatch, [q(name, "SELECT id, amount FROM orders ORDER BY id", "c1")],
{"totals": [{"alias": "Q1", "row": 0, "column": "amount"}, {"alias": "Q1", "row": 3, "column": "amount"}],
"detail": {"count": {"alias": "Q1", "row": 3, "column": "id"}, "note": "四笔"}, "rationale": "x"})
out = await run(name, output_schema=schema)
assert out.result["totals"] == [120, 30] and out.result["detail"] == {"count": 4, "note": "四笔"}
assert [c["path"] for c in out.cells] == ["totals[0]", "totals[1]", "detail.count"]
#: Pydantic 这类工具生成的结构:可为 null 的数写成 anyOf,嵌套的对象写成 $ref 到 $defs,带说明的引用包一层 allOf,
#: Optional[对象] 是 anyOf [$ref, null]
PYDANTIC_STYLE = {
"type": "object",
"$defs": {"Region": {"type": "object", "properties": {"name": {"type": "string"}, "total": {"type": "number"}}}},
"properties": {
"value": {"anyOf": [{"type": "number"}, {"type": "null"}], "description": "未作废订单的金额合计"},
"count": {"type": ["integer", "null"]},
"top": {"$ref": "#/$defs/Region"},
"backup": {"anyOf": [{"$ref": "#/$defs/Region"}, {"type": "null"}]},
"wrapped": {"allOf": [{"$ref": "#/$defs/Region"}], "description": "带说明的引用"},
"regions": {"type": "array", "items": {"$ref": "#/$defs/Region"}},
},
}
def test_every_way_of_writing_a_number_becomes_a_reference():
"""anyOf、type 列表、$ref、allOf 里的数也换成格子引用:认不出这些写法,模型在那里写的数就绕过了格子这一关。"""
shaped = result_schema(PYDANTIC_STYLE)
assert "$ref" not in json.dumps(shaped) and "$defs" not in shaped
def is_ref(node):
return node["anyOf"][0]["required"] == ["alias", "row", "column"] and node["anyOf"][1] == {"type": "null"}
props = shaped["properties"]
assert is_ref(props["value"]) and "未作废订单的金额合计" in props["value"]["description"]
assert is_ref(props["count"])
assert is_ref(props["top"]["properties"]["total"]) and props["top"]["properties"]["name"] == {"type": "string"}
assert is_ref(props["wrapped"]["properties"]["total"])
assert is_ref(props["regions"]["items"]["properties"]["total"])
[region, null] = props["backup"]["anyOf"]
assert is_ref(region["properties"]["total"]) and null == {"type": "null"}
async def test_numbers_written_directly_in_any_of_those_shapes_are_ignored(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": 999, "count": 7, "top": {"name": "分区甲", "total": 999}, "backup": {"name": "分区乙", "total": 1},
"wrapped": {"name": "分区丙", "total": 2}, "regions": [{"name": "分区丁", "total": 3}], "rationale": "x"})
out = await run(name, output_schema=PYDANTIC_STYLE)
assert out.result == {"value": None, "count": None, "top": {"name": "分区甲", "total": None},
"backup": {"name": "分区乙", "total": None}, "wrapped": {"name": "分区丙", "total": None},
"regions": [{"name": "分区丁", "total": None}], "rationale": "x"}
assert {c["path"]: c["model_value"] for c in out.cells} == {
"value": 999, "count": 7, "top.total": 999, "backup.total": 1, "wrapped.total": 2, "regions[0].total": 3}
assert all(c["status"] == "unresolved" and "没有给出格子引用" in c["reason"] for c in out.cells), \
"都认成了数值字段(该写格子引用),不是碰巧被当成认不出的结构丢掉"
async def test_references_in_those_shapes_are_resolved(monkeypatch, shop):
name, _ = shop
ref = {"alias": "Q1", "row": 0, "column": "total"}
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": ref, "count": None, "top": {"name": "分区甲", "total": ref}, "backup": None,
"wrapped": {"name": "分区丙", "total": ref}, "regions": [{"name": "分区丁", "total": ref}], "rationale": "x"})
out = await run(name, output_schema=PYDANTIC_STYLE)
assert out.result["value"] == 230 and out.result["top"]["total"] == 230 and out.result["wrapped"]["total"] == 230
assert out.result["regions"] == [{"name": "分区丁", "total": 230}]
assert out.result["count"] is None and out.result["backup"] is None, "可以为 null 的照实是 null"
async def test_numbers_under_a_shape_that_is_not_recognised_are_dropped(monkeypatch, shop):
"""认不出的结构(没写类型、没写 items 的数组、没写属性的对象)、文本字段里冒出来的数:一律不采信,照记下来。
码值(enum)里的数是调用方给的选项,不是查出来的数,照收。"""
name, _ = shop
schema = {"type": "object", "properties": {
"anything": {}, "notes": {"type": "array"}, "label": {"type": "string"}, "level": {"enum": [1, 2, 3]},
"extra": {"type": "object"}}}
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"anything": 999, "notes": ["见 Q1", 120], "label": 5, "level": 2,
"extra": {"note": "按支付时间算", "share": 0.3}, "rationale": "x"})
out = await run(name, output_schema=schema)
assert out.result == {"anything": None, "notes": ["见 Q1", None], "label": None, "level": 2,
"extra": {"note": "按支付时间算", "share": None}, "rationale": "x"}
assert {c["path"]: c["model_value"] for c in out.cells} == {"anything": 999, "notes[1]": 120, "label": 5,
"extra.share": 0.3}
assert all(c["status"] == "unresolved" for c in out.cells)
async def test_references_written_as_json_text_are_read(monkeypatch, shop):
"""有的模型把嵌套的对象、数组写成一段 JSON 文本:照样认(裁判也是这么认的)。还不是数组的照实记下格式不对。"""
name, _ = shop
schema = {"type": "object", "properties": {
"value": {"type": "number"}, "totals": {"type": "array", "items": {"type": "number"}},
"broken": {"type": "array", "items": {"type": "number"}}}}
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": '{"alias": "Q1", "row": 0, "column": "total"}',
"totals": '[{"alias": "Q1", "row": 0, "column": "total"}]', "broken": "没有这一项", "rationale": "x"})
out = await run(name, output_schema=schema)
assert out.result == {"value": 230, "totals": [230], "broken": None, "rationale": "x"}
broken = next(c for c in out.cells if c["path"] == "broken")
assert broken["status"] == "unresolved" and "数组" in broken["reason"]
async def test_a_number_field_pointing_at_a_text_cell_is_unresolved(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, "SELECT paid_at, amount FROM orders ORDER BY id", "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "paid_at"}, "verdict": "一致", "rationale": "x"})
out = await run(name)
assert out.result["value"] is None, "数值字段只收数:日期文本拿去和原流程的数比,比较会出错"
[cell] = out.cells
assert cell["status"] == "unresolved" and "不是数值" in cell["reason"] and cell["ref"] == "Q1.r0.paid_at"
assert cell["value"] == "2026-09-02"
async def test_a_field_left_out_is_not_a_field_reported_missing(monkeypatch, shop):
"""没交这个字段和交了 null(模型说查不到)是两回事:前者是没按结构交,后者才是 missing。"""
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"verdict": "无法判断", "rationale": "x"})
left_out = await run(name)
[cell] = left_out.cells
assert cell["status"] == "unresolved" and "没有交" in cell["reason"]
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"value": None, "verdict": "无法判断", "rationale": "x"})
said_null = await run(name)
assert said_null.cells == [{"path": "value", "status": "missing"}]
async def test_an_answer_with_none_of_the_fields_is_an_error(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"total": 230, "rationale": "合计 230"})
out = await run(name)
assert out.result is None and out.cells == [] and "没有按结构交结果" in out.error
# --------------------------------------------------------------------------
# 数据版本
# --------------------------------------------------------------------------
def book(rows: list[list]) -> bytes:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "明细"
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
async def test_pinned_data_versions_are_used(monkeypatch, tmp_path):
data = tmp_path / "data"
(data / "uploads").mkdir(parents=True)
monkeypatch.setattr(settings, "data_dir", data)
name = f"upload_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
first = await table_versions.publish_upload(session, row, book([["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]),
"客流.xlsx", header_row=1, mixed="reject", raw_mode=False)
source_id = row.id
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one()
await table_versions.publish_upload(session, row, book([["区域", "数量"], ["分区甲", 60], ["分区乙", 40]]),
"客流.xlsx", header_row=1, mixed="reject", raw_mode=False)
total = {"type": "object", "properties": {"total": {"type": "number"}}}
final = {"total": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"}
sql = 'SELECT SUM("数量") AS total FROM "明细"'
Script(monkeypatch, [q(name, sql, "c1")], final)
pinned = await run(name, output_schema=total,
data_versions={source_id: {"snapshot": first.snapshot_id, "name": name}})
assert pinned.result["total"] == 3, "按运行固定的那一版查"
assert artifact_store.load(pinned.queries[0]["artifact"])["data_version"] == first.snapshot_id
Script(monkeypatch, [q(name, sql, "c1")], final)
current = await run(name, output_schema=total, data_versions=Versions.CURRENT)
assert current.result["total"] == 100
# --------------------------------------------------------------------------
# 出错不抛
# --------------------------------------------------------------------------
async def test_a_model_that_is_not_configured_is_reported_not_raised(monkeypatch, shop):
name, _ = shop
async def broken(session, spec):
raise ProviderNotConfigured("模型接入「无」没有可用的模型", "请在「设置 → 模型接入」中添加", code="no_model")
monkeypatch.setattr(verify_agent, "get_chat_model", broken)
out = await run(name)
assert out.result is None and "核查模型不可用" in out.error and out.usage["calls"] == 0
async def test_an_unknown_source_is_noted(monkeypatch, shop):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"})
out = await run(name, sources=[name, "no_such_source"])
assert out.result["value"] == 230
assert any("no_such_source" in n for n in out.notes)
async def test_a_final_answer_in_plain_text_json_is_still_read(monkeypatch, shop):
"""结构化输出失败(模型不支持、给的不是合法 JSON):退回普通调用,从回复里把 JSON 抠出来。"""
name, _ = shop
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")], "不是 JSON")
original = MockChatModel._decide
def decide(model, messages):
if not model.response_format and any("交结果" in str(m.content) for m in messages[-1:]):
return AIMessage(content='好的:```json\n{"value": {"alias": "Q1", "row": 0, "column": "total"}, '
'"verdict": "一致", "rationale": "x"}\n```')
return original(model, messages)
monkeypatch.setattr(MockChatModel, "_decide", decide)
out = await run(name)
assert out.result["value"] == 230 and script.kinds.count("final") == 1
def plain_reply(monkeypatch, reply):
"""普通调用(退回的那一次)的回复:reply(最后一条消息的正文) → 回复正文。其余照剧本。"""
original = MockChatModel._decide
seen: list[str] = []
def decide(model, messages):
text = str(messages[-1].content)
if not model.response_format and "交结果" in text:
seen.append(text)
return AIMessage(content=reply(text))
return original(model, messages)
monkeypatch.setattr(MockChatModel, "_decide", decide)
return seen
async def test_the_plain_fallback_is_told_which_fields_to_fill(monkeypatch, shop):
"""结构化输出不成、退回普通调用时,结构不在请求里了:要写进消息,不然模型不知道字段叫什么,交回来的字段
对不上,全成了「查不到」。"""
name, _ = shop
schema = {"type": "object", "properties": {
"blind_total_amount": {"type": "number", "description": "盲算的金额合计"},
"consistency_verdict": {"type": "string", "enum": ["一致", "不一致"]}}}
Script(monkeypatch, [q(name, VALID_SUM, "c1")], "不是 JSON")
def reply(text):
# 模型只能按消息里看得到的字段名交
body = {"rationale": "x"}
if "blind_total_amount" in text:
body["blind_total_amount"] = {"alias": "Q1", "row": 0, "column": "total"}
if "consistency_verdict" in text:
body["consistency_verdict"] = "一致"
return json.dumps(body, ensure_ascii=False)
seen = plain_reply(monkeypatch, reply)
out = await run(name, output_schema=schema)
assert len(seen) == 1 and "盲算的金额合计" in seen[0] and "alias" in seen[0]
assert out.error is None and out.result == {"blind_total_amount": 230, "consistency_verdict": "一致",
"rationale": "x"}
async def test_a_model_without_structured_output_goes_straight_to_the_plain_call(monkeypatch, shop):
"""模型不支持结构化输出(建的时候就报错):直接走普通调用,不算一次调用,也不抛。"""
name, _ = shop
def unsupported(self, schema, **kwargs):
raise NotImplementedError("这个模型不支持结构化输出")
monkeypatch.setattr(MockChatModel, "with_structured_output", unsupported)
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")], "不会用到")
plain_reply(monkeypatch, lambda text: '{"value": {"alias": "Q1", "row": 0, "column": "total"}, '
'"verdict": "一致", "rationale": "x"}')
out = await run(name)
assert out.error is None and out.result["value"] == 230
assert out.usage["calls"] == len(script.calls) + 1 == 3, "两轮查库加一次普通调用,建不起来的那次不算"
async def test_a_failing_ledger_is_reported_not_raised(monkeypatch, shop):
"""每日账写不进去(库被锁住):照实交回,不抛。节点因此降成「核不了」,质询接口不会回 500。"""
name, _ = shop
async def locked(*args, **kwargs):
raise RuntimeError("database is locked")
monkeypatch.setattr(spend, "charge", locked)
Script(monkeypatch, [q(name, VALID_SUM, "c1")],
{"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "x"})
out = await run(name)
assert out.result is None and out.error
+205
View File
@@ -0,0 +1,205 @@
"""核查设置(api/settings.py 的 verify 一组):双盲复算、反方审稿、审批质询共用。
- 核查模型(接入、模型):节点上写了用节点的,没写用设置里的,设置也没写就沿用证据裁判模型的取值链
(裁判设置 > 助手模型 > 默认接入),哪一级写了就整组用哪一级,不跨级拼接入和模型
- 三项各有开关和上限:双盲复算 {启用, 允许的相对误差, 查询次数, 时长}、反方审稿 {启用, 句数, 查询次数, 时长}、
审批质询 {启用, 查询次数, 时长};另有全局每日金额上限。上限存 null 表示不限,不会被默认值悄悄盖回去
- 设置页写进来的值先查后写:有一项不对整个请求回 422,说明写设置页上的叫法,一项都不落库
- 核查的每日计数是引擎记的账(engine/spend.py),不从设置接口进出;有一个只读接口给设置页看今天花了多少
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.api.settings import (
VERIFY_DEFAULTS,
VERIFY_GROUP,
VERIFY_SETTING_LABEL,
verify_model_spec,
verify_settings,
)
from app.db.base import SessionLocal
from app.db.models import Setting
from app.engine import spend
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS
from app.main import app
@pytest.fixture(autouse=True)
async def clean():
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_(
[VERIFY_GROUP, spend.SPEND_KEY, "judge", "copilot"])))
await session.commit()
yield
def client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
# --------------------------------------------------------------------------
# 默认值与读写
# --------------------------------------------------------------------------
def test_the_defaults_are_the_agreed_ones():
assert VERIFY_GROUP == "verify"
assert VERIFY_DEFAULTS == {
"provider": None, "model": None,
"dual_check": {"enabled": True, "tolerance": 1e-6, "max_queries": 6, "timeout_s": 120},
"challenge": {"enabled": True, "max_claims": 5, "max_queries": 4, "timeout_s": 90},
"inquiry": {"enabled": True, "max_queries": 6, "timeout_s": 120},
"daily_max_usd": 2.0,
}
async def test_settings_expose_the_verify_group_with_defaults():
async with client() as c:
got = (await c.get("/api/settings")).json()
assert got["verify"] == VERIFY_DEFAULTS
assert got["judge"] == JUDGE_DEFAULTS, "裁判一组不受影响"
assert spend.SPEND_KEY not in got
async def test_a_full_group_round_trips_and_null_stays_unlimited():
value = {
"provider": "house", "model": "verify-model",
"dual_check": {"enabled": False, "tolerance": 0, "max_queries": None, "timeout_s": None},
"challenge": {"enabled": True, "max_claims": None, "max_queries": 2, "timeout_s": 30.5},
"inquiry": {"enabled": False, "max_queries": 1, "timeout_s": None},
"daily_max_usd": None,
}
async with client() as c:
r = await c.put("/api/settings", json={"values": {"verify": value}})
assert r.status_code == 200, r.text
assert r.json()["verify"] == value
assert (await c.get("/api/settings")).json()["verify"] == value
async with SessionLocal() as session:
assert await verify_settings(session) == value
async def test_a_partial_group_keeps_the_other_defaults_down_to_each_item():
async with client() as c:
r = await c.put("/api/settings", json={"values": {"verify": {"dual_check": {"enabled": False},
"daily_max_usd": 5}}})
assert r.status_code == 200, r.text
got = (await c.get("/api/settings")).json()["verify"]
assert got == {**VERIFY_DEFAULTS, "daily_max_usd": 5,
"dual_check": {**VERIFY_DEFAULTS["dual_check"], "enabled": False}}
@pytest.mark.parametrize("key,value,label", [
("daily_max_usd", -1, "daily_max_usd"),
("daily_max_usd", "2", "daily_max_usd"),
("model", 3, "model"),
("dual_check", "on", "dual_check"),
("dual_check", {"enabled": "yes"}, "dual_check.enabled"),
("dual_check", {"tolerance": -0.1}, "dual_check.tolerance"),
("dual_check", {"tolerance": "0.01"}, "dual_check.tolerance"),
("dual_check", {"tolerance": None}, "dual_check.tolerance"),
("dual_check", {"max_queries": 2.5}, "dual_check.max_queries"),
("dual_check", {"max_queries": 0}, "dual_check.max_queries"),
("challenge", {"max_claims": True}, "challenge.max_claims"),
("challenge", {"timeout_s": -3}, "challenge.timeout_s"),
("inquiry", {"timeout_s": 0}, "inquiry.timeout_s"),
("inquiry", {"max_claims": 3}, "inquiry.max_claims"),
("unknown_key", 1, "unknown_key"),
])
async def test_bad_values_are_refused_with_the_settings_page_wording(key, value, label):
async with client() as c:
r = await c.put("/api/settings", json={"values": {"verify": {key: value}, "ui": {"theme": "dark"}}})
assert r.status_code == 422, r.text
detail = r.json()["detail"]
# 报错写设置页上的叫法(「双盲复算 · 允许的相对误差」),不写键名;认不出的键原样点名
assert VERIFY_SETTING_LABEL.get(label, label.rpartition(".")[2]) in detail, detail
got = (await c.get("/api/settings")).json()
assert got["verify"] == VERIFY_DEFAULTS and got["ui"]["theme"] != "dark", "整个请求都没写进去"
@pytest.mark.parametrize("raw", ["NaN", "Infinity"])
async def test_non_finite_numbers_are_refused(raw):
async with client() as c:
r = await c.put("/api/settings", content='{"values": {"verify": {"dual_check": {"tolerance": %s}}}}' % raw,
headers={"content-type": "application/json"})
assert r.status_code == 422, r.text
assert VERIFY_SETTING_LABEL["dual_check.tolerance"] in r.json()["detail"]
async def test_bad_rows_already_in_the_database_fall_back_to_defaults():
"""库里存坏了的值(旧版本、手改)读出来按默认,不让核查拿着负数上限、文字开关去查库。"""
async with SessionLocal() as session:
session.add(Setting(key=VERIFY_GROUP, value={
"model": " ", "daily_max_usd": -3,
"dual_check": {"enabled": "yes", "tolerance": -1, "max_queries": None},
"challenge": "broken", "inquiry": {"timeout_s": 15, "extra": 1}}))
await session.commit()
got = await verify_settings(session)
assert got["model"] is None and got["daily_max_usd"] == VERIFY_DEFAULTS["daily_max_usd"]
assert got["dual_check"] == {**VERIFY_DEFAULTS["dual_check"], "max_queries": None}
assert got["challenge"] == VERIFY_DEFAULTS["challenge"]
assert got["inquiry"] == {**VERIFY_DEFAULTS["inquiry"], "timeout_s": 15}
# --------------------------------------------------------------------------
# 每日计数
# --------------------------------------------------------------------------
async def test_the_daily_counter_does_not_travel_through_the_settings_api():
async with SessionLocal() as session:
session.add(Setting(key=spend.SPEND_KEY, value={"date": spend._today(), "usd": 1.5, "calls": 3}))
await session.commit()
async with client() as c:
assert spend.SPEND_KEY not in (await c.get("/api/settings")).json()
await c.put("/api/settings", json={"values": {spend.SPEND_KEY: {"date": spend._today(), "usd": 0}}})
async with SessionLocal() as session:
row = await session.get(Setting, spend.SPEND_KEY)
assert row.value["usd"] == 1.5, "设置页整组回写不能把今天的核查花费清零"
async def test_todays_spend_is_readable_next_to_the_daily_cap(monkeypatch):
monkeypatch.setattr(spend, "_today", lambda: "2026-10-02")
async with SessionLocal() as session:
session.add(Setting(key=spend.SPEND_KEY, value={"date": "2026-10-02", "usd": 0.42, "calls": 3,
"unpriced_calls": 1}))
await session.commit()
async with client() as c:
got = (await c.get("/api/settings/verify/spend")).json()
assert got == {"date": "2026-10-02", "usd": 0.42, "calls": 3, "unpriced_calls": 1, "daily_max_usd": 2.0}
await c.put("/api/settings", json={"values": {"verify": {"daily_max_usd": None}}})
assert (await c.get("/api/settings/verify/spend")).json()["daily_max_usd"] is None
# --------------------------------------------------------------------------
# 核查模型的取值链
# --------------------------------------------------------------------------
async def test_the_verify_model_falls_back_along_the_judge_chain():
async with SessionLocal() as session:
spec = await verify_model_spec(session)
assert (spec.provider, spec.model) == (None, None), "什么都没配:默认接入"
assert spec.thinking == "off" and spec.max_tokens == MAX_TOKENS
session.add(Setting(key="copilot", value={"provider": "", "model": "copilot-model"}))
await session.commit()
assert (await verify_model_spec(session)).model == "copilot-model", "沿用裁判的取值链:助手模型"
session.add(Setting(key="judge", value={"provider": "house", "model": "judge-model"}))
await session.commit()
spec = await verify_model_spec(session)
assert (spec.provider, spec.model) == ("house", "judge-model"), "沿用裁判的取值链:证据裁判模型"
session.add(Setting(key=VERIFY_GROUP, value={"model": "verify-model"}))
await session.commit()
spec = await verify_model_spec(session)
assert (spec.provider, spec.model) == (None, "verify-model"), "核查设置写了模型就整组用它,不和裁判的接入拼"
assert spec.thinking == "off" and spec.max_tokens == MAX_TOKENS
spec = await verify_model_spec(session, {"provider": "other", "model": "node-model", "tolerance": 0.1})
assert (spec.provider, spec.model) == ("other", "node-model"), "节点上写了就按节点"
spec = await verify_model_spec(session, {"provider": " ", "model": None})
assert spec.model == "verify-model", "节点上留空等于没写"
+107
View File
@@ -0,0 +1,107 @@
"""核查花费账(engine/spend.py):双盲复算、反方审稿、审批质询的模型调用按本地日期记一本每日账。
照结论句裁判的每日花费做(judge.py 的 SPEND_KEY、_charge、_settle),但是独立的一行设置:
- 调用前按估算预留,加上这笔会超过每日上限就不记、返回 None;上限是 None 表示不限
- 调用后按实际用量找补差额;跨了天的不找补(预留记在前一天,今天从零算)
- 价格目录里没有的模型估不出金额:只计次数(unpriced_calls),不计金额
- 和裁判的账互不挤占:核查花掉的钱不算进裁判的每日上限,反过来也一样
- 并发预留不会一起越过上限
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import delete
from app.db.base import SessionLocal
from app.db.models import Setting
from app.engine import spend
from app.engine import judge as judge_mod
@pytest.fixture(autouse=True)
async def clean(monkeypatch):
monkeypatch.setattr(spend, "_today", lambda: "2026-10-02")
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([spend.SPEND_KEY, judge_mod.SPEND_KEY])))
await session.commit()
yield
async def stored() -> dict | None:
async with SessionLocal() as session:
row = await session.get(Setting, spend.SPEND_KEY)
return row.value if row is not None else None
async def test_a_call_within_the_cap_is_recorded():
assert await spend.daily_spend() == {"date": "2026-10-02", "usd": 0.0, "calls": 0, "unpriced_calls": 0}
assert await spend.charge(0.4, limit=1.0, priced=True) == "2026-10-02"
assert await spend.daily_spend() == {"date": "2026-10-02", "usd": 0.4, "calls": 1, "unpriced_calls": 0}
async def test_a_call_that_would_cross_the_cap_is_refused_and_nothing_is_recorded():
assert await spend.charge(0.9, limit=1.0, priced=True)
assert await spend.charge(0.2, limit=1.0, priced=True) is None
assert await spend.daily_spend() == {"date": "2026-10-02", "usd": 0.9, "calls": 1, "unpriced_calls": 0}
# 正好到上限的还能记:上限是「不超过」
assert await spend.charge(0.1, limit=1.0, priced=True) == "2026-10-02"
async def test_no_cap_means_unlimited():
assert await spend.charge(50.0, limit=None, priced=True) == "2026-10-02"
assert (await spend.daily_spend())["usd"] == 50.0
async def test_unpriced_calls_are_counted_without_money():
assert await spend.charge(0.0, limit=0.5, priced=False) == "2026-10-02"
assert await spend.daily_spend() == {"date": "2026-10-02", "usd": 0.0, "calls": 1, "unpriced_calls": 1}
async def test_yesterdays_spend_does_not_count_today():
async with SessionLocal() as session:
session.add(Setting(key=spend.SPEND_KEY, value={"date": "2026-10-01", "usd": 1.9, "calls": 7,
"unpriced_calls": 2}))
await session.commit()
assert await spend.daily_spend() == {"date": "2026-10-02", "usd": 0.0, "calls": 0, "unpriced_calls": 0}
assert await spend.charge(1.5, limit=2.0, priced=True) == "2026-10-02"
assert await stored() == {"date": "2026-10-02", "usd": 1.5, "calls": 1, "unpriced_calls": 0}
async def test_settle_corrects_the_reservation_and_never_goes_below_zero():
day = await spend.charge(0.5, limit=None, priced=True)
await spend.settle(day, -0.2)
assert (await spend.daily_spend())["usd"] == pytest.approx(0.3)
await spend.settle(day, 0.15)
assert (await spend.daily_spend())["usd"] == pytest.approx(0.45)
await spend.settle(day, -5)
assert (await spend.daily_spend())["usd"] == 0.0
assert (await spend.daily_spend())["calls"] == 1, "找补只改金额,不改次数"
async def test_settling_after_midnight_does_not_touch_the_new_day(monkeypatch):
day = await spend.charge(0.5, limit=None, priced=True)
monkeypatch.setattr(spend, "_today", lambda: "2026-10-03")
assert await spend.charge(0.2, limit=None, priced=True) == "2026-10-03"
await spend.settle(day, -0.4)
assert (await spend.daily_spend())["usd"] == pytest.approx(0.2), "预留记在前一天,今天从零算"
async def test_the_verify_ledger_is_separate_from_the_judges(monkeypatch):
monkeypatch.setattr(judge_mod, "_today", lambda: "2026-10-02")
assert spend.SPEND_KEY != judge_mod.SPEND_KEY
await spend.charge(1.2, limit=2.0, priced=True)
assert (await judge_mod.daily_spend())["usd"] == 0.0, "核查花的钱不进裁判的账"
async with SessionLocal() as session:
session.add(Setting(key=judge_mod.SPEND_KEY, value={"date": "2026-10-02", "usd": 1.9, "calls": 4}))
await session.commit()
assert await spend.charge(0.7, limit=2.0, priced=True) == "2026-10-02", "裁判花的钱也不挤占核查的上限"
assert (await spend.daily_spend())["usd"] == pytest.approx(1.9)
async def test_concurrent_reservations_never_cross_the_cap_together():
results = await asyncio.gather(*(spend.charge(0.3, limit=1.0, priced=True) for _ in range(10)))
assert sum(1 for r in results if r) == 3
assert (await spend.daily_spend())["usd"] == pytest.approx(0.9)