mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:31:58 +00:00
60be16a0bb
渲染(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 万份和基线比没有新并成的数,流式逐字、随机分块都等于整篇
渲染。
244 lines
14 KiB
Python
244 lines
14 KiB
Python
"""核查 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()]
|