Files
AgentLab/backend/tests/test_verify_rationale_rules.py
ThearchyHelios 60be16a0bb M1 复核修正(第 3 轮):依据两边什么都没有时也判粘连,两个数不再并成一个;只装着依据的括号认【】[]〔〕[];右边紧贴着下文的半角逗号后面补空格
渲染(evidence._tidy):
- 上一轮只沿着去掉的字往两边找粘连:依据前后没有空白、括号、标点时没有切口,「华东 [[v:Q1.r0.销售额]][[see:Q1]]
  [[v:Q1.r0.订单数]] 笔」渲染、组装都是「4,919.5212 笔」,「[[v:…]][[see:Q1]]2 月」是「4,919.52 月」,「**华东 [[v:…]]**
  [[see:Q1]]2 月」组装出「华东 4,919.52 月」,verify_doc 都不报。现在每个依据(连着写的一串、只装着依据的括号都算)
  两边都判(_glue 看所有不显示的字,不只看切口),会读成一串的补一个空格:这一片有去掉的字的照旧换掉头一个,没有的
  插在头一个依据前面(粗体收尾的星号外面)
- 只装着依据的括号认【】、[](全角半角混用也算)、〔〕、[],和()一样连括号去掉,外面悬着的停顿标点照原规则收拾;
  依据在这些括号开头、末尾紧挨着分隔标点的,同()一样收拾。核查 agent 拿到的查询结果以「【证据 Qn】」开头,模型照着
  写成「【[[see:Q1]]】」,以前渲染成「【】」
- 右边是写作者紧贴着下文写的半角逗号的(「1,200([[see:Q1]]),900」去掉括号读成「1,200,900」),空格补在逗号后面
  (同「212,[[see:Q1]],5」→「212, 5」)。上一轮把这一类当成可以不管的「),5」写法;和基线 58a6759 逐份比,结构化组合里
  有 2481 份是上一轮去掉括号之后才并成一个数的(「增长 12([[see:Q1]]),212 笔」→「增长 12,212 笔」),现在都隔开了
- 找挨上的字时也越过删除线的两个波浪线:前端照样画出删去的数,挨着后面的数读
- 流式渲染把这几种括号、半角方括号、波浪线也攒到后面的字来了再放;半角「]」攒着时,标记收尾的「]]」和标记里末尾的
  空白不算,标记整个放出去,从它后面攒起

不含依据的文档渲染结果、内容哈希逐字不变:随机 1.6 万份不含依据的文档和基线逐份比过,BEFORE 两份哈希照旧。

测试:依据两边什么都没有(数、标记、粗体、斜体、删除线、连着写的一串)、几种括号(只装着依据、全角半角混用、套着、
和别的括号连着写、开头末尾带标点、不含依据的不动)、半角逗号的用例;结构化组合加上两种括号,中间只有依据的也算;流式
渲染、随机语料加上几种括号和波浪线;审批质询、核查 agent、反方审稿、双盲复算各补一个端到端用例。15 处改动逐一去掉实现,
相关测试都变红(第 2 轮的 7 处也重跑过,照样变红)。另在测试之外:复核用的抽查脚本 6000 份 × 6 个种子只剩写作者自己
写成「212,,2」的一份;结构化组合 29.4 万份、随机含依据的 1.6 万份和基线比没有新并成的数,流式逐字、随机分块都等于整篇
渲染。
2026-10-12 12:24:14 +08:00

244 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""核查 agent 交理由的写法和收尾超时记的上限(期 5 真实模型验收 M1)。
- 交结果的提示(VERIFY_FINAL_PROMPT,双盲复算、反方审稿、审批质询共用)和写作者一致:依据写在句末,不放进括号或句子
中间。以前写的是「提到某次查询时写 [[see:Q1]]」,模型就写成「结果一致([[see:Q3]],原报告…)」,反方审稿的系统提示
自己也把它写在括号里(「([[v:Qn.rX.列名]] 或 [[see:Qn]])」)
- 写进了括号的依据,理由文档里照样渲染干净(evidence._tidy),这句仍挂着那条依据
- 模型自己停了工具调用、收尾那次调用超过了时长上限:limit 照实记 timeout_s。以前是 None——质询的报错写着「回答超过
时长上限(180 秒)」,limit 却是空的,界面上缺了「已达时长上限」。不走结构化输出的模型(普通调用收尾)也一样
- 交回的理由是空的、只有标点,或者只是一个占位词(字面的 placeholder、N/A):当作没给理由,不把占位词当理由展示。
按渲染出来的正文判:只写了依据的([[see:Q1]]、「([[see:Q1]])」)显示出来是空的,占位词后面挂着依据的显示出来只剩
占位词,都算没给。理由就是结果本身时(审批质询,结构里只有理由一项),「无」「none」是回答,不算占位
- 依据写在「依据」「见」「取自」这类引出语后面(渲染时去不掉,引出语悬空):记一条日志,验收时查得到
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,数字都是虚构的。
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import pytest
from app.engine import verify_agent
from app.engine.evidence import iter_units
from app.engine.inquiry import INQUIRY_SCHEMA, INQUIRY_SYSTEM, INQUIRY_TASK_PROMPT
from app.engine.nodes.challenge import CHALLENGE_SYSTEM, CHALLENGE_TASK_PROMPT
from app.engine.nodes.dual_check import BLIND_SYSTEM, BLIND_TASK_PROMPT
from app.engine.verify_agent import VERIFY_FINAL_PROMPT, VERIFY_SYSTEM
from app.providers.mock_model import MockChatModel
from tests.test_verify_agent import VALID_SUM, Script, clean_spend, q, run, shop # noqa: F401 - 夹具按名字引入
#: 括号里写着依据标记:「([[v:Qn.rX.列名]] 或 [[see:Qn]])」「([[see:Q1]])」这种示范
_BRACKETED_SEE = re.compile(r"[((][^()()]*\[\[see:[^\]]*\]\][^()()]*[))]")
FOUND = {"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致"}
def slow_final(monkeypatch, seconds: float = 5.0) -> None:
"""交结果那次调用(消息里带着 VERIFY_FINAL_PROMPT:结构化输出、退回普通调用都是)迟迟不返回,查库的几轮照常。"""
original = MockChatModel._agenerate
async def held(chat, messages, stop=None, run_manager=None, **kwargs):
if any(VERIFY_FINAL_PROMPT in str(m.content) for m in messages):
await asyncio.sleep(seconds)
return await original(chat, messages, stop, run_manager, **kwargs)
monkeypatch.setattr(MockChatModel, "_agenerate", held)
# --------------------------------------------------------------------------
# 提示词
# --------------------------------------------------------------------------
def test_the_final_prompt_puts_see_at_the_end_of_the_sentence():
assert "提到某次查询时写 [[see:Q1]]" not in VERIFY_FINAL_PROMPT
assert "句末" in VERIFY_FINAL_PROMPT and "括号" in VERIFY_FINAL_PROMPT
assert re.search(r"。\[\[see:Q\d\]\]", VERIFY_FINAL_PROMPT), "示范的写法:依据挂在句号后面"
assert "渲染后不显示" in VERIFY_FINAL_PROMPT
@pytest.mark.parametrize("prompt", [VERIFY_SYSTEM, VERIFY_FINAL_PROMPT, CHALLENGE_SYSTEM, CHALLENGE_TASK_PROMPT,
INQUIRY_SYSTEM, INQUIRY_TASK_PROMPT, BLIND_SYSTEM, BLIND_TASK_PROMPT])
def test_no_verify_prompt_shows_see_inside_brackets(prompt):
assert not _BRACKETED_SEE.search(prompt), _BRACKETED_SEE.search(prompt).group(0)
async def test_the_final_call_is_told_the_rule(monkeypatch, shop):
name, _ = shop
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": "合计 [[v:Q1.r0.total]]。[[see:Q1]]"})
await run(name)
kind, messages = script.calls[-1]
assert kind == "final" and VERIFY_FINAL_PROMPT in str(messages[-1].content)
# --------------------------------------------------------------------------
# 写进括号的依据
# --------------------------------------------------------------------------
async def test_see_written_in_brackets_renders_clean_in_the_rationale(monkeypatch, shop):
name, _ = shop
said = "未作废订单的金额合计是 [[v:Q1.r0.total]]([[see:Q1]]),口径一致([[see:Q1]],已排除作废单)。"
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": said})
out = await run(name)
shown = "未作废订单的金额合计是 230,口径一致(已排除作废单)。"
assert out.result["rationale"] == shown and out.rationale["markdown"] == shown
assert out.rationale["source"] == said, "原文照存,依据标记还在"
[unit] = [unit for _, unit in iter_units(out.rationale)]
assert [s["alias"] for s in unit["see"]] == ["Q1", "Q1"] and unit["cites"] == ["Q1"]
assert out.rationale["violations"] == []
async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch, shop):
"""两个只装着依据的括号连着写、数字包着粗体(复核第 2 轮):去掉括号之后两个数不能挨成一个(「2303 笔」),句末前
悬着的逗号也去掉。"""
name, _ = shop
said = ("未作废订单合计 **[[v:Q1.r0.total]]**([[see:Q1]])([[see:Q2]])[[v:Q2.r0.n]] 笔都已核对,口径一致,"
"([[see:Q1]])([[see:Q2]])。")
count = "SELECT COUNT(*) AS n FROM orders WHERE is_deleted = 0"
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, count, "c2")], {**FOUND, "rationale": said})
out = await run(name)
shown = "未作废订单合计 230 3 笔都已核对,口径一致。"
assert out.result["rationale"] == shown and out.rationale["markdown"] == shown
[unit] = [unit for _, unit in iter_units(out.rationale)]
assert [s["alias"] for s in unit["see"]] == ["Q1", "Q2", "Q1", "Q2"]
numbers = [seg for seg in unit["segments"] if seg.get("ref")]
assert [(seg["text"], bool(seg.get("strong"))) for seg in numbers] == [("230", True), ("3", False)]
assert out.rationale["violations"] == []
async def test_a_bare_see_between_two_numbers_and_other_brackets_render_clean(monkeypatch, shop):
"""依据直接夹在两个数之间(两边什么都没有)、写进方括号(复核第 3 轮):两个数不能挨成一个(「2303 笔」),括号不能
剩下「[]」;两条依据照样挂在句子上。"""
name, _ = shop
said = "未作废订单合计 [[v:Q1.r0.total]][[see:Q1]][[v:Q2.r0.n]] 笔都已核对[[[see:Q2]]]。"
count = "SELECT COUNT(*) AS n FROM orders WHERE is_deleted = 0"
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, count, "c2")], {**FOUND, "rationale": said})
out = await run(name)
shown = "未作废订单合计 230 3 笔都已核对。"
assert out.result["rationale"] == shown and out.rationale["markdown"] == shown
[unit] = [unit for _, unit in iter_units(out.rationale)]
assert [s["alias"] for s in unit["see"]] == ["Q1", "Q2"]
assert out.rationale["violations"] == []
# --------------------------------------------------------------------------
# 收尾超时
# --------------------------------------------------------------------------
@pytest.mark.parametrize("structured", [True, False], ids=["structured", "plain"])
async def test_a_final_call_that_runs_out_of_time_records_the_time_limit(monkeypatch, shop, structured):
"""模型查了一次就自己停下(没撞上任何上限),交结果那次调用却一直不返回:到点放弃,limit 是 timeout_s,和原因一致。"""
name, _ = shop
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.3)
script = Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": "合计 [[v:Q1.r0.total]]。"})
slow_final(monkeypatch)
if not structured:
def refuse(self, schema, **kwargs):
raise NotImplementedError("不支持结构化输出")
monkeypatch.setattr(MockChatModel, "with_structured_output", refuse)
started = time.monotonic()
out = await run(name, timeout_s=0.5)
assert time.monotonic() - started < 3.0, "收尾那次只等到宽限结束"
assert script.kinds == ["step", "step"], "查了一次、自己停下,交结果那次没等到回复"
assert out.answered is True and [qd["alias"] for qd in out.queries] == ["Q1"]
assert out.result is None and out.error == "收尾时超过时长上限,没能交结果"
assert out.limit == "timeout_s"
async def test_a_final_call_that_runs_out_of_time_after_the_query_limit_records_the_time_limit(monkeypatch, shop):
"""循环先撞上查询次数上限、收尾又超时:没交成结果是因为超时,limit 记 timeout_s,和原因一致——记成查询次数上限的话,
质询会显示「未能回答(已达查询次数上限):回答超过时长上限…」,双盲复算会说盲算查满了次数(查满了照样能交结果)。
金额被拒的收尾照旧报循环里先撞上的那个(见 test_verify_truncation)。"""
name, _ = shop
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.3)
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, "SELECT COUNT(*) AS n FROM orders", "c2")],
{**FOUND, "rationale": "x"})
slow_final(monkeypatch)
out = await run(name, timeout_s=0.5, max_queries=1)
assert [qd["alias"] for qd in out.queries] == ["Q1"], "查满了一次"
assert out.result is None and out.error == "收尾时超过时长上限,没能交结果"
assert out.limit == "timeout_s"
# --------------------------------------------------------------------------
# 占位词当理由
# --------------------------------------------------------------------------
@pytest.mark.parametrize("said", ["placeholder", "PLACEHOLDER", " ", "N/A", "(略)", "…", "<rationale>", "TODO", "无",
"暂无。", "None"])
async def test_a_placeholder_rationale_counts_as_none(monkeypatch, shop, said):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": said})
out = await run(name)
assert out.result["value"] == 230, "数照样取"
assert out.result["rationale"] == "" and out.rationale is None
@pytest.mark.parametrize("said", ["[[see:Q1]]", "([[see:Q1]])", " [[see:Q1]]。", "placeholder。[[see:Q1]]",
"N/A([[see:Q1]])", "TODO [[see:Q1]]"])
async def test_a_rationale_that_shows_nothing_counts_as_none(monkeypatch, shop, said):
"""只写了依据、或者占位词后面挂着依据:原文里看着有字,显示出来没有理由。以前按原文判,标记里的字(see、Q1)也算,
理由文档照样交回——审批卡按文档画出一片空白,判「被推翻」的还因为引用到了 Q1 被当成有出处。"""
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": said})
out = await run(name)
assert out.result["value"] == 230, "数照样取"
assert out.result["rationale"] == "" and out.rationale is None
@pytest.mark.parametrize("said", ["无异常。", "见 [[v:Q1.r0.total]]。", "略高于上周。", "[[v:Q1.r0.total]]",
"一致。[[see:Q1]]"])
async def test_a_short_real_rationale_is_kept(monkeypatch, shop, said):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": said})
out = await run(name)
assert out.result["rationale"] and out.rationale is not None
@pytest.mark.parametrize(("said", "shown"), [("无。", "无。"), ("None.", "None."), ("暂无。[[see:Q1]]", "暂无。")])
async def test_nothing_is_an_answer_when_the_rationale_is_the_answer(monkeypatch, shop, said, shown):
"""结构里只有理由一项(审批质询):理由就是回答。问「有没有测试账号」,答「无。」是一个回答,不能当成没给、让提问人
重新提问再花一次钱。"""
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"rationale": said})
out = await run(name, output_schema=INQUIRY_SCHEMA)
assert out.result["rationale"] == shown and out.rationale["markdown"] == shown
@pytest.mark.parametrize("said", ["placeholder", "null", "[[see:Q1]]", "N/A。[[see:Q1]]"])
async def test_a_placeholder_answer_still_counts_as_none(monkeypatch, shop, said):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {"rationale": said})
out = await run(name, output_schema=INQUIRY_SCHEMA)
assert out.result["rationale"] == "" and out.rationale is None
# --------------------------------------------------------------------------
# 依据写在引出语后面
# --------------------------------------------------------------------------
async def test_see_after_a_lead_in_word_is_logged(monkeypatch, shop, caplog):
"""「依据 [[see:Q1]]。」「(来源:[[see:Q1]])」渲染后引出语悬空,渲染时去不掉(见 test_evidence_see_brackets 的已知
限制):记一条日志,写明是哪几处,验收时查得到。只记标记和引出语,不记数。"""
name, _ = shop
said = "合计 [[v:Q1.r0.total]],依据 [[see:Q1]]。口径一致(来源:[[see:Q1]])。"
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": said})
with caplog.at_level(logging.WARNING, logger="app.engine.verify_agent"):
out = await run(name, node_id="chal", run_id="run-1")
assert out.rationale is not None, "照常交回"
[record] = [r for r in caplog.records if "引出语" in r.getMessage()]
message = record.getMessage()
assert "依据 [[see:Q1]]" in message and "来源:[[see:Q1]]" in message and "run-1" in message
assert "230" not in message
async def test_see_at_the_end_of_the_sentence_is_not_logged(monkeypatch, shop, caplog):
name, _ = shop
Script(monkeypatch, [q(name, VALID_SUM, "c1")], {**FOUND, "rationale": "合计 [[v:Q1.r0.total]],结果一致。[[see:Q1]]"})
with caplog.at_level(logging.WARNING, logger="app.engine.verify_agent"):
await run(name)
assert not [r for r in caplog.records if "引出语" in r.getMessage()]