合入期 5 验收修复 M1:核查 agent 正文里的依据标记不再断句——只装着依据的括号(含【】[]〔〕[])连同里面的停顿标点一起去掉,去掉之后按真正挨上的两个字补空格,两个数不再并成一个;核查 agent 的提示词改成依据写在句末;收尾超时照实记 timeout_s;没给理由时照实说没有给出理由

This commit is contained in:
ThearchyHelios
2026-10-12 13:12:20 +08:00
9 changed files with 1586 additions and 34 deletions
@@ -0,0 +1,115 @@
"""反方审稿的理由:依据写进括号不再断句,占位词不当理由(期 5 真实模型验收 M1)。
真实模型验收里,反方的理由「先按订单状态做全量概览核对口径([[see:Q1]]):」「复算写作者的原查询(同 SQL 同过滤条件,
[[see:Q2]])」渲染成「():」「(同 SQL 同过滤条件,)」:审稿记录(rationale 和 rationale_doc)随正式运行封存,审批卡上
照片段原文显示。
模型一律是 MockChatModel 剧本,区域、金额都是虚构的。
"""
from __future__ import annotations
from app.core import artifact_store
from tests.test_challenge_node import ( # noqa: F401 - 夹具按名字引入
NET,
REFUNDS,
Models,
Play,
engine_up,
finish,
graph,
output_of,
shop,
)
BROKEN = ("()", "(,", ",)", "()", "(,", ",)")
async def _record(monkeypatch, shop, plays) -> tuple[dict, dict]:
Models(monkeypatch, shop, plays=plays)
row = await finish(graph(shop))
assert row.status == "succeeded", row.error
out = await output_of(row.id, "chal")
return artifact_store.load(out["challenge_artifact"]), row.output["_issuance"]
async def test_see_in_brackets_does_not_break_the_rationale(monkeypatch, shop):
plays = {
"华南销售额超过华东": Play([NET], "refuted", "扣除退款后华南为 [[v:Q1.r1.净销售额]]([[see:Q1]]),"
"仍低于华东的 [[v:Q1.r0.净销售额]](同口径,[[see:Q1]])。"),
"居各区域之首": Play([REFUNDS], "weakened", "先核对退款口径([[see:Q1]]):华东含 [[v:Q1.r0.退款笔数]] 笔退款"
"([[see:Q1]],扣除后仍居首)。"),
}
record, issuance = await _record(monkeypatch, shop, plays)
first, second = record["claims"]
assert first["rationale"] == "先核对退款口径:华东含 1 笔退款(扣除后仍居首)。"
assert second["rationale"] == "扣除退款后华南为 900,仍低于华东的 1,200(同口径)。"
for claim in (first, second):
doc = claim["rationale_doc"]
assert doc["markdown"] == claim["rationale"] and doc["violations"] == []
assert not any(mark in claim["rationale"] for mark in BROKEN)
# 括号里的依据仍挂在句子上:判「被推翻」的引用到了自己的查询,照常采信、出具降档
assert second["verdict"] == "refuted" and issuance["tier"] == "degraded"
async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch, shop):
"""两个只装着依据的括号连着写、数字包着粗体(复核第 2 轮):去掉之后两个数不能挨成一个(「9001,200」),句末前悬着的
逗号也去掉。"""
plays = {
"华南销售额超过华东": Play([NET], "refuted", "扣除退款后华南、华东分别为 **[[v:Q1.r1.净销售额]]**([[see:Q1]])"
"([[see:Q1]])[[v:Q1.r0.净销售额]],华南仍低于华东,([[see:Q1]])([[see:Q1]])。"),
"居各区域之首": Play([REFUNDS], "stands", "华东退款 [[v:Q1.r0.退款笔数]] 笔,扣除后仍居首。[[see:Q1]]"),
}
record, _ = await _record(monkeypatch, shop, plays)
refuted = next(claim for claim in record["claims"] if claim["verdict"] == "refuted")
assert refuted["rationale"] == "扣除退款后华南、华东分别为 900 1,200,华南仍低于华东。"
assert refuted["rationale_doc"]["markdown"] == refuted["rationale"]
assert refuted["rationale_doc"]["violations"] == []
async def test_a_bare_see_between_two_numbers_and_other_brackets_do_not_break_the_rationale(monkeypatch, shop):
"""依据直接夹在两个数之间(两边什么都没有)、写进方头括号(复核第 3 轮):两个数不能挨成一个(「9001,200」),括号
不能剩下「【】」。"""
plays = {
"华南销售额超过华东": Play([NET], "refuted", "扣除退款后华南、华东分别为 [[v:Q1.r1.净销售额]][[see:Q1]]"
"[[v:Q1.r0.净销售额]],华南仍低于华东【[[see:Q1]]】。"),
"居各区域之首": Play([REFUNDS], "stands", "华东退款 [[v:Q1.r0.退款笔数]] 笔,扣除后仍居首。[[see:Q1]]"),
}
record, _ = await _record(monkeypatch, shop, plays)
refuted = next(claim for claim in record["claims"] if claim["verdict"] == "refuted")
assert refuted["rationale"] == "扣除退款后华南、华东分别为 900 1,200,华南仍低于华东。"
assert refuted["rationale_doc"]["markdown"] == refuted["rationale"]
assert refuted["rationale_doc"]["violations"] == []
async def test_a_placeholder_rationale_is_recorded_as_no_rationale(monkeypatch, shop):
"""反方交回的理由只是占位词:记录里理由为空(审批卡据此写「反方没有给出理由」),判「被推翻」的照常不予采信。"""
plays = {"华南销售额超过华东": Play([NET], "refuted", "placeholder"),
"居各区域之首": Play([REFUNDS], "stands", "placeholder")}
record, issuance = await _record(monkeypatch, shop, plays)
first, second = record["claims"]
assert (first["verdict"], first["rationale"], first["rationale_doc"]) == ("stands", "", None)
assert second["verdict"] == "inconclusive"
assert second["rationale"] == "理由没有引用到任何查询结果,反方给出的「被推翻」不予采信,记为无法判断。反方没有给出理由"
assert "placeholder" not in str(record)
assert issuance["tier"] == "formal"
async def test_a_rationale_that_shows_nothing_is_recorded_as_no_rationale(monkeypatch, shop):
"""反方的理由只写了依据:显示出来是空的。以前按原文判,理由文档照样记下——审批卡按文档画出一片空白,不说「反方没有
给出理由」;判「被推翻」的因为引用到了 Q1 被当成有出处,照常采信、出具降档。"""
plays = {"华南销售额超过华东": Play([NET], "refuted", "[[see:Q1]]"),
"居各区域之首": Play([REFUNDS], "stands", "([[see:Q1]])")}
record, issuance = await _record(monkeypatch, shop, plays)
first, second = record["claims"]
assert (first["verdict"], first["rationale"], first["rationale_doc"]) == ("stands", "", None)
assert second["verdict"] == "inconclusive" and second["rationale_doc"] is None
assert second["rationale"].endswith("反方给出的「被推翻」不予采信,记为无法判断。反方没有给出理由")
assert issuance["tier"] == "formal", "没有理由的「被推翻」不能让出具降档"
async def test_a_placeholder_with_a_see_is_still_a_placeholder(monkeypatch, shop):
plays = {"华南销售额超过华东": Play([NET], "stands", "placeholder。[[see:Q1]]"),
"居各区域之首": Play([REFUNDS], "stands", "无。")}
record, _ = await _record(monkeypatch, shop, plays)
assert [(c["rationale"], c["rationale_doc"]) for c in record["claims"]] == [("", None), ("", None)]
assert "placeholder" not in str(record)
@@ -0,0 +1,70 @@
"""双盲复算的说明:依据写进括号不再断句,占位词不当说明(期 5 真实模型验收 M1)。
盲算 agent 和反方审稿、审批质询共用核查 agent 交结果的提示,同样会把 [[see:Q1]] 写进括号;真实模型验收里还见过盲算交回
字面的「placeholder」当说明,审批卡上照着显示。
模型一律是 MockChatModel 剧本,订单库是虚构的(tests/fixtures/dual_check_shop.py)。
"""
from __future__ import annotations
from app.core import artifact_store
from tests.fixtures.dual_check_shop import BY_ORDER_DATE, RIGHT, Blind
from tests.test_dual_check_node import ( # noqa: F401 - 夹具按名字引入
clean_settings,
engine_up,
graph,
pending,
shop,
start,
)
async def _blind_input(monkeypatch, shop, rationale: str) -> dict:
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}, rationale=rationale)
run = await start(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted", run.error
card = artifact_store.load((await pending(run.id)).payload["card_artifact"])
[mine] = card["metrics"][0]["blind"]["inputs"]
return mine
async def test_see_in_brackets_does_not_break_the_blind_rationale(monkeypatch, shop):
mine = await _blind_input(monkeypatch, shop, "按口径说明统计([[see:Q1]],已排除退款),结果是 {ref}([[see:Q1]])。")
assert mine["rationale"] == "按口径说明统计(已排除退款),结果是 330。"
doc = mine["rationale_doc"]
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
async def test_see_brackets_written_together_do_not_glue_the_number(monkeypatch, shop):
"""两个只装着依据的括号连着写、数字包着粗体(复核第 2 轮):去掉之后数字不能和后面的字挨成一个数(「3302 月」),
句末前悬着的逗号也去掉。"""
mine = await _blind_input(monkeypatch, shop, "结果是 **{ref}**([[see:Q1]])([[see:Q1]])2 月起按支付日统计,"
"已排除退款,([[see:Q1]])([[see:Q1]])。")
assert mine["rationale"] == "结果是 330 2 月起按支付日统计,已排除退款。"
doc = mine["rationale_doc"]
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
async def test_a_bare_see_before_a_number_and_other_brackets_do_not_break_the_rationale(monkeypatch, shop):
"""依据直接夹在数字和后面的数之间(两边什么都没有)、写进六角括号(复核第 3 轮):数字不能和后面的字挨成一个数
(「3302 月」),括号不能剩下「〔〕」。"""
mine = await _blind_input(monkeypatch, shop, "结果是 {ref}[[see:Q1]]2 月起按支付日统计,已排除退款〔[[see:Q1]]〕。")
assert mine["rationale"] == "结果是 330 2 月起按支付日统计,已排除退款。"
doc = mine["rationale_doc"]
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
async def test_a_placeholder_rationale_is_not_shown_as_the_explanation(monkeypatch, shop):
mine = await _blind_input(monkeypatch, shop, "placeholder")
assert mine["value"] == 330.0, "数照样采用"
assert (mine["rationale"], mine["rationale_doc"]) == ("", None)
async def test_a_rationale_that_shows_nothing_is_not_kept(monkeypatch, shop):
"""只写了依据(显示出来是空的),或者占位词后面挂着依据:说明为空、不留文档。以前文档照样记下,审批卡按文档画出
「理由」两个字,后面什么都没有。"""
for said in ("([[see:Q1]])", "placeholder。[[see:Q1]]"):
mine = await _blind_input(monkeypatch, shop, said)
assert mine["value"] == 330.0, "数照样采用"
assert (mine["rationale"], mine["rationale_doc"]) == ("", None), said
+600
View File
@@ -0,0 +1,600 @@
"""依据标记 [[see:…]] 写进括号、夹在标点中间时的渲染(期 5 真实模型验收 M1)。
[[see:]] 渲染成空,按设计挂在句末。核查 agent(双盲复算、反方审稿、审批质询共用)以前被提示「提到某次查询时写
[[see:Q1]]」,模型就把它写进括号、句中:「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计
[[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。
- 括号(圆括号、方括号的全角半角,一对里混用也算;方头括号【】、六角括号〔〕)里只有依据的,连括号一起去掉;去掉之后
整对括号当一个依据,外面照下面几条再收拾一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个
(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾
- 依据渲染成空、周围的字去掉之后,两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」,依据两边什么都
没有的「4,919.5[[see:Q1]]212 笔」也是),补一个空格:粘起来读成另一个数,哪项核对都查不出来。每个依据两边都判,
找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线、删除线的波浪线;依据标记的「]」
不当数字。一片里最多补一个,挨上的是写作者留着的空白的不补;半角逗号夹着依据、去掉后一个之后同理
(「212,[[see:Q1]],5」→「212, 5」),右边是紧贴着下文的半角逗号的(「1,200([[see:Q1]]),900」)补在逗号后面
- 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理
- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」
「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动
- 换行不算句末:列表项末尾的「;」、软换行处的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩,照旧留着
- 括号里、句末前几个依据之间的分隔标点一并去掉;两边都是字时,依据之间的标点可能是分句的标点,照旧留着
- 依据标记本身留在原文里:句子照旧挂着这条依据(see、cites),只是不显示
- 不含依据的括号、没有依据的文档一个字不动:内容哈希和改动之前一样
- 流式渲染分块输出拼起来仍然等于整篇渲染
- 已知限制:依据写在「依据」「见」「取自」「来源」这类引出语后面时,引出语悬空(「依据。」「(来源)」)。渲染时去不掉,
靠核查 agent 的提示词避免,模型不照做时核查 agent 记一条日志(test_verify_rationale_rules)
数字、表名、地名都是虚构的。
"""
from __future__ import annotations
import random
import re
import pytest
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import (
StreamRenderer,
build_catalog,
compose_doc,
iter_units,
render_markers,
verify_doc,
)
ART = "a" * 64
SNAP_ID = "b" * 64
#: 一次查询的快照:华南 4,919.5 元 212 笔、华东 1,891.8 元 113 笔
SNAP = {"columns": ["区域", "销售额", "订单数"], "rows": [["华南", 4919.5, 212], ["华东", 1891.8, 113]],
"row_count": 2, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"}
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "artifact": ART,
"metrics": [{"id": "gmv", "name": "销售额", "unit": "元", "value": 45678.5, "decimals": 1,
"format": "thousands", "status": "ok", "expression": "…"},
{"id": "yoy", "name": "同比增幅", "unit": "%", "value": 12.5, "decimals": 1,
"format": "plain", "status": "ok", "expression": "…"}]}
LEDGER = [{"kind": "metric_set", "node_id": "cal", "exec": 1, "artifact": ART, "caliber": "周报口径",
"version": "v1", "metrics": ["gmv", "yoy"]},
{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": SNAP_ID, "call_id": "c1",
"tool": "db_query__shop", "source": "shop", "columns": SNAP["columns"], "rows": 2, "truncated": False}]
LOADER = {SNAP_ID: SNAP}.__getitem__
@pytest.fixture
def catalog():
return build_catalog(nodes={"cal": CARD}, ledger=LEDGER, inputs={"week": "2026-W37"})
def render(raw: str, catalog) -> str:
return render_markers(raw, catalog, loader=LOADER)
def compose(raw: str, catalog) -> dict:
return compose_doc(raw, catalog, node_id="write", run_id="r1", loader=LOADER)
# --------------------------------------------------------------------------
# 括号里只有依据:连括号一起去掉
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
("先按状态核对口径([[see:Q1]]):已付 [[v:Q1.r0.订单数]] 笔。", "先按状态核对口径:已付 212 笔。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])。", "合计 4,919.5。"),
("换口径不改变排序([[see:Q1]]),结论成立。", "换口径不改变排序,结论成立。"),
("结果一致([[see:Q1]])。", "结果一致。"), # 一对括号全角半角混用
("结果一致([[see:Q1]] [[see:m:gmv]])。", "结果一致。"),
("结果一致([[see:Q1]]、[[see:m:gmv]])。", "结果一致。"),
("结果一致( [[see:Q1]], )。", "结果一致。"),
("结果一致 ([[see:Q1]])。", "结果一致。"), # 括号前的空白一起去掉
("前一句 ([[see:Q1]])后一句", "前一句 后一句"), # 后面紧跟着字:留一个空格隔开
("末尾([[see:Q1]])", "末尾"),
])
def test_a_bracket_holding_only_see_is_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
def test_indentation_before_a_see_only_bracket_is_kept(catalog):
assert render("- 第一条\n ([[see:Q1]])续行", catalog) == "- 第一条\n 续行"
# --------------------------------------------------------------------------
# 依据在括号开头 / 末尾,紧挨着分隔标点
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
# 在括号开头:后面的标点(和空白)一起去掉
("结果一致([[see:Q1]],原报告未按状态过滤)。", "结果一致(原报告未按状态过滤)。"),
("结果一致([[see:Q1]]、原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]];原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]]:原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]], 原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]] 原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致( [[see:Q1]],[[see:m:gmv]],原报告未过滤)。", "结果一致(原报告未过滤)。"),
# 在括号末尾:前面的标点(和空白)一起去掉
("复算原查询(同 SQL 同过滤条件,[[see:Q1]]):华南居首。", "复算原查询(同 SQL 同过滤条件):华南居首。"),
("复算原查询(同 SQL, [[see:Q1]])。", "复算原查询(同 SQL)。"),
("复算原查询(同 SQL 同过滤条件 [[see:Q1]])。", "复算原查询(同 SQL 同过滤条件)。"),
("订单([[v:Q1.r0.订单数]] 笔,[[see:Q1]])", "订单(212 笔)"),
("口径(按支付日;[[see:Q1]]、[[see:m:gmv]])相同。", "口径(按支付日)相同。"),
])
def test_punctuation_next_to_see_inside_a_bracket_goes_with_it(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 夹在两个分隔标点中间:去掉后一个(括号里外都一样)
("口径相同(按支付日,[[see:Q1]],不含退款)。", "口径相同(按支付日,不含退款)。"),
("结果一致,[[see:Q1]],原报告未过滤。", "结果一致,原报告未过滤。"),
("a, [[see:Q1]], b", "a, b"),
("a,[[see:Q1]],b", "a, b"), # 半角逗号后面照排版习惯隔一个空格
# 前面是分隔标点、后面就到句末:去掉前面那个
("华南居首,[[see:Q1]]。", "华南居首。"),
("华南居首,[[see:Q1]]", "华南居首"),
("华南居首,[[see:Q1]] 。", "华南居首。"),
# 几个依据之间的分隔标点
("华南居首 [[see:Q1]],[[see:m:gmv]]。", "华南居首。"),
# 后面就到句末、中间只隔着空白:空白一起去掉,不剩「华南居首 。」
("华南居首 [[see:Q1]] 。", "华南居首。"),
("华南居首 [[see:Q1]] [[see:m:gmv]] 。", "华南居首。"),
])
def test_punctuation_left_dangling_by_see_is_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 括号前悬着的停顿标点:去掉括号之后和没写括号的「华南居首,[[see:Q1]]。」一样收拾
("华南居首,([[see:Q1]])。", "华南居首。"),
("华南居首、([[see:Q1]])", "华南居首"),
("华南居首 ([[see:Q1]]) 。", "华南居首。"),
("华南居首,([[see:Q1]]),华东第二。", "华南居首,华东第二。"), # 夹在两个逗号中间:去掉后一个
("华南居首(([[see:Q1]]))。", "华南居首。"), # 套着的两层只装着依据,一起去掉
# 换行不算句末:括号里外一样留着写作者的标点
("华南居首、([[see:Q1]])\n下一行", "华南居首、\n下一行"),
])
def test_a_dropped_bracket_is_tidied_like_a_bare_see(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 括号是两边唯一的分隔:去掉之后数字会粘成另一个数,换成一个空格
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])2026 年同比持平。", "合计 4,919.5 2026 年同比持平。"),
("订单 [[v:Q1.r0.订单数]]([[see:Q1]])2 个区域。", "订单 212 2 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])3 个区域。", "增幅 12.5% 3 个区域。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[v:Q1.r0.订单数]] 笔。", "合计 4,919.5 212 笔。"), # 后面的标记渲染出数字
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[see:m:gmv]]2 月。", "合计 4,919.5 2 月。"), # 越过不显示的依据看
("编号 Q1([[see:Q1]])x", "编号 Q1 x"),
("212,([[see:Q1]])5 个区域", "212, 5 个区域"),
# 半角逗号夹着依据:去掉后一个之后,前一个不能直接贴着数字
("212,[[see:Q1]],5 个区域", "212, 5 个区域"),
# 不会粘的照旧去掉:中文和数字之间、括号前已有空白、后面是中文、隔着全角逗号
("居首([[see:Q1]])2 月起", "居首2 月起"),
("合计 [[v:Q1.r0.销售额]] ([[see:Q1]])2 月起", "合计 4,919.5 2 月起"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])元", "合计 4,919.5元"),
("华南([[see:Q1]])居首", "华南居首"),
("212,[[see:Q1]],5 个区域", "212,5 个区域"),
])
def test_dropping_a_bracket_does_not_glue_numbers_together(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
def test_a_glued_number_would_have_slipped_through_the_checks(catalog):
"""粘起来的数没有哪项核对查得出:片段本身对(4,919.5 后面是另一段文字),月份不算裸数字。所以只能在渲染时不粘。"""
doc = compose("华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月起持续下滑。", catalog)
assert doc["markdown"] == "华东 4,919.5 2 月起持续下滑。" and doc["violations"] == []
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
[unit] = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in unit["see"]] == ["Q1"]
# --------------------------------------------------------------------------
# 粘不粘看去掉之后真正挨上的两个字:越过依据、只装着依据的括号、粗体斜体的星号和下划线(复核第 2 轮)
# --------------------------------------------------------------------------
#: 读者看到的字:前端按 Markdown 渲染,星号、两个下划线不显示
_EMPHASIS = re.compile(r"\*+|__+")
def _visible(text: str) -> str:
return _EMPHASIS.sub("", text)
@pytest.mark.parametrize(("raw", "shown"), [
# 两个只装着依据的括号连着写(全角半角混用、套着的也一样):中间什么都不剩,两边会粘的照样隔开
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]])2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("订单 [[v:Q1.r0.订单数]]([[see:Q1]])([[see:m:gmv]])[[v:Q1.r0.订单数]] 笔。", "订单 212 212 笔。"),
("增幅 [[m:yoy]]([[see:m:yoy]])([[see:Q1]])3 个区域。", "增幅 12.5% 3 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])([[see:Q1]])3 个区域。", "增幅 12.5% 3 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])(([[see:Q1]]))3 个区域。", "增幅 12.5% 3 个区域。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]]) ([[see:m:gmv]])2 月", "合计 4,919.5 2 月"),
# 依据和括号连着写、几个依据之间隔着空白:去掉之后两边会粘的,同样隔开
("合计 [[v:Q1.r0.销售额]][[see:Q1]]([[see:m:gmv]])2 月。", "合计 4,919.5 2 月。"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]] [[see:m:gmv]]2 月。", "合计 4,919.5 2 月。"),
# 不会粘的照旧:中文和数字之间
("华南居首([[see:Q1]])([[see:m:gmv]])2 月起回升。", "华南居首2 月起回升。"),
])
def test_see_only_brackets_written_together_do_not_glue_numbers(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 连着写的几个只装着依据的括号当一组收拾:括号前悬着的停顿标点、中间的标点一并去掉
("华南居首,([[see:Q1]])([[see:m:gmv]])。", "华南居首。"),
("华南居首,[[see:Q1]]([[see:m:gmv]])。", "华南居首。"),
("华南居首,([[see:Q1]])、([[see:m:gmv]])。", "华南居首。"),
("结果一致([[see:Q1]])([[see:m:gmv]]),原报告未过滤。", "结果一致,原报告未过滤。"),
("结果一致([[see:Q1]])([[see:m:gmv]],原报告未过滤)。", "结果一致(原报告未过滤)。"),
])
def test_see_only_brackets_written_together_are_tidied_as_one(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 粗体在括号前、后、把括号包在里面:星号不显示,挨上的是两边的数
("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月起持续下滑。", "销售额 4,919.5 2 月起持续下滑。"),
("华东 **[[v:Q1.r0.销售额]]**([[see:Q1]])[[v:Q1.r0.订单数]] 笔。", "华东 4,919.5 212 笔。"),
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])**[[v:Q1.r0.订单数]] 笔**", "华东 4,919.5 212 笔"),
("**[[v:Q1.r0.销售额]]([[see:Q1]])**2 月", "4,919.5 2 月"),
("华东 **[[v:Q1.r0.销售额]]**([[see:Q1]])([[see:m:gmv]])**[[v:Q1.r0.订单数]]** 笔", "华东 4,919.5 212 笔"),
("__[[v:Q1.r0.销售额]]__([[see:Q1]])2 月", "4,919.5 2 月"),
("*[[v:Q1.r0.销售额]]*([[see:Q1]])2 月", "4,919.5 2 月"),
# 依据前的空白:后面紧跟着的是星号,越过它看
("合计 [[v:Q1.r0.销售额]] [[see:Q1]]**2 月**起", "合计 4,919.5 2 月起"),
# 不会粘的照旧
("合计 **[[v:Q1.r0.销售额]]**([[see:Q1]])元", "合计 4,919.5元"),
])
def test_bold_around_a_number_does_not_hide_the_glue(catalog, raw, shown):
assert _visible(render(raw, catalog)) == shown
assert _visible(compose(raw, catalog)["markdown"]) == shown
def test_bold_still_wraps_the_number_after_the_bracket_is_dropped(catalog):
doc = compose("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月起持续下滑。", catalog)
[number] = [seg for _, unit in iter_units(doc) for seg in unit["segments"] if seg.get("ref")]
assert number["text"] == "4,919.5" and number.get("strong") is True
assert render("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月", catalog) == "销售额 **4,919.5** 2 月"
@pytest.mark.xfail(strict=True, reason="已知限制:粗体把括号包在里面时,补的空格落在收尾的星号前面(「**4,919.5 **2 月」)。"
"前端的行内 Markdown 照样认这对星号,组装出的文档去掉了包着标记的星号,读者看到的一样;"
"挪到星号外面得先知道这对星号从哪开始,流式渲染的一块里不一定有")
def test_the_space_lands_outside_a_bold_that_wraps_the_bracket(catalog):
assert render("销售额 **[[v:Q1.r0.销售额]]([[see:Q1]])**2 月", catalog) == "销售额 **4,919.5** 2 月"
# --------------------------------------------------------------------------
# 依据直接夹在两个数之间:两边没有括号、空白、标点,也不能让两个数并成一个(复核第 3 轮)
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
# 依据两边什么都没有:渲染成空之后两边直接挨上
("华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔。", "华东 4,919.5 212 笔。"),
("华东 [[v:Q1.r0.销售额]][[see:Q1]]2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("增幅 [[m:yoy]][[see:m:yoy]]3 个区域。", "增幅 12.5% 3 个区域。"),
("编号 Q1[[see:Q1]]x 区", "编号 Q1 x 区"),
("212,[[see:Q1]]5 个区域", "212, 5 个区域"),
# 连着写的一串当一个看
("华东 [[v:Q1.r0.销售额]][[see:Q1]][[see:m:gmv]]2 月", "华东 4,919.5 2 月"),
# 不会粘的照旧:中文和数字之间、后面是中文或单位、写作者自己留了空白、句号后面
("华南居首[[see:Q1]]2 月起回升。", "华南居首2 月起回升。"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]]元", "合计 4,919.5元"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]]%", "合计 4,919.5%"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]] 2 月", "合计 4,919.5 2 月"),
("合计 [[v:Q1.r0.销售额]] [[see:Q1]]2 月", "合计 4,919.5 2 月"),
("华南居首。[[see:Q1]]2 月起回升。", "华南居首。2 月起回升。"),
])
def test_a_bare_see_between_two_numbers_does_not_glue_them(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 粗体收尾后紧跟依据再跟数字(组装文档时包着标记的星号去掉了,挨上的是两个数)
("**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月", "华东 4,919.5 2 月"),
("华东 **[[v:Q1.r0.销售额]]**[[see:Q1]][[v:Q1.r0.订单数]] 笔", "华东 4,919.5 212 笔"),
("华东 [[v:Q1.r0.销售额]][[see:Q1]]**2 月**起", "华东 4,919.5 2 月起"),
("*[[v:Q1.r0.销售额]]*[[see:Q1]]2 月", "4,919.5 2 月"),
])
def test_a_bare_see_after_bold_does_not_glue_the_numbers(catalog, raw, shown):
assert _visible(render(raw, catalog)) == shown
assert _visible(compose(raw, catalog)["markdown"]) == shown
def test_the_space_goes_before_the_see_and_outside_the_bold(catalog):
"""补的空格落在依据前面、收尾的星号后面:粗体照样成对,依据照样挂在句子上。一片里有去掉的字时落在头一个去掉的字
那里(同复核第 2 轮):粗体在两段依据中间收尾的,空格照样在星号外面。"""
assert render("**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月", catalog) == "**华东 4,919.5** 2 月"
assert render("**华东 [[v:Q1.r0.销售额]][[see:Q1]]**([[see:m:gmv]])2 月", catalog) == "**华东 4,919.5** 2 月"
doc = compose("华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔。", catalog)
[unit] = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in unit["see"]] == ["Q1"]
assert [seg["text"] for seg in unit["segments"] if seg.get("ref")] == ["4,919.5", "212"]
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
@pytest.mark.parametrize(("raw", "shown"), [
# 右边是紧贴着下文的半角逗号:去掉依据(和只装着依据的括号)之后读成一个数(「1,200,900」),空格补在逗号后面
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]),5 个区域", "分别为 212, 5 个区域"),
("分别为 [[v:Q1.r0.销售额]][[see:Q1]],[[v:Q1.r1.销售额]]。", "分别为 4,919.5, 1,891.8。"),
("分别为 [[v:Q1.r0.销售额]]【[[see:Q1]]】,[[v:Q1.r1.销售额]]([[see:Q1]])。", "分别为 4,919.5, 1,891.8。"),
("212[[see:Q1]],5 个区域", "212, 5 个区域"),
("分别为 [[v:Q1.r0.销售额]]([[see:Q1]]),**[[v:Q1.r1.销售额]]**。", "分别为 4,919.5, **1,891.8**。"), # 下文包着粗体
# 不会读成一串的照旧:逗号后面已有空白、是中文,逗号前面是中文
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]), 5 个区域", "分别为 212, 5 个区域"),
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]),华东第二", "分别为 212,华东第二"),
("分别为 [[v:Q1.r0.订单数]][[see:Q1]],[[see:m:gmv]]华东第二", "分别为 212,华东第二"), # 逗号后面的依据不显示
("华南居首([[see:Q1]]),5 个区域", "华南居首,5 个区域"),
])
def test_a_half_width_comma_left_against_the_next_number_gets_a_space(catalog, raw, shown):
assert render(raw, catalog) == shown
assert _visible(compose(raw, catalog)["markdown"]) == _visible(shown)
def test_a_struck_number_does_not_glue_either(catalog):
"""删除线前端照样画成字(淡色),后面紧跟的数挨着它读成一串:星号、下划线之外,两个波浪线也越过去看。"""
assert render("~~[[v:Q1.r0.销售额]]~~[[see:Q1]]2 月", catalog) == "~~4,919.5~~ 2 月"
assert render("~~[[v:Q1.r0.销售额]]~~([[see:Q1]])2 月", catalog) == "~~4,919.5~~ 2 月"
assert render("[[v:Q1.r0.销售额]][[see:Q1]]~~2 月~~", catalog) == "4,919.5 ~~2 月~~"
# --------------------------------------------------------------------------
# 别的括号:【】[]〔〕[] 只装着依据的,和()一样去掉(复核第 3 轮)
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
("合计 [[v:Q1.r0.销售额]]【[[see:Q1]]】。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]][[[see:Q1]]]。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]]〔[[see:Q1]]〕。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]][[[see:Q1]]]。", "合计 4,919.5。"),
("结果一致[[[see:Q1]]]。", "结果一致。"), # 方括号一对里全角半角混用
("结果一致【[[see:Q1]],[[see:m:gmv]]】。", "结果一致。"),
("结果一致 【 [[see:Q1]] 】 。", "结果一致。"),
("前一句 【[[see:Q1]]】后一句", "前一句 后一句"),
("末尾[[[see:Q1]]]", "末尾"),
# 去掉之后整对括号当一个依据,外面悬着的停顿标点照原规则收拾
("华南居首,【[[see:Q1]]】。", "华南居首。"),
("华南居首,[[[see:Q1]]],华东第二。", "华南居首,华东第二。"),
# 套着的、和别的括号连着写的
("华南居首(【[[see:Q1]]】)。", "华南居首。"),
("华南居首【[[see:Q1]]】([[see:m:gmv]])〔[[see:Q1]]〕。", "华南居首。"),
# 依据在括号开头、末尾,紧挨着分隔标点
("结果一致【[[see:Q1]],原报告未过滤】。", "结果一致【原报告未过滤】。"),
("复算原查询〔同 SQL,[[see:Q1]]〕。", "复算原查询〔同 SQL〕。"),
("结果一致[[[see:Q1]]; 原报告未过滤]。", "结果一致[原报告未过滤]。"),
# 去掉之后两边的数不能并成一个
("华东 [[v:Q1.r0.销售额]]【[[see:Q1]]】2 月", "华东 4,919.5 2 月"),
("华东 [[v:Q1.r0.销售额]][[[see:Q1]]]2 月", "华东 4,919.5 2 月"),
("华东 [[v:Q1.r0.销售额]]〔[[see:Q1]]〕[[v:Q1.r0.订单数]] 笔", "华东 4,919.5 212 笔"),
])
def test_other_brackets_holding_only_see_are_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize("raw", [
"【注】原样,[1]、〔2026〕、[a] 也是。",
"空的【】、[]、〔〕、[] 原样,【,】也是。",
"【证据 Q1】华南居首。",
])
def test_other_brackets_without_see_are_left_alone(catalog, raw):
assert render(raw, catalog) == raw
assert compose(raw, catalog)["markdown"] == raw
@pytest.mark.parametrize(("raw", "shown"), [
# 依据标记的「]」不显示,不当数字:左边真正挨上的是句号、文首
("华南居首。[[see:Q1]]([[see:m:gmv]])2 月起回升。", "华南居首。2 月起回升。"),
("[[see:Q1]]([[see:Q1]])2 月", "2 月"),
("华南居首。[[see:Q1]] ([[see:m:gmv]])2 月起回升。", "华南居首。2 月起回升。"), # 中间的空白同几个依据之间的
# 一片里最多补一个空格;挨上的本来就隔着空白的不补
("212,[[see:Q1]],[[see:m:gmv]],5 个区域", "212, 5 个区域"),
("212,([[see:Q1]]),[[see:m:gmv]],5 个区域", "212, 5 个区域"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]]) 2 月", "合计 4,919.5 2 月"),
])
def test_a_space_is_put_back_once_and_only_where_it_sticks(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 1–3 段依据、只装着依据的括号(几类括号)、空白、星号,
#: 右边以数字、字母或者渲染出数的标记开头
_LEFTS = ["合计 [[v:Q1.r0.销售额]]", "合计 **[[v:Q1.r0.销售额]]**", "合计 __[[v:Q1.r0.销售额]]__", "增幅 [[m:yoy]]",
"编号 Q1"]
_MIDDLES = ["([[see:Q1]])", "([[see:m:gmv]])", "(([[see:Q1]]))", "【[[see:Q1]]】", "[[[see:m:gmv]]]", "[[see:Q1]]",
"**", " ", ","]
_RIGHTS = ["2 月", "[[v:Q1.r0.订单数]] 笔", "**[[v:Q1.r0.订单数]] 笔**", "x 区"]
#: 写作者原本就隔开了两边的中间段:括号(去掉依据之后还看得见)、空白、标点
_SEPARATING = {"([[see:Q1]])", "([[see:m:gmv]])", "(([[see:Q1]]))", "【[[see:Q1]]】", "[[[see:m:gmv]]]", " ", ","}
def _combos() -> list[tuple[str, str, str]]:
"""写作者隔开了两边、或者中间写了依据的组合(中间只有星号的,是写作者自己把两个数写在了一起,不归这里管)。"""
middles = [[m] for m in _MIDDLES]
middles += [[a, b] for a in _MIDDLES for b in _MIDDLES]
middles += [[a, b, c] for a in _MIDDLES for b in _MIDDLES for c in _MIDDLES]
return [(left, "".join(middle), right) for left in _LEFTS for right in _RIGHTS for middle in middles
if any(m in _SEPARATING or "see" in m for m in middle)]
#: 左边那个数渲染出来的字,后面紧跟着数字、字母,或者半角的逗号、句点再跟数字:读成了另一个数、另一个词
_GLUED = re.compile(r"(?:4,919\.5|12\.5%|Q1)(?=[\dA-Za-z]|[,.]\d)")
def test_two_numbers_never_run_together_once_see_is_dropped(catalog):
"""写作者隔开了的两边(括号、空白、标点),或者中间只隔着依据的,收拾依据之后不能挨成一串;也不会多出两个连着的
空格。"""
bad = []
for left, middle, right in _combos():
raw = f"{left}{middle}{right}。"
shown = _visible(render(raw, catalog))
writer = _visible(re.sub(r"\[\[see:[^\]]*\]\]", "", raw))
if _GLUED.search(shown) or (" " in shown and " " not in writer):
bad.append((raw, shown))
assert not bad, bad[:10]
@pytest.mark.parametrize(("raw", "shown"), [
("华南居首。[[see:Q1]]", "华南居首。"),
("华南居首 [[see:Q1]],华东第二。", "华南居首,华东第二。"),
# 两个依据中间的逗号后面接着字:它是分句的逗号,不是依据之间的分隔,照旧留着
("华南居首 [[see:Q1]],[[see:m:gmv]]华东第二。", "华南居首,华东第二。"),
("华南居首 [[see:Q1]],", "华南居首,"), # 依据后面的逗号是写作者自己的,不动
("数据如下:[[see:Q1]]\n| 区域 | 销售额 |", "数据如下:\n| 区域 | 销售额 |"), # 冒号引出的是下一行
# 换行不算句末:列表项末尾的「;」、软换行处连着两个分句的「,」是写作者自己的
("1. 华东居首;[[see:Q1]]\n2. 华南第二;[[see:Q1]]\n3. 华北第三。[[see:Q1]]",
"1. 华东居首;\n2. 华南第二;\n3. 华北第三。"),
("华东居首,[[see:Q1]]\n华南第二。", "华东居首,\n华南第二。"),
("华南居首、华东第二;[[see:Q1]]\n下一行", "华南居首、华东第二;\n下一行"),
("- 华东居首;([[see:Q1]])\n- 华南第二。", "- 华东居首;\n- 华南第二。"),
])
def test_punctuation_the_writer_put_there_is_kept(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
#: 引出语悬空:「依据」「见」「取自」「来源」「出处」后面(可能隔着冒号、空白)紧跟着句末、停顿标点或括号
_DANGLING = re.compile(r"(?:依据|见|取自|来源|出处)[::]?[ \t]*(?:[。,、;))!?,;]|$)")
@pytest.mark.xfail(strict=True, reason="已知限制:依据写在引出语后面时,渲染后引出语悬空。引出语在依据前面,渲染时要去掉"
"它,流式渲染得先攒住每个可能是引出语的字;目前靠核查 agent 的提示词避免这种写法,模型"
"不照做时记一条日志(verify_agent._LEAD_IN_SEE)")
@pytest.mark.parametrize("raw", [
"结果见:[[see:Q1]]。",
"依据 [[see:Q1]]。",
"数据取自 [[see:Q1]]。",
"结果取自 [[see:Q1]],完整未截断。",
"按支付日统计(见 [[see:Q1]])。",
"按支付日统计(来源:[[see:Q1]])。",
"按状态拆开(status 取值见 [[see:Q1]])。",
])
def test_see_after_a_lead_in_word_leaves_it_dangling(catalog, raw):
assert not _DANGLING.search(render(raw, catalog)), render(raw, catalog)
@pytest.mark.parametrize("raw", [
"空括号()原样保留,半角 () 也是。",
"说明(含退款,不含测试账号);另见 (a, b)。",
"(,)原样,(;) 也是。",
"甲,乙、丙;丁:戊。",
])
def test_brackets_and_punctuation_without_see_are_left_alone(catalog, raw):
assert render(raw, catalog) == raw
assert compose(raw, catalog)["markdown"] == raw
# --------------------------------------------------------------------------
# 依据仍挂在句子上
# --------------------------------------------------------------------------
def test_the_sentence_still_carries_the_see_that_was_in_brackets(catalog):
doc = compose("先按状态核对口径([[see:Q1]]):已付 [[v:Q1.r0.订单数]] 笔。"
"结果一致([[see:m:gmv]],原报告未过滤)。", catalog)
assert doc["markdown"] == "先按状态核对口径:已付 212 笔。结果一致(原报告未过滤)。"
first, second = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in first["see"]] == ["Q1"] and first["cites"] == ["Q1"]
assert [s["alias"] for s in second["see"]] == ["m:gmv"] and second["cites"] == ["m:gmv"]
assert doc["stats"]["see"] == 2 and doc["stats"]["uncited_claims"] == 0
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
# --------------------------------------------------------------------------
# 没有依据写进括号的文档:渲染和内容哈希都和改动之前一样
# --------------------------------------------------------------------------
#: 改动之前(基线 58a6759)组装出来的文档的内容哈希
BEFORE = {
"plain": ("## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]](含退款,不含测试账号),同比 [[m:yoy]];"
"华南 [[v:Q1.r0.销售额]]([[v:Q1.r0.订单数]] 笔)、华东 [[v:Q1.r1.销售额]]。\n\n"
"- 空括号()原样保留,半角 (a, b) 也是\n- 分隔:甲,乙、丙;丁\n\n"
"| 区域 | 销售额(元) |\n| --- | --- |\n| 华南(含线上) | [[v:Q1.r0.销售额]] |",
"49e7a6e4e430f0cbc495b5ab2b5e319f146bbee2c7147d663494922aaf6773ba"),
"see_at_end": ("本周销售额 [[m:gmv]](含退款)。[[see:m:gmv]]同比 [[m:yoy]],增幅放缓 [[see:m:yoy]]。\n\n"
"华南居首([[v:Q1.r0.订单数]] 笔),华东第二。[[see:Q1]]\n- 列表项 [[see:Q1]]\n",
"1bb0bc31fffa6de8e906d24022dae7fa3cf9b43aeda47d319498f1593d5a3684"),
}
@pytest.mark.parametrize("name", list(BEFORE))
def test_documents_without_see_in_brackets_hash_as_before(catalog, name):
raw, digest = BEFORE[name]
assert content_hash(canonical_json(compose(raw, catalog))) == digest
# --------------------------------------------------------------------------
# 流式渲染:分块输出拼起来仍然等于整篇渲染
# --------------------------------------------------------------------------
def _stream(catalog, raw: str, sizes: list[int]) -> list[str]:
renderer, out, i = StreamRenderer(catalog, loader=LOADER), [], 0
for size in sizes:
if i >= len(raw):
break
out.append(renderer.feed(raw[i:i + size]))
i += size
if i < len(raw):
out.append(renderer.feed(raw[i:]))
out.append(renderer.flush())
return out
@pytest.mark.parametrize("raw", [
"结果一致([[see:Q1]],原报告未按状态过滤),合计 [[v:Q1.r0.销售额]]([[see:Q1]])。下一句",
"前一句 ([[see:Q1]])后一句,复算原查询(同 SQL,[[see:Q1]]):华南居首,[[see:Q1]]。",
"口径相同(按支付日,[[see:Q1]],不含退款)。a, [[see:Q1]], b\n华南、[[see:Q1]]\n末尾([[see:Q1]])",
"华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月,合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[see:m:gmv]][[v:Q1.r0.订单数]] 笔"
",212,[[see:Q1]],5 个",
"华南居首,([[see:Q1]])。华南居首 [[see:Q1]] 。1. 华东居首;[[see:Q1]]\n2. 华南第二,([[see:Q1]]),华东第二",
# 粗体、连着写的括号:往两边找挨上的字要越过星号、下划线,流式渲染得把它们和依据一起攒着
"销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月,华东 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]])"
"**[[v:Q1.r0.订单数]] 笔**",
"华南居首。[[see:Q1]]([[see:m:gmv]])2 月,212,[[see:Q1]],[[see:m:gmv]],5 个,合计 [[v:Q1.r0.销售额]] [[see:Q1]]"
"__2 月__",
"**[[v:Q1.r0.销售额]]([[see:Q1]])**2 月;华南居首,([[see:Q1]])([[see:m:gmv]])。order_([[see:Q1]])_x",
# 依据直接夹在两个数之间、别的括号(复核第 3 轮):方括号、波浪线也得和依据一起攒着
"华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔,[[v:Q1.r0.销售额]][[see:Q1]][[see:m:gmv]]2 月,"
"**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月",
"合计 [[v:Q1.r0.销售额]]【[[see:Q1]]】2 月,[[v:Q1.r0.销售额]][[[see:Q1]]]2 月,华南居首,[[[see:Q1]]]。"
"结果一致【[[see:Q1]],原报告未过滤】〔[[see:m:gmv]]〕[[v:Q1.r0.订单数]] 笔",
"~~[[v:Q1.r0.销售额]]~~[[see:Q1]]2 月,[[v:Q1.r0.销售额 ]][[see:Q1]][[v:Q1.r0.订单数]],a[0] 与 [b] 原样]",
"分别为 [[v:Q1.r0.销售额]]([[see:Q1]]),[[v:Q1.r1.销售额]],212[[see:Q1]],5 个,[[m:gmv]][[see:Q1]],[[m:gmv]] 元",
])
@pytest.mark.parametrize("size", range(1, 9))
def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size):
out = _stream(catalog, raw, [size] * len(raw))
assert "".join(out) == render(raw, catalog), out
assert all("[[see:" not in piece for piece in out)
def _corpus(n: int) -> list[str]:
rng = random.Random(20261006)
pieces = ["[[see:Q1]]", "[[see:m:gmv]]", "[[v:Q1.r0.销售额]]", "[[m:gmv]]", "(", ")", "(", ")", ",", "、",
";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "[", "2", "x", "%", "!",
"**", "*", "__", "_", "([[see:Q1]])", "【", "】", "[", "]", "〔", "〕", "]", "~~", "【[[see:Q1]]】",
"[[[see:m:gmv]]]"]
return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)]
@pytest.mark.parametrize("raw", _corpus(200))
def test_stream_matches_the_full_render_on_random_brackets(catalog, raw):
rng = random.Random(raw)
full = render(raw, catalog)
for _ in range(3):
sizes = [rng.randint(1, 12) for _ in range(len(raw))]
assert "".join(_stream(catalog, raw, sizes)) == full
def test_stream_matches_the_full_render_on_the_structured_combos(catalog):
"""结构化组合(数、依据、只装着依据的括号、空白、星号、下划线)逐字、每 4 个字一块地送进去,拼起来和整篇渲染一样。"""
bad = []
for left, middle, right in _combos():
raw = f"{left}{middle}{right}。"
full = render(raw, catalog)
bad += [(raw, out) for size in (1, 4) if "".join(out := _stream(catalog, raw, [size] * len(raw))) != full]
assert not bad, bad[:5]
@@ -0,0 +1,117 @@
"""审批质询的回答:依据写进括号不再断句,收尾超时记时长上限,占位词不当回答(期 5 真实模型验收 M1)。
真实模型验收里,质询 agent 写的「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计 [[v:Q1.r1.金额合计]]
([[see:Q1]])」渲染成「(,原报告…)」「()」,随正式运行封存;另一次回答查完了,收尾那次调用超过时长上限,报错写着
「回答超过时长上限(180 秒)」,answer.limit 却是 null,界面上缺了「已达时长上限」。
回答只写了依据(「([[see:Q1]])」)时显示出来是空的:以前照样交回,封存之后的追问末尾补上「注:以上回答依据的是数据源
当前的数据…」,回答正文只剩这句系统说明、却记成回答成功。现在按没给回答记。回答就是一个「无。」的,照常是回答。
模型一律是 MockChatModel 剧本,区域、金额都是虚构的。
"""
from __future__ import annotations
import pytest
from app.core import artifact_store
from app.engine import verify_agent
from app.engine.inquiry import NO_ANSWER
from tests.test_inquiry_api import ( # noqa: F401 - 夹具按名字引入
EAST,
NO_TEST,
QUESTION,
Models,
Play,
ask,
client,
engine_up,
events,
finish,
pause,
pending,
report_doc,
set_verify,
shop,
unit_of,
)
from tests.test_verify_rationale_rules import slow_final
#: 依据写进了括号:开头跟着逗号的、只有依据的
BRACKETED = ("仍居各区域之首([[see:Q1]],已排除测试账号):华东为 [[v:Q1.r0.销售额]]([[see:Q1]]),"
"华南为 [[v:Q1.r1.销售额]](同口径,[[see:Q1]])。")
BRACKETED_TEXT = "仍居各区域之首(已排除测试账号):华东为 1,200,华南为 900(同口径)。"
#: 两个只装着依据的括号连着写、数字包着粗体(复核第 2 轮):去掉括号之后两个数不能挨成一个(「1,200900」),句末前悬着的
#: 逗号也去掉
TOGETHER = ("华东、华南分别为 **[[v:Q1.r0.销售额]]**([[see:Q1]])([[see:Q1]])[[v:Q1.r1.销售额]],"
"华东仍居各区域之首,([[see:Q1]])([[see:Q1]])。")
TOGETHER_TEXT = "华东、华南分别为 1,200 900,华东仍居各区域之首。"
#: 依据直接夹在两个数之间(两边什么都没有)、写进方头括号(复核第 3 轮):两个数不能挨成一个,括号不能剩下「【】」
BARE = "华东、华南分别为 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r1.销售额]],华东仍居各区域之首【[[see:Q1]]】。"
BARE_TEXT = "华东、华南分别为 1,200 900,华东仍居各区域之首。"
async def _asked(client, shop) -> tuple[dict, list]:
paused = await pause(shop)
[approval] = await pending(paused.id)
r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id)
assert r.status_code == 200, r.text
return r.json()["inquiry"], await events(paused.id, "approval.inquiry")
@pytest.mark.parametrize(("said", "shown"), [(BRACKETED, BRACKETED_TEXT), (TOGETHER, TOGETHER_TEXT), (BARE, BARE_TEXT)],
ids=["bracketed", "together", "bare"])
async def test_see_in_brackets_does_not_break_the_answer(monkeypatch, shop, client, said, shown):
Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], said)})
item, [event] = await _asked(client, shop)
answer = item["answer"]
assert answer["ok"] is True and answer["text"] == shown
doc = artifact_store.load(answer["doc_artifact"])
assert doc["markdown"] == shown and doc["source"] == said
assert doc["violations"] == []
assert event.data["answer_text"] == shown
async def test_a_final_answer_that_runs_out_of_time_reports_the_time_limit(monkeypatch, shop, client):
"""质询 agent 查完了、自己停下,交回答那次调用超过时长上限:报错说时长上限,limit 也是 timeout_s(事件里同样)。"""
await set_verify({"inquiry": {"timeout_s": 0.5}})
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.3)
models = Models(monkeypatch, shop)
slow_final(monkeypatch)
item, [event] = await _asked(client, shop)
answer = item["answer"]
assert answer["ok"] is False and answer["text"] == "" and answer["doc_artifact"] is None
assert answer["error"] == "回答超过时长上限(0.5 秒),未能给出回答,可缩小问题范围后重新提问"
assert answer["limit"] == "timeout_s"
assert [q["sql"] for q in item["queries"]] == [NO_TEST], "查到的照记"
assert (event.data["limit"], event.data["error"]) == ("timeout_s", answer["error"])
assert models.finals == [], "交回答那次没等到回复"
async def test_a_placeholder_answer_is_no_answer(monkeypatch, shop, client):
Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], "placeholder")})
item, _ = await _asked(client, shop)
answer = item["answer"]
assert (answer["ok"], answer["text"], answer["limit"]) == (False, "", None)
assert answer["error"] == NO_ANSWER
async def test_an_answer_that_shows_nothing_is_no_answer_even_after_the_seal(monkeypatch, shop, client):
"""封存之后的追问:回答只写了依据,显示出来是空的。以前回答文档照样组装,末尾补上「查的是现在的数据」那句说明,
回答正文只剩系统说明、却记成回答成功。"""
Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], "([[see:Q1]])")})
done = await finish(shop)
assert done.status == "succeeded", done.error
r = await ask(client, done.id, report="write", unit=unit_of(await report_doc(done.id), EAST), question=QUESTION)
assert r.status_code == 200, r.text
answer = r.json()["inquiry"]["answer"]
assert (answer["ok"], answer["text"], answer["doc_artifact"]) == (False, "", None)
assert answer["error"] == NO_ANSWER
async def test_a_one_word_answer_is_an_answer(monkeypatch, shop, client):
"""问的是「有没有」,答「无。」:这是回答,不是占位词,不能报「模型没有给出回答内容」让提问人重问、再花一次钱。"""
Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], "无。[[see:Q1]]")})
item, [event] = await _asked(client, shop)
answer = item["answer"]
assert (answer["ok"], answer["text"], answer["error"]) == (True, "无。", None)
assert event.data["answer_text"] == "无。"
@@ -0,0 +1,243 @@
"""核查 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()]
+139
View File
@@ -0,0 +1,139 @@
"""核查 agent 收尾超时之后,反方审稿、双盲复算对外怎么说(期 5 真实模型验收 M1 的复核)。
核查 agent 查完了、自己停下,交结果那次调用超过了时长上限:verify_agent 照实记 limit=timeout_s(以前模型自己停下时是
None,test_verify_rationale_rules 钉住了这一层)。两个节点据此按时长上限说,不再把核查 agent 的原话(「收尾时超过时长
上限,没能交结果」)摆上审批卡、时间线:
- 反方审稿:这句计入 challenge_limit(limit=timeout_s,claims_skipped 算上它),不再记成 challenge_unreviewed;审稿记录
里这句的理由是「反查未完成:已达上限(每句时长上限 N 秒)」。时长按剩余时间收紧过的那组带 cause=run_deadline,理由
说运行时长上限将至
- 双盲复算:无法复算的原因是「盲算已达每项时长上限(N 秒)」,发 dual_check_limit;收紧过的说运行时长上限将至,不发
dual_check_limit(它叫人调高每项的上限,调高了只会更快撞线),时间线另起一条带 cause 的 dual_check_unchecked
交结果那次用 slow_final 拖住,收尾宽限调到 0.3 秒;收紧过的那组把 allot() 看到的钟拨到「给下游留出余量、扣掉收尾宽限
之后只剩 0.8 秒」(真的钟照走,核查 agent 按真的 0.8 秒到点)。模型一律是 MockChatModel 剧本,数据源是现造的 SQLite
文件,区域、金额都是虚构的。
"""
from __future__ import annotations
import pytest
from app.core import artifact_store
from app.engine import deadline, verify_agent
from tests.fixtures.dual_check_shop import RIGHT, Blind
from tests.test_challenge_node import ( # noqa: F401 - 夹具按名字引入
Models,
engine_up,
finish,
graph,
logs,
output_of,
shop,
)
from tests.test_dual_check_node import clean_settings, dual_output, events, start # noqa: F401
from tests.test_dual_check_node import graph as dual_graph
from tests.test_dual_check_node import logs as dual_logs
from tests.test_dual_check_node import shop as dual_shop # noqa: F401
from tests.test_verify_rationale_rules import slow_final
#: 收紧过的那组:allot() 算出的查库时长
ROOM = 0.8
#: 收紧过的时长到点时,反方审稿每句的理由、双盲复算的无法复算原因(默认 600 秒的一段)
SQUEEZED_CLAIM = "反查未完成:已达上限(本次运行即将达到 600 秒的时长上限,反查时长已按剩余时间收紧)"
SQUEEZED_BLIND = "本次运行即将达到 600 秒的时长上限,盲算时长已按剩余时间收紧,到时未能完成"
@pytest.fixture
def settles_slowly(monkeypatch):
"""交结果那次调用拖 5 秒,收尾宽限只给 0.3 秒:查完了的核查一律在收尾时超时。"""
monkeypatch.setattr(verify_agent, "SETTLE_GRACE_S", 0.3)
slow_final(monkeypatch)
def squeeze(monkeypatch) -> None:
"""allot() 看到的「现在」:这一段的截止时刻扣掉给下游的余量(下游只有成果节点,一份)、收尾宽限,再往前 ROOM 秒。
每开始一项都一样,所以每一项的时长都收紧到 ROOM。"""
def now() -> float:
found = deadline.current()
return found.deadline - deadline.reserve(found.seconds) - verify_agent.SETTLE_GRACE_S - ROOM - 0.05
monkeypatch.setattr(deadline, "_now", now)
# --------------------------------------------------------------------------
# 反方审稿
# --------------------------------------------------------------------------
async def test_a_claim_whose_verdict_runs_out_of_time_counts_as_hitting_the_limit(monkeypatch, shop, settles_slowly):
models = Models(monkeypatch, shop)
row = await finish(graph(shop, timeout_s=0.5), run_class="exploratory")
assert row.status == "succeeded", row.error
assert [kind for kind, _, _ in models.calls].count("final") == 0, "交结论那次都没等到回复"
[limit] = await logs(row.id, "challenge_limit")
assert (limit["limit"], limit["claims_skipped"]) == ("timeout_s", 2)
assert "cause" not in limit and "每句时长上限 0.5 秒" in limit["message"] and "2 句未能给出结论" in limit["message"]
assert await logs(row.id, "challenge_unreviewed") == [], "触了上限的由 challenge_limit 说,不另记成未能反查"
out = await output_of(row.id, "chal")
assert out["counts"]["inconclusive"] == 2 and out["unreviewed"] == 2
record = artifact_store.load(out["challenge_artifact"])
for claim in record["claims"]:
assert claim["verdict"] == "inconclusive" and claim["rationale_doc"] is None
assert claim["rationale"] == "反查未完成:已达上限(每句时长上限 0.5 秒)"
assert len(claim["queries"]) == 1, "查到的照记"
assert "没能交结果" not in str(record)
assert row.output["_issuance"]["tier"] == "formal", "没给出结论的句子不让出具降档"
async def test_a_squeezed_claim_whose_verdict_runs_out_of_time_says_the_run_deadline(monkeypatch, shop,
settles_slowly):
Models(monkeypatch, shop)
squeeze(monkeypatch)
row = await finish(graph(shop), run_class="exploratory")
assert row.status == "succeeded", row.error
[limit] = await logs(row.id, "challenge_limit")
assert (limit["limit"], limit["claims_skipped"], limit["claims"]) == ("timeout_s", 2, 2)
assert limit["cause"] == deadline.RUN_DEADLINE and limit["run_seconds"] == 600
assert "每句时长上限" not in limit["message"], "收紧过的不叫人调高每句的上限"
assert await logs(row.id, "challenge_unreviewed") == []
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
assert [c["rationale"] for c in record["claims"]] == [SQUEEZED_CLAIM, SQUEEZED_CLAIM]
# --------------------------------------------------------------------------
# 双盲复算
# --------------------------------------------------------------------------
async def test_a_blind_check_whose_result_runs_out_of_time_names_the_time_limit(monkeypatch, dual_shop,
settles_slowly):
name, _ = dual_shop
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(dual_graph(name, RIGHT), options={"verify": {"dual_check": {"timeout_s": 0.5}}})
assert run.status == "succeeded", run.error
assert blind.runs == 0, "交结果那次没等到回复"
evs = await events(run.id)
assert [(d["limit"], d["metric"]) for d in dual_logs(evs, "dual_check_limit")] == [("timeout_s", "gmv")]
out = dual_output(run, evs)
assert out["status"] == "unchecked" and out["metrics"][0]["status"] == "unchecked"
assert out["metrics"][0]["reason"] == "盲算已达每项时长上限(0.5 秒)"
assert out["issuance_gaps"] == ["指标「销售额」无法复算:盲算已达每项时长上限(0.5 秒)"]
[said] = dual_logs(evs, "dual_check_unchecked")
assert "cause" not in said and said["metrics"][0]["reason"] == "盲算已达每项时长上限(0.5 秒)"
assert "没能交结果" not in str(out)
async def test_a_squeezed_blind_check_whose_result_runs_out_of_time_says_the_run_deadline(monkeypatch, dual_shop,
settles_slowly):
name, _ = dual_shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
squeeze(monkeypatch)
run = await start(dual_graph(name, RIGHT))
assert run.status == "succeeded", run.error
evs = await events(run.id)
assert dual_logs(evs, "dual_check_limit") == [], "收紧过的不叫人调高每项的上限"
out = dual_output(run, evs)
assert out["metrics"][0]["status"] == "unchecked" and out["metrics"][0]["reason"] == SQUEEZED_BLIND
[said] = dual_logs(evs, "dual_check_unchecked")
assert said["cause"] == deadline.RUN_DEADLINE and said["run_seconds"] == 600