K3b:交给核查 agent 的证据摘录先遮;反方审稿的理由过最后一道关

- 摘录由 judge.prepare 生成,以前 _hidden 只按列名遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),质询、反方 agent 的任务里四个号码都在,模型照抄进理由。现在 prepare 收 kinds({数据源名: 类型},只有核查 agent 的摘录给),查询摘录、指标输入按 masking._derived_hidden 认出改了名字的遮罩列(和工具边界同一套规则,表结构按快照冻结的那份),认的过程出了意外整份的列都不给;结论句裁判自己的摘录不给 kinds,和以前逐字相同、复用键不变。反方审稿、审批质询再把摘录里别的列、SQL 里写着的遮罩值按值换成「已遮罩」(换了写法的号码也认),值同交给查询工具的那一份(报告快照里的,反方去掉这句结论原文里写着的);解析 SQL、按值换放进线程。
- 反方审稿的理由按值遮之后,正文、理由文档(正文、原文、目录里的列名)里还认得出遮罩值的,这句记为无法判断、理由换成系统说明、不存理由文档(_kept,同审批质询的 _leaked),遮不住的「被推翻」不再让出具降档。
补测试:质询、反方各一条端到端(任务里的摘录没有号码,手机号列不给,SQL 里的号码、备注里换了写法的号码换掉),反方理由里 agent 自己查出的号码换着写法也换掉,最后一道关;分别去掉按 SQL 认列、两处按值换、agent 查到的值、最后一道关,对应的测试都变红。
This commit is contained in:
ThearchyHelios
2026-10-06 23:11:22 +08:00
parent 6ba7babdf0
commit 888f746d31
5 changed files with 276 additions and 42 deletions
+23 -12
View File
@@ -7,8 +7,9 @@
运行已经结束封存、核对一致、是探索运行——问答追加在封存之后(post_seal),是批注不是证据。正式运行封存之后不再追加
(同按需裁判);设置里关了「审批质询 · 启用」一律不答。同一次运行同时只答一条:进程内登记(_ANSWERING),登记期间
这次运行的审批回复、继续运行也不受理(api/runs.py),免得运行往下走了、问答还在路上
2. 回答(_answer):交给 agent 被问的那句原文、它挂的证据摘录(照 judge.prepare 的办法取,数据源遮罩的列照旧不给,原报告
的编号标明「原报告」)、审批人选中的原文和问题;工具是这次运行用过的查询工具对应的数据源(同名重建的、没固定过版本的
2. 回答(_answer):交给 agent 被问的那句原文、它挂的证据摘录(照 judge.prepare 的办法取,原报告的编号标明「原报告」;
摘录先遮:遮罩列不给,改了名字的遮罩列按 SQL 认出来也不给,别的列、SQL 里写着的遮罩值按值换掉,见 _excerpts)、
审批人选中的原文和问题;工具是这次运行用过的查询工具对应的数据源(同名重建的、没固定过版本的
上传表格不算,见 _usable_sources);上限按设置(每次的查询次数、时长,全局每日金额)。回答先给结论,数字只能引用 agent
自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。
封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明
@@ -125,9 +126,9 @@
点、括号,含全角),带 +86 / 0086 / 86 前缀、全角数字:写成汉字(一三九……)、中间夹着别的字、拆成几处写的、跨行
写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、19914.74)不算号码;SQL 里不带引号的数不这样认
(199-1474-2785 在 SQL 里是三个数相减),字符串、带引号的名字、注释里照样认
- 质询 agent 的任务里的证据摘录照 judge.prepare 按列名遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号)时,
摘录里有原值,agent 看得到;它写进回答、SQL 的,按值认得出的照样换掉(报告快照里的值都收了),封存的是换过的。摘录
按 SQL 认改了名字的遮罩列要改 judge.py,另行处理
- 质询 agent 的任务里的证据摘录先遮(_excerpts):遮罩列、按 SQL 认出改了名字的遮罩列(和工具边界同一套规则)不给,
别的列、SQL 里写着的遮罩值按值换(报告快照里的值),按值认的门槛同上:短的数(年龄、等级)写在 SQL 的条件里、别的列
里照原样。没指明是哪一句时任务里给的是报告正文,正文里写着的值照原样(读者本来就看得到)
"""
from __future__ import annotations
@@ -582,9 +583,13 @@ def _table_text(block: dict[str, Any], markdown: str) -> str:
def _excerpts(doc: dict[str, Any], catalog: dict[str, Any], cand: judging.Candidate, loader: Callable[[str], Any],
masked: dict[str, Any]) -> list[str]:
"""这句挂的每件证据的摘录:和 judge.prepare 一样逐件摘(数据源遮罩的列不给,带期 4 标记的文档多来历行),
指向原报告编号的写法标上「原报告」——agent 自己的查询也编 Q1、Q2…,不分开的话会把它们混成一回事。"""
masked: dict[str, Any], kinds: dict[str, Any] | None = None, secrets: _Secrets | None = None
) -> list[str]:
"""这句挂的每件证据的摘录:和 judge.prepare 一样逐件摘(带期 4 标记的文档多来历行),指向原报告编号的写法标上
「原报告」——agent 自己的查询也编 Q1、Q2…,不分开的话会把它们混成一回事。
摘录先遮,模型从任务里也看不到遮罩值:数据源遮罩的列不给,改了名字的(buyer_phone AS 手机号)按 SQL 认出来也不给
(kinds:{数据源名: 类型},和工具边界同一套规则,judge._derived);别的列、SQL 里写着的遮罩值按值换成「已遮罩」,
换了写法的号码也认(secrets:报告快照里的遮罩值,同交给查询工具的那一份)。解析 SQL、按值换是纯计算,调用方放进线程。"""
fetch = judging._Fetch(loader)
provenance_on = doc.get("provenance") == DOC_PROVENANCE
queries = {a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == "query"}
@@ -592,7 +597,9 @@ def _excerpts(doc: dict[str, Any], catalog: dict[str, Any], cand: judging.Candid
for alias in cand.aliases:
entry = catalog.get(alias)
if isinstance(entry, dict) and (text := judging._excerpt(alias, entry, [cand], fetch, masked, catalog,
provenance_on=provenance_on)):
provenance_on=provenance_on, kinds=kinds)):
if secrets is not None and secrets.strong:
text = secrets.scrub(text, loose=True)
out.append(mark_original(text, alias, queries))
return out
@@ -1811,7 +1818,6 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict
doc = op.doc
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
masked = await judging.source_masks(catalog, loader=op.loader)
excerpts = _excerpts(doc, catalog, target.cand, op.loader, masked) if target is not None else []
refs = _report_refs(catalog)
wanted = _run_sources(op.events)
usable = await _usable_sources(op.run_id, wanted, op.versions)
@@ -1820,15 +1826,20 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict
async with SessionLocal() as session:
spec = await verify_model_spec(session, None, settings=settings)
# 报告自己的查询快照:被问的那句可能直接写着遮罩列的值,agent 会照着复述;里面的遮罩值在派 agent 之前就交给它的
# 查询工具(masked_values),agent 查到写着同一个号码的备注、别的表时,交给模型之前就换掉
# 查询工具(masked_values),agent 查到写着同一个号码的备注、别的表时,交给模型之前就换掉;证据摘录也按它们先遮
given = [ask.question, ask.quote, *_ref_values(refs)]
others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query"
and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))]
seed = await snapshot_secrets(others, given)
excerpts: list[str] = []
if target is not None:
kinds = await _source_kinds([*masked, *(str(s.get("source") or "") for s in others)])
excerpts = await asyncio.to_thread(_excerpts, doc, catalog, target.cand, op.loader, masked, kinds, seed)
outcome = await run_verify_agent(
spec=spec, system=INQUIRY_SYSTEM, task=_task(op, ask, target, excerpts, refs, live, lost), sources=list(live),
output_schema=INQUIRY_SCHEMA, max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"),
daily_max_usd=settings.get("daily_max_usd"), run_id=op.run_id, data_versions=op.versions,
masked_values=await snapshot_secrets(others, given))
masked_values=seed)
# 遮罩:agent 自己的查询快照(查询时就遮好了,遮掉的值在 outcome.masked_values 里)、报告自己的查询快照
own: dict[str, dict[str, Any]] = {}
+39 -13
View File
@@ -342,6 +342,25 @@ def _hidden(snapshot: dict[str, Any], source: Any, masked: dict[str, Any] | None
return names
def _derived(snapshot: dict[str, Any], hidden: set[str], fetch: _Fetch, kinds: dict[str, Any] | None) -> set[str]:
"""核查 agent 的摘录(kinds 给了:{数据源名: 类型})要遮的列:按列名的(hidden),加上按 SQL 认出改了名字的遮罩列
(buyer_phone AS 手机号、套函数、经子查询转一道……),和核查 agent 的查询在工具边界遮的是同一套规则
(masking._derived_hidden,表结构按快照冻结的那份、经 fetch 取)。认的过程出了意外,整份快照的列都算(宁可多遮)。
kinds 为 None(结论句裁判自己的摘录)照旧只按列名,摘录、复用键都和以前一样。"""
if kinds is None or not hidden:
return hidden
from app.engine.masking import _derived_hidden, _schema_columns # masking 导入了别的引擎模块:用到时再导入
try:
schema = fetch(snapshot.get("schema_artifact")) if snapshot.get("schema_artifact") else None
tables = _schema_columns(schema) if isinstance(schema, dict) else None
kind = kinds.get(str(snapshot.get("source") or ""))
return set(hidden) | _derived_hidden(dict(snapshot), set(hidden), kind, tables)[0]
except Exception: # noqa: BLE001 - 认不准就整份遮:摘录少几列,不让遮罩值交给核查 agent
logger.warning("judge: 按 SQL 认摘录里改了名字的遮罩列时出错,整份快照的列都按遮罩处理", exc_info=True)
return set(hidden) | {str(c).lower() for c in snapshot.get("columns") or []}
def _shown_columns(columns: list[str], cited: Iterable[str]) -> list[str]:
"""摘录给哪几列:被引用的列一律给(超过上限也给),再按原来的顺序用别的列补满到 EXCERPT_COLS 列。
@@ -354,11 +373,13 @@ def _shown_columns(columns: list[str], cited: Iterable[str]) -> list[str]:
def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iterable[str], whole: bool,
fetch: _Fetch, masked: dict[str, Any] | None, *, provenance_on: bool = False) -> str | None:
fetch: _Fetch, masked: dict[str, Any] | None, *, provenance_on: bool = False,
kinds: dict[str, Any] | None = None) -> str | None:
"""一条查询的摘录:头、SQL、(期 4)来历行、列、被引用的行。
provenance_on:文档带期 4 标记时为真(prepare 算)。这时上传表格的查询快照(有 data_version)在 SQL 之后、「列:」
之前多几行来历(_provenance_lines);其余情况一个字都不多,期 4 之前的文档、手工源的摘录和以前逐字相同。
kinds:给核查 agent 的摘录才给,改了名字的遮罩列也不给(_derived)。
"""
snapshot = fetch(entry.get("artifact"))
if not isinstance(snapshot, dict) or not isinstance(snapshot.get("rows"), list):
@@ -372,7 +393,7 @@ def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iter
lines = [head, f"SQL:{sql[:SQL_CHARS]}{'…' if len(sql) > SQL_CHARS else ''}"]
if snapshot.get("source") == MERGE_SOURCE:
lines.append(_merge_line(snapshot))
hidden = _hidden(snapshot, source, masked)
hidden = _derived(snapshot, _hidden(snapshot, source, masked), fetch, kinds)
if provenance_on and snapshot.get("data_version"):
lines += _provenance_lines(snapshot, fetch, hidden)
columns = [str(c) for c in snapshot.get("columns") or [] if str(c).lower() not in hidden]
@@ -483,7 +504,7 @@ def _cell_words(locator: dict[str, Any], hidden: set[str]) -> str:
def _metric_inputs(metric: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any],
masked: dict[str, Any] | None) -> list[str]:
masked: dict[str, Any] | None, kinds: dict[str, Any] | None = None) -> list[str]:
"""指标的输入取自哪次查询、哪一格,再附上那次查询的 SQL(截到 SQL_CHARS,最多 ENTITY_QUERIES 次)。
「活跃用户按会员号去重」这种讲口径的句子,证据在取数的 SQL 里;只给原式,裁判只能判证据不足。
@@ -504,8 +525,8 @@ def _metric_inputs(metric: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any
snapshot = fetch(artifact)
snap = snapshot if isinstance(snapshot, dict) else {}
source = (catalog.get(alias) or {}).get("source") if alias else None
hidden = _source_hidden(source or snap.get("source"), catalog, fetch, masked) \
| _mask_names(snap.get("mask_columns"))
hidden = _derived(snap, _source_hidden(source or snap.get("source"), catalog, fetch, masked)
| _mask_names(snap.get("mask_columns")), fetch, kinds)
label = f"查询 {alias} " if alias else "一次查询(不在这份报告的证据目录中)"
lines.append(f"输入 {path}:取自{label}的 {_cell_words(item.get('locator') or {}, hidden)}")
sql = _clean(str(snap.get("sql") or ""))
@@ -518,7 +539,7 @@ def _metric_inputs(metric: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any
def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any],
masked: dict[str, Any] | None) -> str:
masked: dict[str, Any] | None, kinds: dict[str, Any] | None = None) -> str:
name = entry.get("name") or (entry.get("locator") or {}).get("metric") or alias
rendered = entry.get("rendered")
where = " ".join(v for v in (f"口径「{entry['caliber']}」" if entry.get("caliber") else "",
@@ -533,7 +554,7 @@ def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch, catalog: d
if metric is not None:
if metric.get("expression"):
lines.append(f"原式:{_clean(str(metric['expression']))}")
lines.extend(_metric_inputs(metric, fetch, catalog, masked))
lines.extend(_metric_inputs(metric, fetch, catalog, masked, kinds))
return "\n".join(lines)
@@ -631,12 +652,13 @@ def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], who
def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _Fetch,
masked: dict[str, Any] | None, catalog: dict[str, Any], *, provenance_on: bool = False) -> str | None:
masked: dict[str, Any] | None, catalog: dict[str, Any], *, provenance_on: bool = False,
kinds: dict[str, Any] | None = None) -> str | None:
"""一件证据的摘录。provenance_on 只交给查询摘录(_query_excerpt):指标摘录里「输入取自查询 Qn」的那几行不加
来历(P4-SPEC 调整 1),表名、字段名的摘录照旧。"""
来历(P4-SPEC 调整 1),表名、字段名的摘录照旧。kinds 只给核查 agent 的摘录(见 _derived)。"""
kind = entry.get("kind")
if kind == "metric":
return _metric_excerpt(alias, entry, fetch, catalog, masked)
return _metric_excerpt(alias, entry, fetch, catalog, masked, kinds)
if kind == "input":
field_name = (entry.get("locator") or {}).get("field") or alias
return f"【{alias}】运行输入:{field_name} = {entry.get('rendered')}"
@@ -644,7 +666,7 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _
rows = {r for c in cands for a, r in c.rows if a == alias}
cols = [col for c in cands for a, col in c.cols if a == alias]
return _query_excerpt(alias, entry, rows, cols, any(alias in c.whole for c in cands), fetch, masked,
provenance_on=provenance_on)
provenance_on=provenance_on, kinds=kinds)
if kind == "retrieval":
picked = [h for c in cands for a, h in c.hits if a == alias]
return _retrieval_excerpt(alias, entry, picked, any(alias in c.whole for c in cands), fetch)
@@ -739,10 +761,14 @@ class JudgeRequest:
def prepare(doc: dict[str, Any], catalog: dict[str, Any] | None = None, *, units: Iterable[str] | None = None,
loader: Callable[[str], Any] | None = None, masked: dict[str, Any] | None = None) -> JudgeRequest:
loader: Callable[[str], Any] | None = None, masked: dict[str, Any] | None = None,
kinds: dict[str, Any] | None = None) -> JudgeRequest:
"""裁判的输入:候选句和证据摘录。纯函数(快照经 loader 取,缺省读工件库),不调模型。
catalog 缺省用文档自己存的目录;masked = {数据源名: 要遮的列},和快照里记下的 mask_columns 合在一起。
kinds = {数据源名: 类型} 只在给核查 agent(反方审稿、审批质询)准备摘录时给:查询摘录、指标输入里改了名字的遮罩列
(buyer_phone AS 手机号)按 SQL 认出来也不给,和核查 agent 的查询在工具边界遮的是同一套规则(_derived)。结论句裁判
自己的摘录不给它,和以前逐字相同。
摘录按文档标记分版本(P4-SPEC 1.4、3.3):带 `provenance: DOC_PROVENANCE` 的文档,上传表格的查询摘录多几行
来历;没有标记的(期 4 之前组装的)和以前逐字相同,复用键、断点指纹都不变,已判过的句子照旧复用。
@@ -755,7 +781,7 @@ def prepare(doc: dict[str, Any], catalog: dict[str, Any] | None = None, *, units
for alias in dict.fromkeys(a for c in cands for a in c.aliases):
entry = catalog.get(alias)
if isinstance(entry, dict) and (text := _excerpt(alias, entry, cands, fetch, masked, catalog,
provenance_on=provenance_on)):
provenance_on=provenance_on, kinds=kinds)):
excerpts[alias] = text
picked = {c.unit for c in cands}
skipped: dict[str, str] = {}
+66 -16
View File
@@ -11,9 +11,10 @@
的 Q1 分开),要它先想清楚「如果这句不成立,数据里会看到什么」,写只读查询去找,最后交结论(经受住 / 被削弱 /
被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来。
遮罩:agent 的查询结果在工具边界就遮好了;这份报告引用的查询快照里的遮罩值(按 SQL 认出改了名字的遮罩列,同审批
质询)反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。摘录按列名遮,改了名字的遮罩列原值
agent 看得到,会照着写进理由:理由正文和理由文档按值换成「已遮罩」(换了写法的号码也认),这句结论原文里写着的
不换(_masked_rationale)
质询)反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。任务里的证据摘录先遮:遮罩列、按 SQL
认出改了名字的遮罩列(buyer_phone AS 手机号)不给,别的列、SQL 里写着的遮罩值按值换(_task)。理由正文和理由文档
仍按值换成「已遮罩」(换了写法的号码也认,agent 查询时遮掉的值也算),这句结论原文里写着的不换(_masked_rationale);
换完还认得出的,这句记为无法判断、理由不记(_kept,同审批质询的最后一道关)
3. 出处:一条查询都没执行成功的,判什么都记为无法判断;判「被推翻」的,理由里还得引用到自己的查询结果——它要让
正式出具降档,审稿记录里得查得到凭什么。不采信时系统写明原因,反方原本的结论和理由照录在后面(纯文字:解析
不了的引用写成「(引用无效)」,裸数字注明「(无出处)」)。这句该查的数据源都不存在或已停用、核查模型本身用
@@ -49,6 +50,8 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from collections.abc import Callable, Collection, Iterable
from dataclasses import replace
@@ -76,8 +79,12 @@ from app.engine.state import GraphState
from app.engine.verify_agent import RATIONALE, VerifyOutcome, run_verify_agent
from app.tools.datasource import run_versions
logger = logging.getLogger(__name__)
#: 四种结论:经受住 / 被削弱 / 被推翻 / 无法判断。counts 的键照这个顺序
VERDICTS = ("stands", "weakened", "refuted", "inconclusive")
#: 理由按值遮之后还认得出遮罩值时,这句的理由换成这段系统说明(_kept)
LEAKED = "反方的理由里有设置了遮罩的值,无法遮住,这句的结论不予采信,记为无法判断"
#: 节点产出、审批卡里最多列几句(审稿记录里一句不少)
ITEMS_MAX = 10
#: 出具缺口、报错里最多点几句的原文,每句最多几个字
@@ -378,6 +385,12 @@ def _claim_of(cand: judging.Candidate, outcome: VerifyOutcome) -> tuple[dict[str
return _claim(cand, verdict, rationale, doc=outcome.rationale, queries=outcome.queries, usage=outcome.usage), None
def _scrubbed_excerpt(text: str, known: Any) -> str:
"""证据摘录里的遮罩值换成「已遮罩」:别的列、SQL 里写着的(换了写法的号码也认),交给核查 agent 之前就换掉。值是
报告快照里的遮罩值去掉这句结论原文里写着的(_spared),同交给查询工具的那一份。"""
return known.scrub(text, loose=True) if known is not None and known.strong else text
def _spared(seed: Any, claim: str) -> Any:
"""报告快照里的遮罩值(seed)去掉这句结论原文里写着的,交给这一句的反查。seed 本身不动(下一句另算)。"""
known = seed.copy()
@@ -391,7 +404,7 @@ def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[s
原文里写着的(given)已经摆在读者眼前,不换。反方 agent 的查询结果在工具边界就遮好了,可证据摘录按列名遮,改了名字
的遮罩列(buyer_phone AS 手机号)的原值它看得到,会写进理由。
换的办法同审批质询的回答(_scrubbed_source):原文里的值先换掉,再照原来的目录重新组装理由文档;引用到的格子按
遮好的快照渲染(文字格里夹着的遮罩值换掉那一截)。理由里认不出遮罩值的原样交回。"""
遮好的快照渲染(文字格里夹着的遮罩值换掉那一截)。理由里认不出遮罩值的原样交回。换完还认得出的过最后一道关(_kept)。"""
from app.engine.evidence import compose_doc
from app.engine.masking import _masked_view, _scrubbed_source, _Secrets
@@ -406,7 +419,7 @@ def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[s
pool.prepare()
source = str(doc.get("source") or "")
if not (pool.spans(str(doc.get("markdown") or ""), prose=True) or pool.spans(source, prose=True)):
return outcome
return _kept(outcome, pool)
views: dict[str, Any] = {}
fetch = _fetcher()
for query in outcome.queries:
@@ -422,8 +435,23 @@ def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[s
except Exception: # noqa: BLE001 - 重新组装出了意外,值也不能留着
# 正文按值换掉,理由文档不要了(界面照正文显示;判「被推翻」的因此查不到出处,按无法判断记)
said = pool.scrub(str(doc.get("markdown") or ""), prose=True)
return replace(outcome, rationale=None, result={**result, RATIONALE: said})
return replace(outcome, rationale=again, result={**result, RATIONALE: again["markdown"]})
return _kept(replace(outcome, rationale=None, result={**result, RATIONALE: said}), pool)
return _kept(replace(outcome, rationale=again, result={**result, RATIONALE: again["markdown"]}), pool)
def _kept(outcome: VerifyOutcome, pool: Any) -> VerifyOutcome:
"""最后一道关(同审批质询的 _leaked):按值遮之后,理由正文、理由文档(正文、原文、目录里的列名)里还认得出遮罩值的
(换了写法的号码也算),这句的结论不采信、理由不记:记为无法判断,理由换成系统的说明(LEAKED)。遮不住的「被推翻」
不能让出具降档,也不能把号码存进审稿记录、随运行封存。"""
doc = outcome.rationale if isinstance(outcome.rationale, dict) else {}
texts = [str((outcome.result or {}).get(RATIONALE) or ""), str(doc.get("markdown") or ""),
str(doc.get("source") or ""),
json.dumps(doc.get("catalog") or {}, ensure_ascii=False, default=str)]
if not any(pool.spans(text, prose=True) for text in texts):
return outcome
logger.warning("反方审稿的理由里有遮罩值遮不住,这句记为无法判断(运行 %s)", doc.get("run_id"))
return replace(outcome, rationale=None, result={**(outcome.result or {}), "verdict": "inconclusive",
RATIONALE: LEAKED})
def _cites(doc: dict[str, Any] | None) -> bool:
@@ -573,6 +601,8 @@ class _Review:
max_claims, max_queries, timeout_s, daily_max_usd
self.spec, self.masks, self.writer_model = spec, masks, writer_model
self.fetch = _fetcher()
#: 报告用到的数据源的类型(摘录按 SQL 认改了名字的遮罩列时按它的方言解析)。run() 里查好
self.kinds: dict[str, Any] = {}
#: 这一段执行的总时长(engine/deadline.py):有句子的时长按剩余时间收紧过、或者不再开始时记下,说明和日志里写
self.run_seconds: float | None = None
#: 这份报告目录里的查询编号:摘录里指向它们的写法标上「原报告」(mark_original)
@@ -597,13 +627,20 @@ class _Review:
"""当天的核查金额用完、这句一次模型都没调用时的理由:派出去就撞上的和后面不再派出的,是同一回事。"""
return f"已达上限({self._limit_words('daily_max_usd')}),这句未反查"
def _task(self, cand: judging.Candidate, sources: list[str], lost: list[str], *, own: bool) -> str:
"""交给核查 agent 的任务:这句结论、它挂的证据摘录(遮罩照旧,原报告的编号标明,见 mark_original)、可以查的
数据源;该查的源有停用了的,也告诉它查不了哪几个,免得它把查不到当成没有反例。own:源是这句自己引用的,
不是没挂依据、按整份报告用到的源查的。"""
request = judging.prepare(self.doc, self.catalog, units=[cand.unit], loader=self.fetch, masked=self.masks)
def _task(self, cand: judging.Candidate, sources: list[str], lost: list[str], *, own: bool,
known: Any = None) -> str:
"""交给核查 agent 的任务:这句结论、它挂的证据摘录(原报告的编号标明,见 mark_original)、可以查的数据源;该查的
源有停用了的,也告诉它查不了哪几个,免得它把查不到当成没有反例。own:源是这句自己引用的,不是没挂依据、按整份
报告用到的源查的。
摘录先遮,模型从任务里也看不到遮罩值:遮罩列不给,改了名字的(buyer_phone AS 手机号)按 SQL 认出来也不给
(judge.prepare 的 kinds,和工具边界同一套规则);别的列、SQL 里写着的遮罩值按值换成「已遮罩」,换了写法的号码
也认(known:报告快照里的遮罩值去掉这句结论原文里写着的,同交给查询工具的那一份)。解析 SQL、按值换是纯计算,
在线程里做。"""
request = judging.prepare(self.doc, self.catalog, units=[cand.unit], loader=self.fetch, masked=self.masks,
kinds=self.kinds)
aliases = dict.fromkeys(a for c in request.cands for a in c.aliases if a in request.excerpts)
evidence = "\n\n".join(mark_original(request.excerpts[a], a, self.queries) for a in aliases)
evidence = "\n\n".join(mark_original(_scrubbed_excerpt(request.excerpts[a], known), a, self.queries)
for a in aliases)
gone = f"({'这句还引用了' if own else '这份报告还用到了'}{_quoted(lost)},已不存在或已停用,无法查询)" \
if lost else ""
return CHALLENGE_TASK_PROMPT.format(
@@ -620,6 +657,7 @@ class _Review:
# 这句没挂依据时在整份报告引用的数据源里查:写作者看得到的就是这几个库
everywhere = claim_sources(self.catalog, self.catalog, self.fetch)
usable = await _usable_sources(everywhere)
self.kinds = await self._kinds(everywhere)
# 报告引用的查询快照里遮罩列的值:反查之前交给查询工具(agent 查到写着同一个号码的别的表时看不到),反查之后
# 据此换掉理由里的遮罩值(_masked_rationale)
seed = await self._seed()
@@ -653,12 +691,15 @@ class _Review:
claims.append(_claim(cand, "inconclusive", late[0]))
overdue += 1
continue
# 这句结论原文里写着的值已经摆在读者眼前,不当遮罩值。上万个值过一遍要几十毫秒,放进线程
known = await asyncio.to_thread(_spared, seed, cand.text)
try:
own = claim_sources(cand.aliases, self.catalog, self.fetch)
sources = own or everywhere
live = [s for s in sources if s in usable]
lost = [s for s in sources if s not in usable]
task_text = self._task(cand, live, lost, own=bool(own)) if live else ""
task_text = await asyncio.to_thread(self._task, cand, live, lost, own=bool(own), known=known) \
if live else ""
except Exception as e: # noqa: BLE001 - 一句的摘录出了意外(坏形状的工件),只让这一句没审成
why = f"整理证据摘录时出错({describe_exception(e)})"
unreached(cand, f"反查未完成:{why}", why)
@@ -686,8 +727,6 @@ class _Review:
if allot.tightened:
self.run_seconds = allot.seconds
# 收紧过的:核查 agent 到点自己发的 verify_limit 改说运行时长上限将至(deadline.relabel),不写收紧后的秒数
# 这句结论原文里写着的值已经摆在读者眼前,不当遮罩值。上万个值过一遍要几十毫秒,放进线程
known = await asyncio.to_thread(_spared, seed, cand.text)
outcome = await run_verify_agent(
spec=self.spec, system=CHALLENGE_SYSTEM, task=task_text, sources=live,
output_schema=CHALLENGE_SCHEMA, max_queries=self.max_queries, timeout_s=allot.timeout_s,
@@ -777,6 +816,17 @@ class _Review:
return await snapshot_secrets(self.fetch(entry.get("artifact")) for entry in self.catalog.values()
if isinstance(entry, dict) and entry.get("kind") == "query")
async def _kinds(self, names: Iterable[str]) -> dict[str, Any]:
"""这几个数据源的类型(摘录按 SQL 认改了名字的遮罩列时用)。快照记的数据源也算上(旧台账条目没记数据源)。"""
from app.engine.inquiry import _source_kinds # inquiry 导入了这个模块,用到时再导入
wanted = set(names)
for entry in self.catalog.values():
snapshot = self.fetch(entry.get("artifact")) if isinstance(entry, dict) else None
if isinstance(snapshot, dict) and snapshot.get("source"):
wanted.add(str(snapshot["source"]))
return await _source_kinds(wanted)
async def _store(self, record: dict[str, Any]) -> str | None:
try:
return await artifact_store.put_json(record, kind="challenge", run_id=self.ctx.run.run_id,
+120
View File
@@ -0,0 +1,120 @@
"""反方审稿的遮罩收口(engine/nodes/challenge.py):
- 交给反方 agent 的证据摘录先遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录里不给这一列(按 SQL
认,和工具边界同一套规则);别的列(备注里换了写法的号码)、SQL 里写着的遮罩值换成「已遮罩」。以前摘录只按列名遮,
四个号码原样交给模型,模型照抄进理由
- 理由正文按值遮(报告快照里的值、agent 查询时遮掉的值、换了写法的号码),端到端;组装之后还认得出遮罩值的(第二道
防线漏了),整句降为无法判断,理由不记,同审批质询的最后一道关
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
import pytest
from app.core import artifact_store
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.engine import masking
from app.engine.nodes import challenge as challenge_mod
from tests.test_challenge_node import Models, Play, engine_up, finish, graph, output_of # noqa: F401 - engine_up 是自动用上的夹具
#: 四笔订单,买家手机号设了遮罩;备注没设遮罩,其中一条写着换了写法的号码
SCRIPT = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, refunded INTEGER,"
" note TEXT);"
"INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0, '客户 139-0000-0002 代下单'),"
" (2, '华东', '13900000002', 300, 1, ''), (3, '华南', '13900000003', 900, 0, '例行回访'),"
" (4, '华北', '13900000004', 400, 0, '');")
SECRETS = ("1390000000", "0000-0002", "139 0000")
@pytest.fixture
async def masked_shop(tmp_path):
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(SCRIPT)
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=True, enabled=True,
options={"mask_columns": ["buyer_phone"]})
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)
def _clean(text: str) -> bool:
return not any(s in text for s in SECRETS)
#: 报告取数:手机号改了名字选出来,备注照选,按号码排序时把号码写进了 SQL
REPORT_SQL = ("SELECT buyer_phone AS 手机号, note AS 备注, amount AS 金额 FROM sales "
"ORDER BY buyer_phone = '13900000001' DESC, amount DESC")
WRITER = ("金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]",)
CLAIM = "金额最高的一笔"
async def test_the_excerpt_in_the_task_is_masked_before_the_agent_sees_it(monkeypatch, masked_shop):
"""反方 agent 收到的任务里:改了名字的遮罩列(手机号)不给,备注里换了写法的号码、SQL 里写着的号码换成「已遮罩」,
金额照常。以前「列:金额 | 手机号」「r0:1,200 | 13900000001」…,四个号码都在。"""
name = masked_shop
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
models = Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
assert row.status == "succeeded", row.error
task = models.tasks()[CLAIM]
assert "金额最高的一笔 1,200。" in task and "【原报告 Q1】" in task
excerpt = task[task.index("【原报告 Q1】"):]
assert _clean(excerpt), excerpt
columns = next(line for line in excerpt.splitlines() if line.startswith("列:"))
assert "手机号" not in columns and "金额" in columns and "备注" in columns
assert "客户 已遮罩 代下单" in excerpt and "buyer_phone = '已遮罩'" in excerpt
assert "(有的列按数据源的设置遮罩了,没有给出)" in excerpt
async def test_masked_values_the_agent_found_itself_are_masked_in_the_rationale(monkeypatch, masked_shop):
"""端到端:反方 agent 自己查出的号码(查询时在工具边界遮掉、只在内存里)被它换着写法写进理由:审稿记录里的理由正文、
理由文档都换成「已遮罩」。报告没有选出号码,值只来自 agent 查询时遮掉的那一份。"""
name = masked_shop
top = "SELECT buyer_phone AS 电话, amount AS 金额 FROM sales WHERE region = '华南'"
said = "华南那笔来自 +86 139 0000 0003,金额 [[v:Q1.r0.金额]]。[[see:Q1]]"
plays = {CLAIM: Play([top], "stands", said)}
Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql="SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY amount DESC"),
run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
assert claim["verdict"] == "stands" and claim["rationale"] == "华南那笔来自 已遮罩,金额 900。"
assert claim["rationale_doc"]["markdown"] == claim["rationale"]
assert "0000 0003" not in json.dumps(record, ensure_ascii=False)
async def test_a_rationale_still_showing_a_masked_value_is_not_kept(monkeypatch, masked_shop):
"""最后一道关:按值遮之后组装出来的理由里还认得出遮罩值(这里让按值换失手),这句降为无法判断,理由不记、不存,
原值不进审稿记录。同审批质询的回答。"""
name = masked_shop
monkeypatch.setattr(masking, "_scrubbed_source", lambda text, secrets: text)
said = "号码 13900000001 的那笔属实,金额 [[v:Q1.r0.笔数]]。[[see:Q1]]"
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "refuted", said)}
Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
assert claim["verdict"] == "inconclusive" and claim["rationale_doc"] is None
assert claim["rationale"] == challenge_mod.LEAKED
assert _clean(json.dumps(record, ensure_ascii=False))
out = await output_of(row.id, "chal")
assert out["counts"]["refuted"] == 0, "遮不住的理由不能让出具降档"
+28 -1
View File
@@ -14,7 +14,7 @@ import json
import pytest
from app.core import artifact_store
from tests.test_inquiry_api import ( # noqa: F401 - engine_up 是自动用上的夹具,client、big_shop 是夹具
from tests.test_inquiry_api import ( # noqa: F401 - engine_up 是自动用上的夹具,client、big_shop、masked_shop 是夹具
AMOUNTS,
Models,
Play,
@@ -24,8 +24,11 @@ from tests.test_inquiry_api import ( # noqa: F401 - engine_up 是自动用上
engine_up,
events,
fetching,
masked_shop,
pause,
pending,
report_doc,
unit_of,
)
BAND = "CASE WHEN amount >= 20000 THEN '大单' ELSE '小单' END"
@@ -61,3 +64,27 @@ async def test_a_band_grouped_outside_its_derived_source_reads_as_written(monkey
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
assert got.status_code == 200, got.text
assert (got.json()["content"]["rows"], got.json()["masked"]) == ([["大单", 3], ["小单", 2]], [])
#: 报告取数:手机号改了名字选出来,按号码排序时把号码写进了 SQL
RENAMED = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY buyer_phone = '13900000001' DESC, amount DESC"
async def test_the_excerpt_in_the_task_is_masked_before_the_agent_sees_it(monkeypatch, masked_shop, client):
"""针对「金额最高的一笔 1,200。」发起质询:任务里这句挂的证据摘录不给改了名字的遮罩列(手机号),SQL 里写着的号码
换成「已遮罩」,金额照常。以前「列:金额 | 手机号」「r0:1,200 | 13900000001」…,四个号码都在。"""
models = Models(monkeypatch, masked_shop, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]",
plays={"这笔是谁买的": Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")})
paused = await pause(masked_shop, graph=fetching(masked_shop, RENAMED))
[approval] = await pending(paused.id)
doc = await report_doc(paused.id)
r = await ask(client, paused.id, report="write", unit=unit_of(doc, "金额最高的一笔 1,200。"),
question="这笔是谁买的?", approval_id=approval.id)
assert r.status_code == 200, r.text
[task] = models.tasks
excerpt = task[task.index("【原报告 Q1】"):]
assert "1390000000" not in task, excerpt
columns = next(line for line in excerpt.splitlines() if line.startswith("列:"))
assert "手机号" not in columns and "金额" in columns
assert "buyer_phone = '已遮罩'" in excerpt and "(有的列按数据源的设置遮罩了,没有给出)" in excerpt
assert r.json()["inquiry"]["answer"]["ok"] is True