From d0e9c6d1e7622524d24b8a7f70188969b8b3820d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 00:04:41 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=86=99=E8=BF=9B=E6=8B=AC=E5=8F=B7=E3=80=81=E5=A4=B9=E5=9C=A8?= =?UTF-8?q?=E6=A0=87=E7=82=B9=E4=B8=AD=E9=97=B4=E6=97=B6=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=96=AD=E5=8F=A5=EF=BC=9A=5Ftidy=20?= =?UTF-8?q?=E6=8A=8A=E4=BE=9D=E6=8D=AE=E5=91=A8=E5=9B=B4=E7=95=99=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E6=8B=AC=E5=8F=B7=E3=80=81=E6=A0=87=E7=82=B9=E4=B8=80?= =?UTF-8?q?=E8=B5=B7=E6=94=B6=E6=8B=BE=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实模型验收里,核查 agent 把 [[see:Qn]] 写进括号、句中(「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」 「合计 [[v:Q1.r1.金额合计]]([[see:Q1]])」),see 渲染成空,正文留下「(,原报告…)」「()」,审批质询的回答、反方审稿和 双盲复算的理由都这样,正式运行里随封存永久留着。_tidy 以前只去掉 see 前面的空白。 - 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉,括号前的空白照依据前的空白处理 - 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理 - 后面就到句末(句末标点、换行、全文结尾)、前面或几个依据之间有停顿标点的,去掉这些停顿标点(冒号不动:它引出的 可能是下一行的表格);依据夹在两个分隔标点中间的,去掉后一个;依据后面、句末前面的标点是写作者自己的,不动 - 两边都是字时,依据之间的分隔标点可能就是分句的标点,按它断开、照旧处理 - 依据标记本身留在原文里:句子照旧挂着那条依据(unit.see、cites 不变),只是周围的字不显示 - 流式渲染把末尾的括号、分隔标点也攒到后面的字来了再放(_TRAILING_SEE),分块输出拼起来仍等于整篇渲染 不含依据的括号、没有依据的文档一个字不动:两份文档的内容哈希和改动之前(58a6759)一致,对照基线随机渲染 6 万段, 改了的都是依据挨着括号、分隔标点的,不含依据的 2 万段逐字相同。封存过的文档复核不重新收拾原文,照旧通过。 --- backend/app/engine/evidence.py | 129 +++++++++-- backend/tests/test_evidence_see_brackets.py | 237 ++++++++++++++++++++ 2 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_evidence_see_brackets.py diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 6d62b2f5..4e89fe44 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1811,8 +1811,24 @@ _UNIT_KINDS = frozenset({"m", "i", "v"}) #: 渲染结果末尾的单位:最后一个数字后面的字(12,345人次 → 人次,4.57万元 → 万元,12.5% → %) _UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$") _BLANK = " \t\u3000" -_SEE_RUN = re.compile(r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]" - r"(?:[" + _BLANK + r"]*\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*") +#: 依据标记 [[see:…]](同 MARKER_RE,只认 MARKER_MAX 以内的) +_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]" +#: 括号:全角、半角(模型偶尔一对里混用,也算一对) +_OPEN, _CLOSE = frozenset("(("), frozenset("))") +#: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」) +_SEPS = ",、;,;::" +_SEP = frozenset(_SEPS) +#: 其中的停顿标点(不含冒号):依据挂在它后面、紧接着就到句末时,它悬在句末(「华南居首,。」)。冒号后面另起一行 +#: 常常是它引出的表格、列表,不动 +_PAUSE = _SEP - frozenset("::") +#: 句末标点:停顿标点悬在它前面时去掉(还有换行、全文结尾)。半角句点不算:后面跟着数字时它是小数点 +_ENDING = frozenset("。!?!?") +#: 依据周围、渲染后会挤在一起的字:空白和分隔标点 +_GAP = frozenset(_BLANK) | _SEP +#: 连着写的几个依据:中间只隔着空白、分隔标点 +_SEE_GROUP = re.compile(_SEE_MARK + r"(?:[" + _BLANK + _SEPS + r"]*" + _SEE_MARK + r")*") +#: 其中只隔着空白的一段(两边都是字时,依据之间的分隔标点可能就是分句的标点,按它断开各算各的) +_SEE_RUN = re.compile(_SEE_MARK + r"(?:[" + _BLANK + r"]*" + _SEE_MARK + r")*") def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, @@ -1830,8 +1846,8 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: - 数字标记后面重复写的单位去掉。[[m:visits]] 渲染出来已经带单位(12,345人次),写作者常常 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时 不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来; - - [[see:…]] 前面的空白去掉。see 渲染成空,「最高月份 [[see:Q2]]。」会剩下「最高月份 。」。 - see 后面紧跟着字、要靠这个空白隔开的(「A [[see:Q1]]B」)留着;行首的缩进不动。 + - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_see_cuts):「最高月份 [[see:Q2]]。」会剩下 + 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。 prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。 """ @@ -1847,29 +1863,96 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: after = text[hit.end():hit.end() + 1] if len(unit) > 1 or not after.isalnum(): cuts.append((marker["end"], hit.end())) - for run in _SEE_RUN.finditer(text): - start = run.start() - while start > 0 and text[start - 1] in _BLANK: - start -= 1 - before = text[start - 1] if start > 0 else prev - after = text[run.end():run.end() + 1] - # 几个 see 之间的空白也一样渲染成多余的空格 - parts = list(MARKER_RE.finditer(text, run.start(), run.end())) - cuts.extend((m.end(), n.start()) for m, n in zip(parts, parts[1:]) if m.end() < n.start()) - if start < run.start() and before not in ("", "\n") and not (after.isalnum() or after == "["): - cuts.append((start, run.start())) + for group in _SEE_GROUP.finditer(text): + cuts.extend(_see_cuts(text, group.start(), group.end(), prev)) if not cuts: return text + # 几处要去掉的字可能重叠(依据前面的空白、它和括号之间的标点):按并集去掉 out, last = [], 0 for a, b in sorted(cuts): - if a < last: - continue - out.append(text[last:a]) - last = b + if a >= last: + out.append(text[last:a]) + last = b + else: + last = max(last, b) out.append(text[last:]) return "".join(out) +def _see_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: + """一组依据(_SEE_GROUP,text[start:end])渲染成空之后,周围要一起去掉的字。标记本身留着:组装文档时句子靠它挂 + 依据(unit.see),渲染时它本来就不显示。 + + - 括号里只有依据的(「合计 6,635.68([[see:Q1]])。」),连括号一起去掉,括号前的空白照依据前的空白处理 + - 依据在括号开头(「([[see:Q3]],原报告…)」):它和括号之间、它后面的标点和空白;在括号末尾(「(同 SQL, + [[see:Q2]])」)同理 + - 后面就到句末(句末标点、换行、全文结尾),依据前面、几个依据之间有停顿标点的(「华南居首,[[see:Q1]]。」): + 这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表) + - 以上几种里,几个依据之间的空白、分隔标点一并去掉(「([[see:Q1]]、[[see:Q2]])」) + - 两边都是字的,依据之间的分隔标点可能就是分句的标点:按它断开,每段照 _run_cuts + + 不含依据的括号、标点一个字不动。要看的只有依据前后连着的空白、括号、分隔标点,外加再往外的一个字:流式渲染把 + 这些字攒到后面的字来了才放(_TRAILING_SEE),分块渲染和整篇渲染判得一样。prev 是这段文字前面那个字。 + """ + marks = list(MARKER_RE.finditer(text, start, end)) + gaps = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] + lo, hi = start, end + while lo > 0 and text[lo - 1] in _GAP: + lo -= 1 + while hi < len(text) and text[hi] in _GAP: + hi += 1 + opener = text[lo - 1] if lo > 0 else prev + closer = text[hi] if hi < len(text) else "" + left, right = text[lo:start], text[end:hi] + if opener in _OPEN and closer in _CLOSE: + # 括号里只有依据:连括号一起去掉 + return [(lo - 1, start), *gaps, (end, hi + 1), *_blank_cut(text, lo - 1, hi + 1, prev)] + if opener in _OPEN: + return [(lo, start), *gaps, (end, hi)] + if closer in _CLOSE: + # 和括号之间只有空白的,照依据前的空白处理(行首的缩进不动) + return [*gaps, (end, hi), *([(lo, start)] if _SEP & set(left) else _blank_cut(text, start, end, prev))] + if (closer in _ENDING or closer in ("", "\n")) and _PAUSE & set(left + "".join(text[a:b] for a, b in gaps)): + # 依据后面、句末前面的停顿标点是写作者自己的(「增长 [[see:Q1]],」),不是依据留下的,不动;只剩空白的去掉 + return [*gaps, *([] if _SEP & set(right) else [(end, hi)]), + *([(lo, start)] if _PAUSE & set(left) else _blank_cut(text, start, end, prev))] + return [cut for run in _SEE_RUN.finditer(text, start, end) for cut in _run_cuts(text, run.start(), run.end(), prev)] + + +def _run_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: + """两边都是字的一段依据(_SEE_RUN,中间只隔着空白): + + - 几个依据之间的空白,和依据前面的空白(_blank_cut) + - 夹在两个分隔标点中间(「结果一致,[[see:Q1]],原报告…」):去掉后面那个(连同它前面的空白,后面的空白留着 + 隔开下文:「a, [[see:Q1]], b」→「a, b」) + """ + marks = list(MARKER_RE.finditer(text, start, end)) + cuts = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] + cuts += _blank_cut(text, start, end, prev) + lo, hi = start, end + while lo > 0 and text[lo - 1] in _GAP: + lo -= 1 + while hi < len(text) and text[hi] in _GAP: + hi += 1 + right = text[end:hi] + if _SEP & set(text[lo:start]) and _SEP & set(right): + cuts.append((end, end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1)) + return cuts + + +def _blank_cut(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: + """text[start:end](依据,或者只装着依据的一对括号)渲染成空,它前面的空白去掉:「最高月份 [[see:Q2]]。」会剩下 + 「最高月份 。」。后面紧跟着字、要靠这个空白隔开的(「A [[see:Q1]]B」)留着;行首的缩进不动。""" + blank = start + while blank > 0 and text[blank - 1] in _BLANK: + blank -= 1 + before = text[blank - 1] if blank > 0 else prev + after = text[end:end + 1] + if blank < start and before not in ("", "\n") and not (after.isalnum() or after == "["): + return [(blank, start)] + return [] + + def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str: text = _tidy(_expand_tables(text, catalog, snaps, cells_allowed, prev), catalog, snaps, cells_allowed, prev) out: list[str] = [] @@ -1893,8 +1976,9 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool = # 流式渲染:让 llm.token 里出现的就是最终的数字 # -------------------------------------------------------------------------- -#: 文字末尾的空白和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy -_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + r"]|\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])+$") +#: 文字末尾的空白、括号、分隔标点和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy(依据周围的这些字 +#: 去不去掉,要看再往后的那个字,见 _see_cuts) +_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + "(())" + _SEPS + r"]|" + _SEE_MARK + r")+$") def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int: @@ -1905,7 +1989,7 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non 开头可能是另一个 [。 整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。 - _tidy 要往后看的也先不放:末尾的空白和 [[see:…]](后面来的字决定空白去不去掉), + _tidy 要往后看的也先不放:末尾的空白、括号、分隔标点和 [[see:…]](后面来的字决定它们去不去掉), 以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断一个 标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。 """ @@ -3055,7 +3139,6 @@ SEE_AND_CODE_RULES = ( #: 句中的 [[see:]]:前面是介词、后面接着句子的写法(「以 [[see:Q1]] 为依据」「根据 [[see:Q1]],」「详见 [[see:Q1]]」)。 #: 只认这几种拿得准的:「以」前面是「所、可、加……」的是另一个词(所以、可以),不算;「依据 [[see:…]]。」可能是 #: 「这是结论的依据」,不算。认出来就交回写作者重写(report.REWRITE_ONLY:重写不好也不判失败) -_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]" _INLINE_SEE = re.compile( r"(? 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"), + # 前面是分隔标点、后面就到句末:去掉前面那个 + ("华南居首,[[see:Q1]]。", "华南居首。"), + ("华南居首、华东第二;[[see:Q1]]\n下一行", "华南居首、华东第二\n下一行"), + ("华南居首,[[see:Q1]]", "华南居首"), + ("华南居首,[[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:m:gmv]]华东第二。", "华南居首,华东第二。"), + ("华南居首 [[see:Q1]] [[see:m:gmv]] 。", "华南居首 。"), # 没有悬空的标点:照旧只去掉依据前的空白 + ("华南居首 [[see:Q1]],", "华南居首,"), # 依据后面的逗号是写作者自己的,不动 + ("数据如下:[[see:Q1]]\n| 区域 | 销售额 |", "数据如下:\n| 区域 | 销售额 |"), # 冒号引出的是下一行 + ("结果见:[[see:Q1]]。", "结果见:。"), # 冒号前的引出语去不掉:靠提示词让模型写在句末 + ("依据 [[see:Q1]]。", "依据。"), +]) +def test_see_outside_brackets_without_dangling_pauses_renders_as_before(catalog, raw, shown): + assert render(raw, catalog) == shown + + +@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]])", +]) +@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", "文字", "元", "[[", "]]", "["] + 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 From 735dc3205255a84220d9430eecbf4e7978fb73db Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 00:05:17 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=A0=B8=E6=9F=A5=20agent=20=E7=9A=84?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E7=85=A7=E5=86=99=E4=BD=9C=E8=80=85=E7=9A=84?= =?UTF-8?q?=E8=A7=84=E7=9F=A9=E6=8C=82=E5=9C=A8=E5=8F=A5=E6=9C=AB=EF=BC=9B?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=E8=B6=85=E6=97=B6=E7=85=A7=E5=AE=9E=E8=AE=B0?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E4=B8=8A=E9=99=90=EF=BC=9B=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E8=AF=8D=E4=B8=8D=E5=BD=93=E7=90=86=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 交结果的提示(VERIFY_FINAL_PROMPT,双盲复算、反方审稿、审批质询共用)以前写「提到某次查询时写 [[see:Q1]]」,模型就 把它写进括号、句中;反方审稿的系统提示自己也把它写在括号里(「([[v:Qn.rX.列名]] 或 [[see:Qn]])」)。改成和写作者 一致:依据挂在句末(「……结果一致。[[see:Q3]]」),说明它渲染后不显示,不要放进括号、句中或「依据」「见」「取自」 后面。已经写进括号的由上一个提交的渲染收拾干净 - 模型自己停了查询、收尾那次调用超过时长上限时,limit 以前是 None:质询的报错写着「回答超过时长上限(180 秒)」, answer.limit 却是 null,界面少了「已达时长上限」;双盲复算、反方审稿把它当成别的原因,审批卡上照出内部原话。 现在记 timeout_s;循环里先撞上的上限照旧优先(同金额被拒的收尾),结构化输出、退回普通调用两条路都一样 - 交回的理由是空的、只有标点,或者只是一个占位词(验收里盲算交了字面的「placeholder」,审批卡照着显示):当没给 理由,不组装理由文档。反方审稿的记录里理由为空,审批卡照旧写「反方没有给出理由」;质询照「模型没有给出回答内容」 报错;双盲复算的对照工件里说明为空 测试:交给模型的提示(含三处调用方的系统提示、任务)里不再示范括号里的依据;写进括号的依据在理由文档里渲染干净、 仍挂在句子上;审批质询的回答、反方审稿的理由、双盲复算的说明端到端都没有空括号;收尾超时 limit 为 timeout_s(核查 agent 和质询接口、事件里都是);占位词的理由三处都不当理由。 --- backend/app/engine/nodes/challenge.py | 2 +- backend/app/engine/verify_agent.py | 28 +++- .../tests/test_challenge_rationale_rules.py | 64 ++++++++ .../tests/test_dual_check_rationale_rules.py | 42 +++++ backend/tests/test_inquiry_rationale_rules.py | 76 +++++++++ backend/tests/test_verify_rationale_rules.py | 144 ++++++++++++++++++ 6 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_challenge_rationale_rules.py create mode 100644 backend/tests/test_dual_check_rationale_rules.py create mode 100644 backend/tests/test_inquiry_rationale_rules.py create mode 100644 backend/tests/test_verify_rationale_rules.py diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 00f0c22d..a782efc4 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -117,7 +117,7 @@ CHALLENGE_SYSTEM = ( "原因说过了头;refuted 查到的数据和这句结论相矛盾;inconclusive 查不到判断所需的数据\n" "- 没有找到反例不等于结论被推翻:只有数据和结论冲突时才判 refuted\n" "- 结论要有出处:一条查询都没执行成功时,判什么都不会被采信;判 refuted 时,理由里至少要引用一处你自己查询的" - "结果([[v:Qn.rX.列名]] 或 [[see:Qn]]),否则同样不会被采信" + "结果:写 [[v:Qn.rX.列名]] 引用一格,或者在那句的句末写 [[see:Qn]],否则同样不会被采信" ) CHALLENGE_TASK_PROMPT = ( "要审的结论(报告「{report}」中的一句{section}):\n「{claim}」\n\n" diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index fd7b3bf8..77ea2ea3 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -124,7 +124,10 @@ VERIFY_FINAL_PROMPT = ( "row 是行号(从 0 数),column 是列名。系统按引用从查询结果里取值,你自己写的数字不会被采用;查不到就写 null," "不要估算\n" "- rationale 写理由,简短说清依据。理由里出现的每个数字都写成 [[v:Q1.r0.列名]] 这样的引用,系统会换成查询结果" - "里的值;不要直接写数字,提到某次查询时写 [[see:Q1]]\n" + "里的值;不要直接写数字\n" + "- 要指明一句话依据的是哪次查询,在这句的句末写 [[see:Q1]],比如「……结果一致。[[see:Q3]]」。[[see:…]] 渲染后" + "不显示,只能挂在句末:不要放进括号,也不要写进句子中间、写在「依据」「见」「取自」这类字后面,否则会留下「()」" + "「(,」「依据。」这样的断句\n" "- 其余字段按各自的说明填写" ) _CELL_REF: dict[str, Any] = { @@ -662,6 +665,20 @@ def _parsed(value: Any) -> dict[str, Any] | None: return value if isinstance(value, dict) else None +#: 只是占位词的理由(只留字母、数字和汉字,不分大小写):模型照着结构填了个字面的 placeholder、N/A,等于没给理由 +_PLACEHOLDERS = frozenset({"placeholder", "todo", "tbd", "na", "none", "null", "nil", "rationale", "reason", + "无", "暂无", "略", "理由"}) + + +def _no_rationale(value: Any) -> bool: + """交回的理由等于没给:不是文字、空的、只有标点,或者只是一个占位词。当没给理由处理(理由为空、没有理由文档), + 审批卡照「没有给出理由」说,不把占位词当理由展示。""" + if not isinstance(value, str): + return True + word = "".join(ch for ch in value if ch.isalnum()).casefold() + return not word or word in _PLACEHOLDERS + + @dataclass class _State: """一次核查过程中攒下的东西:出意外时也照这些交回,用了多少、查了什么不丢。""" @@ -1007,10 +1024,13 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou if parsed is None: if settle_cut: return await report_cut("verify_result", settler.max_tokens) - why = "收尾时超过时长上限,没能交结果" if isinstance(failure, asyncio.TimeoutError) else \ + timed_out = isinstance(failure, asyncio.TimeoutError) + why = "收尾时超过时长上限,没能交结果" if timed_out else \ f"核查模型没有按结构交结果({explain_model_error(failure, model_id)})" if failure is not None else \ "核查模型没有按结构交结果" - return outcome(limit=stopped, answered=answered, error=why) + # 收尾超时也是触了时长上限:模型自己停了查询(stopped 为 None)时照实记 timeout_s,和原因对得上(质询的报错说 + # 时长上限,界面按 limit 标「已达时长上限」)。循环里先撞上的上限照旧优先,同上面金额被拒的收尾 + return outcome(limit=stopped or ("timeout_s" if timed_out else None), answered=answered, error=why) declared = list((resolved.get("properties") or {}) if _is_object(resolved) else {}) if declared and not any(k in parsed for k in declared): # 一个要求的字段都没交:不是「查不到」,是没按结构交。不这么分,字段名对不上的回复会被当成什么都没查到 @@ -1023,7 +1043,7 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou result = cells.walk(resolved, parsed) if _is_object(resolved) else {} rationale = parsed.get(RATIONALE) doc = None - if isinstance(rationale, str) and rationale.strip(): + if not _no_rationale(rationale): doc = compose_doc(rationale, build_catalog(nodes={}, ledger=ledger), node_id=owner, run_id=run_id or None) result[RATIONALE] = doc["markdown"] if doc else "" return outcome(result=result, cells=cells.cells, rationale=doc, limit=stopped, answered=answered) diff --git a/backend/tests/test_challenge_rationale_rules.py b/backend/tests/test_challenge_rationale_rules.py new file mode 100644 index 00000000..d7a22d92 --- /dev/null +++ b/backend/tests/test_challenge_rationale_rules.py @@ -0,0 +1,64 @@ +"""反方审稿的理由:依据写进括号不再断句,占位词不当理由(期 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_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" diff --git a/backend/tests/test_dual_check_rationale_rules.py b/backend/tests/test_dual_check_rationale_rules.py new file mode 100644 index 00000000..9f9fc7e0 --- /dev/null +++ b/backend/tests/test_dual_check_rationale_rules.py @@ -0,0 +1,42 @@ +"""双盲复算的说明:依据写进括号不再断句,占位词不当说明(期 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_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) diff --git a/backend/tests/test_inquiry_rationale_rules.py b/backend/tests/test_inquiry_rationale_rules.py new file mode 100644 index 00000000..c8a4c604 --- /dev/null +++ b/backend/tests/test_inquiry_rationale_rules.py @@ -0,0 +1,76 @@ +"""审批质询的回答:依据写进括号不再断句,收尾超时记时长上限,占位词不当回答(期 5 真实模型验收 M1)。 + +真实模型验收里,质询 agent 写的「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计 [[v:Q1.r1.金额合计]] +([[see:Q1]])」渲染成「(,原报告…)」「()」,随正式运行封存;另一次回答查完了,收尾那次调用超过时长上限,报错写着 +「回答超过时长上限(180 秒)」,answer.limit 却是 null,界面上缺了「已达时长上限」。 + +模型一律是 MockChatModel 剧本,区域、金额都是虚构的。 +""" +from __future__ import annotations + +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 - 夹具按名字引入 + NO_TEST, + QUESTION, + Models, + Play, + ask, + client, + engine_up, + events, + pause, + pending, + set_verify, + shop, +) +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(同口径)。" + + +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") + + +async def test_see_in_brackets_does_not_break_the_answer(monkeypatch, shop, client): + Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], BRACKETED)}) + item, [event] = await _asked(client, shop) + answer = item["answer"] + assert answer["ok"] is True and answer["text"] == BRACKETED_TEXT + doc = artifact_store.load(answer["doc_artifact"]) + assert doc["markdown"] == BRACKETED_TEXT and doc["source"] == BRACKETED + assert doc["violations"] == [] + assert event.data["answer_text"] == BRACKETED_TEXT + + +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 diff --git a/backend/tests/test_verify_rationale_rules.py b/backend/tests/test_verify_rationale_rules.py new file mode 100644 index 00000000..d3957a90 --- /dev/null +++ b/backend/tests/test_verify_rationale_rules.py @@ -0,0 +1,144 @@ +"""核查 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):当作没给理由,不把占位词当理由展示 + +模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,数字都是虚构的。 +""" +from __future__ import annotations + +import asyncio +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_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"] == [] + + +# -------------------------------------------------------------------------- +# 收尾超时 +# -------------------------------------------------------------------------- + +@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_limit_hit_in_the_loop_is_still_the_one_reported(monkeypatch, shop): + """循环先撞上查询次数上限、收尾又超时:触顶原因照旧报先撞上的那个(同金额被拒的收尾,见 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 out.result is None and out.error == "收尾时超过时长上限,没能交结果" + assert out.limit == "max_queries" + + +# -------------------------------------------------------------------------- +# 占位词当理由 +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("said", ["placeholder", "PLACEHOLDER", " ", "N/A", "(略)", "…", "", "TODO"]) +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", ["无异常。", "见 [[v:Q1.r0.total]]。", "略高于上周。"]) +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 From 7de4aa15502372c35e9bcdf1b55e15c24867d920 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 00:09:46 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=94=B6=E5=B0=BE=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=B8=80=E5=BE=8B=E8=AE=B0=E6=97=B6=E9=95=BF=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=9A=E5=BE=AA=E7=8E=AF=E9=87=8C=E5=85=88=E6=9F=A5=E6=BB=A1?= =?UTF-8?q?=E4=BA=86=E6=AC=A1=E6=95=B0=E3=80=81=E5=86=8D=E5=9C=A8=E4=BA=A4?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=97=B6=E8=B6=85=E6=97=B6=E7=9A=84=EF=BC=8C?= =?UTF-8?q?limit=20=E4=B9=9F=E6=98=AF=20timeout=5Fs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交在模型自己停了查询时补上了 timeout_s,但循环里先撞上别的上限(查询次数、轮数)、交结果那次又超时的,仍记 先撞上的那个:质询会显示「未能回答(已达查询次数上限):回答超过时长上限…」,自相矛盾;双盲复算会说盲算查满了次数、 反方审稿说「每句最多查询 N 次」——查满了照样能交结果,没交成是因为超时。现在交结果那次超时一律记 timeout_s,和报错 原因一致(运行时长上限将至、时长被收紧过的,双盲复算、反方审稿照旧按 timeout_s 说成运行时长上限将至)。金额被拒的 收尾不变,仍报循环里先撞上的那个(test_verify_truncation 钉着它,原因见那里)。 --- backend/app/engine/verify_agent.py | 7 ++++--- backend/tests/test_verify_rationale_rules.py | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index 77ea2ea3..3f410eaa 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -1028,9 +1028,10 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou why = "收尾时超过时长上限,没能交结果" if timed_out else \ f"核查模型没有按结构交结果({explain_model_error(failure, model_id)})" if failure is not None else \ "核查模型没有按结构交结果" - # 收尾超时也是触了时长上限:模型自己停了查询(stopped 为 None)时照实记 timeout_s,和原因对得上(质询的报错说 - # 时长上限,界面按 limit 标「已达时长上限」)。循环里先撞上的上限照旧优先,同上面金额被拒的收尾 - return outcome(limit=stopped or ("timeout_s" if timed_out else None), answered=answered, error=why) + # 收尾超时:没交成结果是因为时长上限,limit 照实记 timeout_s,和原因对得上——模型自己停了查询(stopped 为 None) + # 时以前记成没触顶,质询的报错说时长上限、界面却没有「已达时长上限」;循环里先查满了次数的,记成查询次数上限 + # 会让双盲复算、反方审稿说错原因(查满了照样能交结果)。上面金额被拒的收尾仍报循环里先撞上的那个 + return outcome(limit="timeout_s" if timed_out else stopped, answered=answered, error=why) declared = list((resolved.get("properties") or {}) if _is_object(resolved) else {}) if declared and not any(k in parsed for k in declared): # 一个要求的字段都没交:不是「查不到」,是没按结构交。不这么分,字段名对不上的回复会被当成什么都没查到 diff --git a/backend/tests/test_verify_rationale_rules.py b/backend/tests/test_verify_rationale_rules.py index d3957a90..03490fef 100644 --- a/backend/tests/test_verify_rationale_rules.py +++ b/backend/tests/test_verify_rationale_rules.py @@ -111,16 +111,19 @@ async def test_a_final_call_that_runs_out_of_time_records_the_time_limit(monkeyp assert out.limit == "timeout_s" -async def test_a_limit_hit_in_the_loop_is_still_the_one_reported(monkeypatch, shop): - """循环先撞上查询次数上限、收尾又超时:触顶原因照旧报先撞上的那个(同金额被拒的收尾,见 test_verify_truncation)。""" +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 == "max_queries" + assert out.limit == "timeout_s" # -------------------------------------------------------------------------- From 261636d3b7c000cbe1f0b5e97309ee89de94eac8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 00:10:28 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E6=8B=AC=E5=8F=B7?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=B5=8B=E8=AF=95=E7=9A=84=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E7=85=A7=E6=94=B6=E7=B4=A7=E5=90=8E=E7=9A=84=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=86=99=EF=BC=9A=E5=87=A0=E4=B8=AA=E4=BE=9D=E6=8D=AE=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E7=9A=84=E5=88=86=E9=9A=94=E6=A0=87=E7=82=B9=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E6=8B=AC=E5=8F=B7=E9=87=8C=E3=80=81=E5=8F=A5=E6=9C=AB?= =?UTF-8?q?=E5=89=8D=E4=B8=80=E5=B9=B6=E5=8E=BB=E6=8E=89=EF=BC=8C=E4=B8=A4?= =?UTF-8?q?=E8=BE=B9=E9=83=BD=E6=98=AF=E5=AD=97=E6=97=B6=E7=85=A7=E6=97=A7?= =?UTF-8?q?=E7=95=99=E7=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_evidence_see_brackets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_evidence_see_brackets.py b/backend/tests/test_evidence_see_brackets.py index c6b0571e..d5ef3092 100644 --- a/backend/tests/test_evidence_see_brackets.py +++ b/backend/tests/test_evidence_see_brackets.py @@ -6,8 +6,9 @@ - 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉,括号前的空白照 see 前的空白处理 - 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理 -- 依据夹在两个分隔标点中间的,去掉后一个;前面是「,」「、」「;」、后面就是句末(句号、换行)的,去掉前面那个; - 几个依据之间的分隔标点一律去掉 +- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句号、换行、全文结尾)、前面或几个依据之间有「,」「、」 + 「;」的,去掉这些标点;依据后面、句末前面的标点是写作者自己的,不动 +- 括号里、句末前几个依据之间的分隔标点一并去掉;两边都是字时,依据之间的标点可能是分句的标点,照旧留着 - 依据标记本身留在原文里:句子照旧挂着这条依据(see、cites),只是不显示 - 不含依据的括号、没有依据的文档一个字不动:内容哈希和改动之前一样 - 流式渲染分块输出拼起来仍然等于整篇渲染 From c97390511e30f2176dbb817bd1e3c63c2d3cbd6d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 01:19:26 +0800 Subject: [PATCH 5/7] =?UTF-8?q?M1=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=8F=AA=E8=A3=85=E7=9D=80=E4=BE=9D=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E6=8B=AC=E5=8F=B7=E4=B8=8D=E5=86=8D=E6=8A=8A=E4=B8=A4=E8=BE=B9?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E5=AD=97=E7=B2=98=E6=88=90=E5=8F=A6=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=95=B0=EF=BC=9B=E6=8B=AC=E5=8F=B7=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=E5=A4=96=E9=9D=A2=E7=85=A7=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E5=86=8D=E6=94=B6=E6=8B=BE=EF=BC=9B=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E7=AE=97=E5=8F=A5=E6=9C=AB=EF=BC=9B=E6=A0=B8?= =?UTF-8?q?=E6=9F=A5=20agent=20=E7=9A=84=E7=90=86=E7=94=B1=E6=8C=89?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=90=8E=E7=9A=84=E6=AD=A3=E6=96=87=E5=88=A4?= =?UTF-8?q?=E6=9C=89=E6=B2=A1=E6=9C=89=E7=BB=99=EF=BC=9B=E5=8F=8D=E6=96=B9?= =?UTF-8?q?=E5=AE=A1=E7=A8=BF=E6=94=B6=E5=B0=BE=E8=B6=85=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E4=B8=8A=E9=99=90=E8=AF=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渲染(evidence._see_cuts): - 括号是两边唯一的分隔、去掉之后数字字母会粘成一串的(「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」渲染成「4,919.52 月」,片段 本身没错、月份不算裸数字,哪项核对都查不出来),括号换成一个空格;半角逗号夹着依据、去掉后一个之后同理(「212,5」→ 「212, 5」)。切口可以带替换串,连成一片的按并集换 - 只装着依据的括号去掉之后,整对括号当一个依据看外面:括号前悬着的停顿标点(「华南居首,([[see:Q1]])。」)、到句末只隔着 的空白、夹在两个分隔标点中间的后一个,和不带括号的写法收拾得一样 - 依据后面到句末标点只隔着空白的,空白一起去掉(「华南居首 [[see:Q1]] 。」不再剩「华南居首 。」) - 换行不再算句末:列表项末尾的「;」、软换行处连着两个分句的「,」是写作者自己的,依据挂在它后面正合规矩,照旧留着(上一轮 把它们删了) - 没有依据的文档渲染、内容哈希不变(随机两万份和基线逐份比过);流式分块输出仍等于整篇渲染 - 依据写在「依据」「见」「取自」「来源」这类引出语后面时引出语悬空,渲染时去不掉(引出语在依据前面,流式渲染得攒住每个可能 是引出语的字):测试里改成严格的 xfail 写明已知限制,不再把验收里的缺陷钉成预期;核查 agent 遇到这种写法记一条日志 核查 agent(verify_agent): - 有没有给理由按渲染出来的正文判:只写了依据([[see:Q1]]、「([[see:Q1]])」)显示出来是空的,占位词后面挂着依据的显示出来 只剩占位词,都算没给。以前按原文判,标记里的字也算,理由文档照样交回:审批卡画出一片空白,判「被推翻」的因为引用到了 Q1 被当成有出处、出具降档;封存之后的质询回答只剩系统说明却记成回答成功 - 「无」「暂无」「none」只在理由是另一个结果的说明时(双盲复算、反方审稿)算没给;结构里只有理由一项(审批质询)时理由就是 回答,「无。」是一个回答,不再报「模型没有给出回答内容」让提问人重问、再花一次钱 - 收尾超时的原话收成常量 SETTLE_TIMEOUT,调用方据此换成自己的说法 反方审稿:交结论那次超过时长上限的句子,理由写「反查未完成:已达上限(每句时长上限 N 秒)」(收紧过的说运行时长上限将至), 和时间线上的 challenge_limit 一致,不再把核查 agent 的内部原话摆上审批卡。 测试:补节点级用例钉住收尾超时之后两个节点对外的说法(challenge_limit 的 limit、claims_skipped、cause,双盲复算的无法复算 原因和 dual_check_limit,收紧过的两种);理由只有依据、占位词挂依据、「无。」作为回答的各层用例。每处改动都做过去掉实现、 测试变红的验证。 --- backend/app/engine/evidence.py | 105 +++++++++---- backend/app/engine/nodes/challenge.py | 11 +- backend/app/engine/verify_agent.py | 42 ++++-- .../tests/test_challenge_rationale_rules.py | 21 +++ .../tests/test_dual_check_rationale_rules.py | 9 ++ backend/tests/test_evidence_see_brackets.py | 103 +++++++++++-- backend/tests/test_inquiry_rationale_rules.py | 29 ++++ backend/tests/test_verify_rationale_rules.py | 71 ++++++++- backend/tests/test_verify_settle_timeout.py | 139 ++++++++++++++++++ 9 files changed, 474 insertions(+), 56 deletions(-) create mode 100644 backend/tests/test_verify_settle_timeout.py diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 4e89fe44..c4926905 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1818,11 +1818,17 @@ _OPEN, _CLOSE = frozenset("(("), frozenset("))") #: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」) _SEPS = ",、;,;::" _SEP = frozenset(_SEPS) +#: 其中的半角标点:按排版习惯后面隔一个空格。两个夹着依据的去掉后一个时,前一个不能直接贴着下文(「212,5」读成一个数) +_HALF = frozenset(",;:") #: 其中的停顿标点(不含冒号):依据挂在它后面、紧接着就到句末时,它悬在句末(「华南居首,。」)。冒号后面另起一行 #: 常常是它引出的表格、列表,不动 _PAUSE = _SEP - frozenset("::") -#: 句末标点:停顿标点悬在它前面时去掉(还有换行、全文结尾)。半角句点不算:后面跟着数字时它是小数点 +#: 句末标点:停顿标点悬在它前面时去掉(还有全文结尾)。半角句点不算:后面跟着数字时它是小数点。换行不算:列表项末尾 +#: 的「;」、软换行处连着两个分句的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩 _ENDING = frozenset("。!?!?") +#: 去掉一对括号之后会粘成一串的字:前面是数字、字母(或者标记的结尾、百分号、半角的逗号和小数点),后面是数字、字母 +#: (或者一个渲染出字的标记)。中文和数字之间不加空格也读得对,不算 +_NUMBERISH = frozenset("]%‰.,") #: 依据周围、渲染后会挤在一起的字:空白和分隔标点 _GAP = frozenset(_BLANK) | _SEP #: 连着写的几个依据:中间只隔着空白、分隔标点 @@ -1847,11 +1853,12 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时 不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来; - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_see_cuts):「最高月份 [[see:Q2]]。」会剩下 - 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。 + 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。去掉之后两边的数字、字母 + 会粘成一串的,换成一个空格(_glued)。 prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。 """ - cuts: list[tuple[int, int]] = [] + cuts: list[tuple[int, ...]] = [] for marker in parse_markers(text): if marker["kind"] not in _UNIT_KINDS: continue @@ -1867,34 +1874,43 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: cuts.extend(_see_cuts(text, group.start(), group.end(), prev)) if not cuts: return text - # 几处要去掉的字可能重叠(依据前面的空白、它和括号之间的标点):按并集去掉 - out, last = [], 0 - for a, b in sorted(cuts): - if a >= last: - out.append(text[last:a]) - last = b + # 几处要去掉的字可能重叠(依据前面的空白、它和括号之间的标点):按并集去掉。带着替换的(第三项,去掉之后两边会 + # 粘在一起,换成一个空格)连成一片时整片换成它 + merged: list[list[Any]] = [] + for a, b, *rep in sorted(cuts): + if merged and a < merged[-1][1]: + merged[-1][1] = max(merged[-1][1], b) + merged[-1][2] = merged[-1][2] or "".join(rep) else: - last = max(last, b) + merged.append([a, b, "".join(rep)]) + out, last = [], 0 + for a, b, rep in merged: + out += [text[last:a], rep] + last = b out.append(text[last:]) return "".join(out) -def _see_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: +def _see_cuts(text: str, start: int, end: int, prev: str, *, whole: bool = False) -> list[tuple[int, ...]]: """一组依据(_SEE_GROUP,text[start:end])渲染成空之后,周围要一起去掉的字。标记本身留着:组装文档时句子靠它挂 依据(unit.see),渲染时它本来就不显示。 - - 括号里只有依据的(「合计 6,635.68([[see:Q1]])。」),连括号一起去掉,括号前的空白照依据前的空白处理 + - 括号里只有依据的(「合计 6,635.68([[see:Q1]])。」),连括号一起去掉;去掉之后整对括号当一个依据,外面照下面 + 几条再收拾一遍(「华南居首,([[see:Q1]])。」的逗号)。括号两边的数字、字母会粘成一串的,括号换成一个空格 + (「4,919.5([[see:Q1]])2 月」不剩「4,919.52 月」,_glued) - 依据在括号开头(「([[see:Q3]],原报告…)」):它和括号之间、它后面的标点和空白;在括号末尾(「(同 SQL, [[see:Q2]])」)同理 - - 后面就到句末(句末标点、换行、全文结尾),依据前面、几个依据之间有停顿标点的(「华南居首,[[see:Q1]]。」): - 这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表) + - 后面就到句末(句末标点、全文结尾),依据前面、几个依据之间有停顿标点的(「华南居首,[[see:Q1]]。」): + 这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表);没有停顿标点、到句末只隔着空白的,空白去掉 + (「华南居首 [[see:Q1]] 。」) - 以上几种里,几个依据之间的空白、分隔标点一并去掉(「([[see:Q1]]、[[see:Q2]])」) - 两边都是字的,依据之间的分隔标点可能就是分句的标点:按它断开,每段照 _run_cuts 不含依据的括号、标点一个字不动。要看的只有依据前后连着的空白、括号、分隔标点,外加再往外的一个字:流式渲染把 这些字攒到后面的字来了才放(_TRAILING_SEE),分块渲染和整篇渲染判得一样。prev 是这段文字前面那个字。 + whole:text[start:end] 是一对只装着依据的括号(括号本身已经记了去掉),当一个依据看它外面。 """ - marks = list(MARKER_RE.finditer(text, start, end)) + marks = [] if whole else list(MARKER_RE.finditer(text, start, end)) gaps = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] lo, hi = start, end while lo > 0 and text[lo - 1] in _GAP: @@ -1904,39 +1920,70 @@ def _see_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, int opener = text[lo - 1] if lo > 0 else prev closer = text[hi] if hi < len(text) else "" left, right = text[lo:start], text[end:hi] - if opener in _OPEN and closer in _CLOSE: + if lo > 0 and opener in _OPEN and closer in _CLOSE: # 括号里只有依据:连括号一起去掉 - return [(lo - 1, start), *gaps, (end, hi + 1), *_blank_cut(text, lo - 1, hi + 1, prev)] + glue = " " if _glued(text, lo - 1, hi + 1, prev) else "" + return [(lo - 1, start), *gaps, (end, hi + 1, glue), *_see_cuts(text, lo - 1, hi + 1, prev, whole=True)] if opener in _OPEN: return [(lo, start), *gaps, (end, hi)] if closer in _CLOSE: # 和括号之间只有空白的,照依据前的空白处理(行首的缩进不动) return [*gaps, (end, hi), *([(lo, start)] if _SEP & set(left) else _blank_cut(text, start, end, prev))] - if (closer in _ENDING or closer in ("", "\n")) and _PAUSE & set(left + "".join(text[a:b] for a, b in gaps)): - # 依据后面、句末前面的停顿标点是写作者自己的(「增长 [[see:Q1]],」),不是依据留下的,不动;只剩空白的去掉 - return [*gaps, *([] if _SEP & set(right) else [(end, hi)]), - *([(lo, start)] if _PAUSE & set(left) else _blank_cut(text, start, end, prev))] - return [cut for run in _SEE_RUN.finditer(text, start, end) for cut in _run_cuts(text, run.start(), run.end(), prev)] + tail: list[tuple[int, ...]] = [] + if closer in _ENDING or closer == "": + if _PAUSE & set(left + "".join(text[a:b] for a, b in gaps)): + # 依据后面、句末前面的停顿标点是写作者自己的(「增长 [[see:Q1]],」),不是依据留下的,不动;只剩空白的去掉 + return [*gaps, *([] if _SEP & set(right) else [(end, hi)]), + *([(lo, start)] if _PAUSE & set(left) else _blank_cut(text, start, end, prev))] + if not _SEP & set(right): + tail = [(end, hi)] + runs = [(start, end)] if whole else [(run.start(), run.end()) for run in _SEE_RUN.finditer(text, start, end)] + return [*tail, *(cut for a, b in runs for cut in _run_cuts(text, a, b, prev))] -def _run_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: - """两边都是字的一段依据(_SEE_RUN,中间只隔着空白): +def _glued(text: str, start: int, end: int, prev: str) -> bool: + """去掉 text[start:end](一对只装着依据的括号)之后,两边的字会不会粘成一串:前面是数字、字母(或者标记的结尾、 + 百分号、半角的逗号和小数点,_NUMBERISH),后面紧跟着数字、字母,或者一个渲染出字的标记。「[[v:Q1.r0.销售额]] + ([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,是另一个数,而且哪项核对都查不出来。括号前有空白的不会粘: + 空白照 _blank_cut 留着。紧跟在括号后面的依据也不显示,越过它看下一个字(流式渲染把它和括号一起攒着)。""" + before = text[start - 1] if start > 0 else prev + if not (_wordish(before) or before in _NUMBERISH): + return False + while (marker := MARKER_RE.match(text, end)) is not None and marker.group("kind") == "see": + end = marker.end() + after = text[end:end + 1] + if after == "[": + return MARKER_RE.match(text, end) is not None + return _wordish(after) + + +def _wordish(ch: str) -> bool: + """数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。""" + return ch.isdecimal() or (ch.isascii() and ch.isalnum()) + + +def _run_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, ...]]: + """两边都是字的一段依据(_SEE_RUN,中间只隔着空白;或者一对只装着依据的括号): - 几个依据之间的空白,和依据前面的空白(_blank_cut) - 夹在两个分隔标点中间(「结果一致,[[see:Q1]],原报告…」):去掉后面那个(连同它前面的空白,后面的空白留着 - 隔开下文:「a, [[see:Q1]], b」→「a, b」) + 隔开下文:「a, [[see:Q1]], b」→「a, b」)。前面那个是半角标点、后面紧跟着字的,换成一个空格:「212,[[see:Q1]],5」 + 不剩「212,5」 """ marks = list(MARKER_RE.finditer(text, start, end)) - cuts = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] + cuts: list[tuple[int, ...]] = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] cuts += _blank_cut(text, start, end, prev) lo, hi = start, end while lo > 0 and text[lo - 1] in _GAP: lo -= 1 while hi < len(text) and text[hi] in _GAP: hi += 1 - right = text[end:hi] - if _SEP & set(text[lo:start]) and _SEP & set(right): - cuts.append((end, end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1)) + left, right = text[lo:start], text[end:hi] + if _SEP & set(left) and _SEP & set(right): + cut = end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1 + after = text[cut:cut + 1] + spaced = left.rstrip(_BLANK)[-1] in _HALF and after not in ("", "\n") and after not in _BLANK + cuts.append((end, cut, " " if spaced else "")) return cuts diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index a782efc4..33d29502 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -703,9 +703,17 @@ class _Review: message=f"反方模型与报告的写作模型相同(「{outcome.model}」),难以发现写作模型自身的错误。" f"请在设置的「核查」一节选择另一个模型,或在节点的「{CHALLENGE_LABEL['model']}」中指定") claim, why = _claim_of(cand, outcome) + # 触顶的计在哪一组:时长按剩余时间收紧过的另算一组(_SQUEEZED),说运行时长上限将至 + group = _SQUEEZED if outcome.limit == "timeout_s" and allot.tightened else outcome.limit if outcome.limit == "daily_max_usd" and outcome.result is None and not outcome.usage.get("calls"): # 派出去就撞上每日金额上限、一次模型都没调用:和后面不再派出的句子是同一回事,同一种说法 claim = _claim(cand, "inconclusive", self._capped(), queries=outcome.queries, usage=outcome.usage) + elif outcome.result is None and outcome.error == verify_agent.SETTLE_TIMEOUT: + # 查完了、交结论那次超过时长上限:照上限说,和时间线上的 challenge_limit 一致。核查 agent 的原话(「收尾时 + # 超过时长上限,没能交结果」)是内部的说法,不摆上审批卡 + words = self._limit_words(_SQUEEZED if allot.tightened else "timeout_s") + claim = _claim(cand, "inconclusive", f"反查未完成:已达上限({words})", queries=outcome.queries, + usage=outcome.usage) elif lost and why is None: # 反查了,可该查的源有停用了的:判断照常采信,理由末尾写明哪几个没查(只写在任务里的话,审稿记录和 # 审批卡上的「经受住」看着就像每个源都查过了) @@ -714,8 +722,7 @@ class _Review: claims.append(claim) usage = _add_usage(usage, outcome.usage) if outcome.limit in _CLAIM_LIMITS: - squeezed = outcome.limit == "timeout_s" and allot.tightened - counted = hit.setdefault(_SQUEEZED if squeezed else outcome.limit, [0, 0]) + counted = hit.setdefault(group, [0, 0]) counted[0] += 1 counted[1] += why is not None broke = outcome.limit == "daily_max_usd" diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index 3f410eaa..e81f209d 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -53,6 +53,7 @@ import asyncio import inspect import json import logging +import re import time from collections.abc import Callable, Iterable from dataclasses import asdict, dataclass, field, replace @@ -100,6 +101,9 @@ _REF_DEPTH = 8 RATIONALE = "rationale" #: 触顶原因 LIMITS = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd", "max_steps") +#: 收尾那次调用超过时长上限、没交成结果时的原话(limit 记 timeout_s)。是内部的说法,给人看时调用方换成自己的话:质询的 +#: _error_words、反方审稿的「反查未完成:已达上限(…)」、双盲复算按 limit 写的无法复算原因 +SETTLE_TIMEOUT = "收尾时超过时长上限,没能交结果" #: 核查模型的输出到了上限被截断、没交成结果时给人看的话:双盲复算的无法复算、反方审稿的「反查未完成」、质询的报错都照 #: 它说,也是 verify_truncated 日志的原话。cut 是 budget.cut_words 的「核查模型的输出达到上限(N token)被截断」。开头只说 #: 「未能给出结果」:质询里提问人看到的就是这句,它问的是问题,不是在核查 @@ -666,17 +670,23 @@ def _parsed(value: Any) -> dict[str, Any] | None: #: 只是占位词的理由(只留字母、数字和汉字,不分大小写):模型照着结构填了个字面的 placeholder、N/A,等于没给理由 -_PLACEHOLDERS = frozenset({"placeholder", "todo", "tbd", "na", "none", "null", "nil", "rationale", "reason", - "无", "暂无", "略", "理由"}) +_PLACEHOLDERS = frozenset({"placeholder", "todo", "tbd", "na", "null", "nil", "rationale", "reason", "略", "理由"}) +#: 说「没有」的词:理由是另一个结果的说明时(双盲复算的数、反方审稿的结论),它等于没给理由;理由就是结果本身时(审批 +#: 质询,结构里只有理由一项)它可能就是回答——问「有没有测试账号」,答「无。」 +_NOTHING = frozenset({"none", "无", "暂无"}) +#: 依据写在引出语后面(「依据 [[see:Q1]]。」「结果见:[[see:Q1]]」「(来源:[[see:Q1]])」):依据渲染后不显示,引出语悬在 +#: 那里(「依据。」),渲染时去不掉(evidence._see_cuts 只收拾依据周围的空白、括号和标点)。交结果的提示要求写在句末, +#: 模型不照做时记一条日志,验收时查得到 +_LEAD_IN_SEE = re.compile(r"(?:依据|见|取自|来源|出处)[ \t\u3000]*[::]?[ \t\u3000]*\[\[[ \t]*see[ \t]*:[^\[\]\n]*\]\]") -def _no_rationale(value: Any) -> bool: - """交回的理由等于没给:不是文字、空的、只有标点,或者只是一个占位词。当没给理由处理(理由为空、没有理由文档), - 审批卡照「没有给出理由」说,不把占位词当理由展示。""" - if not isinstance(value, str): - return True - word = "".join(ch for ch in value if ch.isalnum()).casefold() - return not word or word in _PLACEHOLDERS +def _no_rationale(shown: str, *, answer: bool = False) -> bool: + """渲染出来的理由等于没给:空的、只有标点(只写了依据:[[see:Q1]]、「([[see:Q1]])」渲染出来是空的),或者只是一个 + 占位词(「placeholder。[[see:Q1]]」渲染出来只剩占位词)。按渲染后的正文判,不按原文:标记里的字(see、Q1)不显示。 + answer:理由就是交回的结果本身(审批质询的回答),说「没有」的词不算。当没给理由处理(理由为空、没有理由文档), + 审批卡照「没有给出理由」说,不把占位词当理由展示,判「被推翻」的也不当有出处。""" + word = "".join(ch for ch in shown if ch.isalnum()).casefold() + return not word or word in _PLACEHOLDERS or (not answer and word in _NOTHING) @dataclass @@ -1025,7 +1035,7 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou if settle_cut: return await report_cut("verify_result", settler.max_tokens) timed_out = isinstance(failure, asyncio.TimeoutError) - why = "收尾时超过时长上限,没能交结果" if timed_out else \ + why = SETTLE_TIMEOUT if timed_out else \ f"核查模型没有按结构交结果({explain_model_error(failure, model_id)})" if failure is not None else \ "核查模型没有按结构交结果" # 收尾超时:没交成结果是因为时长上限,limit 照实记 timeout_s,和原因对得上——模型自己停了查询(stopped 为 None) @@ -1044,8 +1054,15 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou result = cells.walk(resolved, parsed) if _is_object(resolved) else {} rationale = parsed.get(RATIONALE) doc = None - if not _no_rationale(rationale): + if isinstance(rationale, str) and rationale.strip(): doc = compose_doc(rationale, build_catalog(nodes={}, ledger=ledger), node_id=owner, run_id=run_id or None) + if hits := _LEAD_IN_SEE.findall(rationale): + logger.warning("核查 agent 的理由里依据写在了引出语后面,渲染后引出语会悬空(运行 %s,节点 %s):%s", + run_id or "-", owner, "、".join(hits[:5])) + # 按渲染出来的判:只写了依据的、占位词后面挂着依据的,原文里有字,显示出来没有理由。结构里只有理由一项(审批 + # 质询)时理由就是回答,「无」是一个回答 + if _no_rationale(doc["markdown"], answer=declared == [RATIONALE]): + doc = None result[RATIONALE] = doc["markdown"] if doc else "" return outcome(result=result, cells=cells.cells, rationale=doc, limit=stopped, answered=answered) @@ -1126,4 +1143,5 @@ async def _run_tool(tool: Any, name: str, args: dict[str, Any], call_id: str, re return content -__all__ = ["LIMITS", "RATIONALE", "SETTLE_GRACE_S", "Default", "VerifyOutcome", "result_schema", "run_verify_agent"] +__all__ = ["LIMITS", "RATIONALE", "SETTLE_GRACE_S", "SETTLE_TIMEOUT", "Default", "VerifyOutcome", "result_schema", + "run_verify_agent"] diff --git a/backend/tests/test_challenge_rationale_rules.py b/backend/tests/test_challenge_rationale_rules.py index d7a22d92..9b9ee6a6 100644 --- a/backend/tests/test_challenge_rationale_rules.py +++ b/backend/tests/test_challenge_rationale_rules.py @@ -62,3 +62,24 @@ async def test_a_placeholder_rationale_is_recorded_as_no_rationale(monkeypatch, 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) diff --git a/backend/tests/test_dual_check_rationale_rules.py b/backend/tests/test_dual_check_rationale_rules.py index 9f9fc7e0..a4fb3d7e 100644 --- a/backend/tests/test_dual_check_rationale_rules.py +++ b/backend/tests/test_dual_check_rationale_rules.py @@ -40,3 +40,12 @@ async def test_a_placeholder_rationale_is_not_shown_as_the_explanation(monkeypat 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 diff --git a/backend/tests/test_evidence_see_brackets.py b/backend/tests/test_evidence_see_brackets.py index d5ef3092..2c7c53a8 100644 --- a/backend/tests/test_evidence_see_brackets.py +++ b/backend/tests/test_evidence_see_brackets.py @@ -4,20 +4,27 @@ [[see:Q1]]」,模型就把它写进括号、句中:「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计 [[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。 -- 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉,括号前的空白照 see 前的空白处理 +- 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉;去掉之后整对括号当一个依据,外面照下面几条再收拾 + 一遍(括号前悬着的停顿标点、括号前后的空白) +- 去掉括号之后两边的数字、字母会粘成一串的(「4,919.5([[see:Q1]])2 月」),括号换成一个空格:粘起来读成另一个数, + 哪项核对都查不出来;半角逗号夹着依据、去掉后一个之后同理(「212,[[see:Q1]],5」→「212, 5」) - 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理 -- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句号、换行、全文结尾)、前面或几个依据之间有「,」「、」 - 「;」的,去掉这些标点;依据后面、句末前面的标点是写作者自己的,不动 +- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」 + 「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动 +- 换行不算句末:列表项末尾的「;」、软换行处的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩,照旧留着 - 括号里、句末前几个依据之间的分隔标点一并去掉;两边都是字时,依据之间的标点可能是分句的标点,照旧留着 - 依据标记本身留在原文里:句子照旧挂着这条依据(see、cites),只是不显示 - 不含依据的括号、没有依据的文档一个字不动:内容哈希和改动之前一样 - 流式渲染分块输出拼起来仍然等于整篇渲染 +- 已知限制:依据写在「依据」「见」「取自」「来源」这类引出语后面时,引出语悬空(「依据。」「(来源)」)。渲染时去不掉, + 靠核查 agent 的提示词避免,模型不照做时核查 agent 记一条日志(test_verify_rationale_rules) 数字、表名、地名都是虚构的。 """ from __future__ import annotations import random +import re import pytest @@ -116,32 +123,107 @@ def test_punctuation_next_to_see_inside_a_bracket_goes_with_it(catalog, raw, sho ("口径相同(按支付日,[[see:Q1]],不含退款)。", "口径相同(按支付日,不含退款)。"), ("结果一致,[[see:Q1]],原报告未过滤。", "结果一致,原报告未过滤。"), ("a, [[see:Q1]], b", "a, b"), + ("a,[[see:Q1]],b", "a, b"), # 半角逗号后面照排版习惯隔一个空格 # 前面是分隔标点、后面就到句末:去掉前面那个 ("华南居首,[[see:Q1]]。", "华南居首。"), - ("华南居首、华东第二;[[see:Q1]]\n下一行", "华南居首、华东第二\n下一行"), ("华南居首,[[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"] + + @pytest.mark.parametrize(("raw", "shown"), [ ("华南居首。[[see:Q1]]", "华南居首。"), ("华南居首 [[see:Q1]],华东第二。", "华南居首,华东第二。"), # 两个依据中间的逗号后面接着字:它是分句的逗号,不是依据之间的分隔,照旧留着 ("华南居首 [[see:Q1]],[[see:m:gmv]]华东第二。", "华南居首,华东第二。"), - ("华南居首 [[see:Q1]] [[see:m:gmv]] 。", "华南居首 。"), # 没有悬空的标点:照旧只去掉依据前的空白 ("华南居首 [[see:Q1]],", "华南居首,"), # 依据后面的逗号是写作者自己的,不动 ("数据如下:[[see:Q1]]\n| 区域 | 销售额 |", "数据如下:\n| 区域 | 销售额 |"), # 冒号引出的是下一行 - ("结果见:[[see:Q1]]。", "结果见:。"), # 冒号前的引出语去不掉:靠提示词让模型写在句末 - ("依据 [[see:Q1]]。", "依据。"), + # 换行不算句末:列表项末尾的「;」、软换行处连着两个分句的「,」是写作者自己的 + ("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_see_outside_brackets_without_dangling_pauses_renders_as_before(catalog, raw, shown): +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", [ @@ -214,6 +296,9 @@ def _stream(catalog, raw: str, sizes: list[int]) -> list[str]: "结果一致([[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]]),华东第二", ]) @pytest.mark.parametrize("size", range(1, 9)) def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size): @@ -225,7 +310,7 @@ def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size): def _corpus(n: int) -> list[str]: rng = random.Random(20261006) pieces = ["[[see:Q1]]", "[[see:m:gmv]]", "[[v:Q1.r0.销售额]]", "[[m:gmv]]", "(", ")", "(", ")", ",", "、", - ";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "["] + ";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "[", "2", "x", "%", "!"] return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)] diff --git a/backend/tests/test_inquiry_rationale_rules.py b/backend/tests/test_inquiry_rationale_rules.py index c8a4c604..e2710343 100644 --- a/backend/tests/test_inquiry_rationale_rules.py +++ b/backend/tests/test_inquiry_rationale_rules.py @@ -4,6 +4,9 @@ ([[see:Q1]])」渲染成「(,原报告…)」「()」,随正式运行封存;另一次回答查完了,收尾那次调用超过时长上限,报错写着 「回答超过时长上限(180 秒)」,answer.limit 却是 null,界面上缺了「已达时长上限」。 +回答只写了依据(「([[see:Q1]])」)时显示出来是空的:以前照样交回,封存之后的追问末尾补上「注:以上回答依据的是数据源 +当前的数据…」,回答正文只剩这句系统说明、却记成回答成功。现在按没给回答记。回答就是一个「无。」的,照常是回答。 + 模型一律是 MockChatModel 剧本,区域、金额都是虚构的。 """ from __future__ import annotations @@ -12,6 +15,7 @@ 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, @@ -20,10 +24,13 @@ from tests.test_inquiry_api import ( # noqa: F401 - 夹具按名字引入 client, engine_up, events, + finish, pause, pending, + report_doc, set_verify, shop, + unit_of, ) from tests.test_verify_rationale_rules import slow_final @@ -74,3 +81,25 @@ async def test_a_placeholder_answer_is_no_answer(monkeypatch, shop, client): 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"] == "无。" diff --git a/backend/tests/test_verify_rationale_rules.py b/backend/tests/test_verify_rationale_rules.py index 03490fef..a97b27d3 100644 --- a/backend/tests/test_verify_rationale_rules.py +++ b/backend/tests/test_verify_rationale_rules.py @@ -6,13 +6,17 @@ - 写进了括号的依据,理由文档里照样渲染干净(evidence._tidy),这句仍挂着那条依据 - 模型自己停了工具调用、收尾那次调用超过了时长上限:limit 照实记 timeout_s。以前是 None——质询的报错写着「回答超过 时长上限(180 秒)」,limit 却是空的,界面上缺了「已达时长上限」。不走结构化输出的模型(普通调用收尾)也一样 -- 交回的理由是空的、只有标点,或者只是一个占位词(字面的 placeholder、N/A):当作没给理由,不把占位词当理由展示 +- 交回的理由是空的、只有标点,或者只是一个占位词(字面的 placeholder、N/A):当作没给理由,不把占位词当理由展示。 + 按渲染出来的正文判:只写了依据的([[see:Q1]]、「([[see:Q1]])」)显示出来是空的,占位词后面挂着依据的显示出来只剩 + 占位词,都算没给。理由就是结果本身时(审批质询,结构里只有理由一项),「无」「none」是回答,不算占位 +- 依据写在「依据」「见」「取自」这类引出语后面(渲染时去不掉,引出语悬空):记一条日志,验收时查得到 模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,数字都是虚构的。 """ from __future__ import annotations import asyncio +import logging import re import time @@ -20,7 +24,7 @@ import pytest from app.engine import verify_agent from app.engine.evidence import iter_units -from app.engine.inquiry import INQUIRY_SYSTEM, INQUIRY_TASK_PROMPT +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 @@ -130,7 +134,8 @@ async def test_a_final_call_that_runs_out_of_time_after_the_query_limit_records_ # 占位词当理由 # -------------------------------------------------------------------------- -@pytest.mark.parametrize("said", ["placeholder", "PLACEHOLDER", " ", "N/A", "(略)", "…", "", "TODO"]) +@pytest.mark.parametrize("said", ["placeholder", "PLACEHOLDER", " ", "N/A", "(略)", "…", "", "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}) @@ -139,9 +144,67 @@ async def test_a_placeholder_rationale_counts_as_none(monkeypatch, shop, said): assert out.result["rationale"] == "" and out.rationale is None -@pytest.mark.parametrize("said", ["无异常。", "见 [[v:Q1.r0.total]]。", "略高于上周。"]) +@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()] diff --git a/backend/tests/test_verify_settle_timeout.py b/backend/tests/test_verify_settle_timeout.py new file mode 100644 index 00000000..473b1d36 --- /dev/null +++ b/backend/tests/test_verify_settle_timeout.py @@ -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 From 3d85a43301c8b90eff292d345319d51a921ac3ed Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 12 Oct 2026 10:35:13 +0800 Subject: [PATCH 6/7] =?UTF-8?q?M1=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=202=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E4=BE=9D=E6=8D=AE=E4=B9=8B=E5=90=8E=E6=8C=89=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E6=8C=A8=E4=B8=8A=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=AD=97?= =?UTF-8?q?=E5=88=A4=E7=B2=98=E8=BF=9E=E2=80=94=E2=80=94=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E5=8F=AA=E8=A3=85=E7=9D=80=E4=BE=9D=E6=8D=AE=E7=9A=84=E6=8B=AC?= =?UTF-8?q?=E5=8F=B7=E8=BF=9E=E7=9D=80=E5=86=99=E3=80=81=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E5=8C=85=E7=9D=80=E7=B2=97=E4=BD=93=E6=97=B6=EF=BC=8C=E4=B8=A4?= =?UTF-8?q?=E8=BE=B9=E7=9A=84=E6=95=B0=E4=B8=8D=E5=86=8D=E5=B9=B6=E6=88=90?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=9B=E4=BE=9D=E6=8D=AE=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E7=9A=84=E3=80=8C]=E3=80=8D=E4=B8=8D=E5=BD=93=E6=95=B0?= =?UTF-8?q?=E5=AD=97=EF=BC=9B=E4=B8=80=E7=89=87=E9=87=8C=E5=8F=AA=E8=A1=A5?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渲染(evidence._tidy): - 上一轮判粘连只看原文里紧挨着括号的两个字(往后只越过 [[see:]])。两个只装着依据的括号连着写(「[[v:Q1.r0.销售额]] ([[see:Q1]])([[see:m:gmv]])2 月」)、数字包着粗体(「**[[v:…]]**([[see:Q1]])2 月」,组装文档时星号又被去掉) 时,去掉之后真正挨上的是两边的数,读者看到「4,919.52 月」「212212 笔」,compose_doc、verify_doc 都不报。现在先把依据 标记和只装着依据的括号(套着的、几个连着写的都算)认成渲染出来什么都没有的一段(_voids),连着写的几个并成一组,外面的 停顿标点、空白照原规矩收拾;所有切口去掉之后,再往两边越过去掉的字、依据、粗体斜体的星号和下划线,找真正挨上的两个看得 见的字,会读成一串的补回一个空格(_glue) - 依据标记的「]」越过去、不当数字:「华南居首。[[see:Q1]]([[see:Q2]])2 月」不再多出空格,「[[see:Q1]]([[see:Q1]]) 2 月」不再以空格开头 - 同一片里最多补一个空格:「212,[[see:Q1]],[[see:m:gmv]],5」→「212, 5」(上一轮是两个空格);挨上的是写作者自己留着的 空白的,不补 - 半角标点后面补空格并进同一条规则:只在下文是数字、字母或渲染出字的标记时补(「a,[[see:Q1]],b」→「a, b」),下文是中文 的照写作者原样 - 流式渲染把末尾的星号、下划线也攒到后面的字来了再放,和整篇渲染判得一样 - 粗体把括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)空格落在收尾的星号前面:前端行内 Markdown 照样认这对星号, 组装出的文档去掉了包着标记的星号,读者看到的一样;挪到星号外面得先知道这对星号从哪开始,流式渲染的一块里不一定有。 测试里用严格的 xfail 写明这条已知限制 测试:两个括号连着写(全角半角混用、套着的)、粗体在括号前、后、里面、下划线和单个星号、依据的「]」、一片只补一个的用例; 结构化组合(数、依据、只装依据的括号、空白、星号、标点)逐份查写作者隔开了的两边没有挨上、没多出连着的两个空格,逐字和 每 4 个字分块渲染都等于整篇渲染;审批质询、核查 agent、反方审稿、双盲复算各补一个端到端用例。11 处改动逐一去掉实现, 相关测试都变红。另在测试之外抽查:结构化组合 15.9 万份和基线 58a6759 逐份比数字串,只剩复核说可以不管的「),5」这类半角 标点紧贴数字的写法;随机 8000 份不含依据的文档,内容哈希、渲染结果和基线逐份一致;随机 1.6 万份含依据的文档流式和整篇 一致。 --- backend/app/engine/evidence.py | 231 +++++++++++------- .../tests/test_challenge_rationale_rules.py | 15 ++ .../tests/test_dual_check_rationale_rules.py | 10 + backend/tests/test_evidence_see_brackets.py | 153 +++++++++++- backend/tests/test_inquiry_rationale_rules.py | 19 +- backend/tests/test_verify_rationale_rules.py | 18 ++ 6 files changed, 350 insertions(+), 96 deletions(-) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index c4926905..2f2c329a 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1818,23 +1818,31 @@ _OPEN, _CLOSE = frozenset("(("), frozenset("))") #: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」) _SEPS = ",、;,;::" _SEP = frozenset(_SEPS) -#: 其中的半角标点:按排版习惯后面隔一个空格。两个夹着依据的去掉后一个时,前一个不能直接贴着下文(「212,5」读成一个数) -_HALF = frozenset(",;:") #: 其中的停顿标点(不含冒号):依据挂在它后面、紧接着就到句末时,它悬在句末(「华南居首,。」)。冒号后面另起一行 #: 常常是它引出的表格、列表,不动 _PAUSE = _SEP - frozenset("::") #: 句末标点:停顿标点悬在它前面时去掉(还有全文结尾)。半角句点不算:后面跟着数字时它是小数点。换行不算:列表项末尾 #: 的「;」、软换行处连着两个分句的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩 _ENDING = frozenset("。!?!?") -#: 去掉一对括号之后会粘成一串的字:前面是数字、字母(或者标记的结尾、百分号、半角的逗号和小数点),后面是数字、字母 -#: (或者一个渲染出字的标记)。中文和数字之间不加空格也读得对,不算 -_NUMBERISH = frozenset("]%‰.,") #: 依据周围、渲染后会挤在一起的字:空白和分隔标点 _GAP = frozenset(_BLANK) | _SEP -#: 连着写的几个依据:中间只隔着空白、分隔标点 -_SEE_GROUP = re.compile(_SEE_MARK + r"(?:[" + _BLANK + _SEPS + r"]*" + _SEE_MARK + r")*") -#: 其中只隔着空白的一段(两边都是字时,依据之间的分隔标点可能就是分句的标点,按它断开各算各的) -_SEE_RUN = re.compile(_SEE_MARK + r"(?:[" + _BLANK + r"]*" + _SEE_MARK + r")*") +#: 去掉依据周围的字之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染出一个数)、 +#: 百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、字母,或者 +#: 一个渲染出字的标记。中文和数字之间不加空格也读得对,不算 +_STICKY = frozenset("]%‰.,;:") +#: 渲染出来什么都没有的东西(依据标记、只装着依据的括号)在遮盖文本里换成这个字,位置和原文一一对应(_voids) +_VOID = "\x00" +#: 只装着依据的括号:里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。头一个依据前面只认空白、 +#: 标点,免得一长串依据后面没有右括号时来回试 +_VOID_BRACKET = re.compile("[((][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*[))]") +#: 连着写的几个:中间只隔着空白、分隔标点 +_VOID_GROUP = re.compile(_VOID + "+(?:[" + _BLANK + _SEPS + "]*" + _VOID + "+)*") +#: 其中只隔着空白的一段(两边都是字时,中间的分隔标点可能就是分句的标点,按它断开各算各的) +_VOID_RUN = re.compile(_VOID + "+(?:[" + _BLANK + "]*" + _VOID + "+)*") +#: 遮盖文本里看得见的一段:一组里夹着的空白、标点,括号连同里面的空白、标点 +_SOLID = re.compile("[^" + _VOID + "]+") +#: 粗体、斜体的星号和粗体的两个下划线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体也去掉了星号) +_EMPHASIS = re.compile(r"\*+|__+") def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, @@ -1852,13 +1860,13 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: - 数字标记后面重复写的单位去掉。[[m:visits]] 渲染出来已经带单位(12,345人次),写作者常常 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时 不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来; - - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_see_cuts):「最高月份 [[see:Q2]]。」会剩下 - 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。去掉之后两边的数字、字母 - 会粘成一串的,换成一个空格(_glued)。 + - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_voids、_see_cuts):「最高月份 [[see:Q2]]。」会剩下 + 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。去掉之后两边挨上的字会读成 + 一串的,补回一个空格(_glue)。 prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。 """ - cuts: list[tuple[int, ...]] = [] + cuts: list[tuple[int, int]] = [] for marker in parse_markers(text): if marker["kind"] not in _UNIT_KINDS: continue @@ -1870,48 +1878,67 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: after = text[hit.end():hit.end() + 1] if len(unit) > 1 or not after.isalnum(): cuts.append((marker["end"], hit.end())) - for group in _SEE_GROUP.finditer(text): - cuts.extend(_see_cuts(text, group.start(), group.end(), prev)) - if not cuts: + see: list[tuple[int, int]] = [] + spaces: set[int] = set() + if "see" in text: # 没有依据标记就没有要收拾的(流式渲染的每一块都走这里,先挡掉) + masked, see = _voids(text) + for group in _VOID_GROUP.finditer(masked): + see += _see_cuts(text, masked, group.start(), group.end(), prev) + spaces = _glue(text, masked, see, prev) + if not cuts and not see: return text - # 几处要去掉的字可能重叠(依据前面的空白、它和括号之间的标点):按并集去掉。带着替换的(第三项,去掉之后两边会 - # 粘在一起,换成一个空格)连成一片时整片换成它 - merged: list[list[Any]] = [] - for a, b, *rep in sorted(cuts): - if merged and a < merged[-1][1]: + # 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格 + merged: list[list[int]] = [] + for a, b in sorted(cuts + see): + if merged and a <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], b) - merged[-1][2] = merged[-1][2] or "".join(rep) else: - merged.append([a, b, "".join(rep)]) + merged.append([a, b]) out, last = [], 0 - for a, b, rep in merged: - out += [text[last:a], rep] + for a, b in merged: + out += [text[last:a], " " if any(a <= x < b for x in spaces) else ""] last = b out.append(text[last:]) return "".join(out) -def _see_cuts(text: str, start: int, end: int, prev: str, *, whole: bool = False) -> list[tuple[int, ...]]: - """一组依据(_SEE_GROUP,text[start:end])渲染成空之后,周围要一起去掉的字。标记本身留着:组装文档时句子靠它挂 - 依据(unit.see),渲染时它本来就不显示。 +def _voids(text: str) -> tuple[str, list[tuple[int, int]]]: + """渲染出来什么都没有的东西:依据标记,和只装着依据的括号(里面可以隔着空白、分隔标点,套着的也算)。返回遮盖文本 + (这些地方换成 _VOID,位置和原文一一对应)和这种括号连同里面的空白、标点:要一起去掉的字(「合计 6,635.68 + ([[see:Q1]])。」不剩「()」)。依据标记本身留着:组装文档时句子靠它挂依据(unit.see),渲染时它本来就不显示。 - - 括号里只有依据的(「合计 6,635.68([[see:Q1]])。」),连括号一起去掉;去掉之后整对括号当一个依据,外面照下面 - 几条再收拾一遍(「华南居首,([[see:Q1]])。」的逗号)。括号两边的数字、字母会粘成一串的,括号换成一个空格 - (「4,919.5([[see:Q1]])2 月」不剩「4,919.52 月」,_glued) - - 依据在括号开头(「([[see:Q3]],原报告…)」):它和括号之间、它后面的标点和空白;在括号末尾(「(同 SQL, + 认出来的括号和依据一样当一个不显示的东西:连着写的几个(「([[see:Q1]])([[see:m:gmv]])」「[[see:Q1]] + ([[see:Q2]])」)由 _VOID_GROUP 并成一组,外面照 _see_cuts 收拾一遍(「华南居首,([[see:Q1]])。」的逗号)。""" + chars = list(text.replace(_VOID, "\x01")) + for m in MARKER_RE.finditer(text): + if m.group("kind") == "see": + chars[m.start():m.end()] = _VOID * (m.end() - m.start()) + masked, cuts = "".join(chars), [] + while hits := list(_VOID_BRACKET.finditer(masked)): + # 一轮认出最里面的一层:套在外面的括号下一轮才只剩依据 + for hit in hits: + cuts += [(s.start(), s.end()) for s in _SOLID.finditer(masked, hit.start(), hit.end())] + chars[hit.start():hit.end()] = _VOID * (hit.end() - hit.start()) + masked = "".join(chars) + return masked, cuts + + +def _see_cuts(text: str, masked: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: + """一组不显示的东西(_VOID_GROUP:依据、只装着依据的括号,连着写的几个,masked[start:end])渲染成空之后,周围要 + 一起去掉的字: + + - 在括号开头(「([[see:Q3]],原报告…)」):它和括号之间、它后面的标点和空白;在括号末尾(「(同 SQL, [[see:Q2]])」)同理 - - 后面就到句末(句末标点、全文结尾),依据前面、几个依据之间有停顿标点的(「华南居首,[[see:Q1]]。」): - 这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表);没有停顿标点、到句末只隔着空白的,空白去掉 - (「华南居首 [[see:Q1]] 。」) - - 以上几种里,几个依据之间的空白、分隔标点一并去掉(「([[see:Q1]]、[[see:Q2]])」) - - 两边都是字的,依据之间的分隔标点可能就是分句的标点:按它断开,每段照 _run_cuts + - 后面就到句末(句末标点、全文结尾),前面、中间有停顿标点的(「华南居首,[[see:Q1]]。」「华南居首, + ([[see:Q1]])。」):这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表);没有停顿标点、到句末 + 只隔着空白的,空白去掉(「华南居首 [[see:Q1]] 。」) + - 以上几种里,中间的空白、分隔标点一并去掉(「(同 SQL;[[see:Q1]]、[[see:Q2]])」) + - 两边都是字的,中间的分隔标点可能就是分句的标点:按它断开,每段照 _run_cuts - 不含依据的括号、标点一个字不动。要看的只有依据前后连着的空白、括号、分隔标点,外加再往外的一个字:流式渲染把 - 这些字攒到后面的字来了才放(_TRAILING_SEE),分块渲染和整篇渲染判得一样。prev 是这段文字前面那个字。 - whole:text[start:end] 是一对只装着依据的括号(括号本身已经记了去掉),当一个依据看它外面。 + 不含依据的括号、标点一个字不动。要看的只有这组前后连着的空白、分隔标点,外加再往外的一个字:流式渲染把这些字攒到 + 后面的字来了才放(_TRAILING_SEE),分块渲染和整篇渲染判得一样。prev 是这段文字前面那个字。 """ - marks = [] if whole else list(MARKER_RE.finditer(text, start, end)) - gaps = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] + gaps = [(s.start(), s.end()) for s in _SOLID.finditer(masked, start, end)] lo, hi = start, end while lo > 0 and text[lo - 1] in _GAP: lo -= 1 @@ -1920,16 +1947,12 @@ def _see_cuts(text: str, start: int, end: int, prev: str, *, whole: bool = False opener = text[lo - 1] if lo > 0 else prev closer = text[hi] if hi < len(text) else "" left, right = text[lo:start], text[end:hi] - if lo > 0 and opener in _OPEN and closer in _CLOSE: - # 括号里只有依据:连括号一起去掉 - glue = " " if _glued(text, lo - 1, hi + 1, prev) else "" - return [(lo - 1, start), *gaps, (end, hi + 1, glue), *_see_cuts(text, lo - 1, hi + 1, prev, whole=True)] if opener in _OPEN: return [(lo, start), *gaps, (end, hi)] if closer in _CLOSE: # 和括号之间只有空白的,照依据前的空白处理(行首的缩进不动) return [*gaps, (end, hi), *([(lo, start)] if _SEP & set(left) else _blank_cut(text, start, end, prev))] - tail: list[tuple[int, ...]] = [] + tail: list[tuple[int, int]] = [] if closer in _ENDING or closer == "": if _PAUSE & set(left + "".join(text[a:b] for a, b in gaps)): # 依据后面、句末前面的停顿标点是写作者自己的(「增长 [[see:Q1]],」),不是依据留下的,不动;只剩空白的去掉 @@ -1937,41 +1960,19 @@ def _see_cuts(text: str, start: int, end: int, prev: str, *, whole: bool = False *([(lo, start)] if _PAUSE & set(left) else _blank_cut(text, start, end, prev))] if not _SEP & set(right): tail = [(end, hi)] - runs = [(start, end)] if whole else [(run.start(), run.end()) for run in _SEE_RUN.finditer(text, start, end)] - return [*tail, *(cut for a, b in runs for cut in _run_cuts(text, a, b, prev))] + return [*tail, *(cut for run in _VOID_RUN.finditer(masked, start, end) + for cut in _run_cuts(text, masked, run.start(), run.end(), prev))] -def _glued(text: str, start: int, end: int, prev: str) -> bool: - """去掉 text[start:end](一对只装着依据的括号)之后,两边的字会不会粘成一串:前面是数字、字母(或者标记的结尾、 - 百分号、半角的逗号和小数点,_NUMBERISH),后面紧跟着数字、字母,或者一个渲染出字的标记。「[[v:Q1.r0.销售额]] - ([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,是另一个数,而且哪项核对都查不出来。括号前有空白的不会粘: - 空白照 _blank_cut 留着。紧跟在括号后面的依据也不显示,越过它看下一个字(流式渲染把它和括号一起攒着)。""" - before = text[start - 1] if start > 0 else prev - if not (_wordish(before) or before in _NUMBERISH): - return False - while (marker := MARKER_RE.match(text, end)) is not None and marker.group("kind") == "see": - end = marker.end() - after = text[end:end + 1] - if after == "[": - return MARKER_RE.match(text, end) is not None - return _wordish(after) +def _run_cuts(text: str, masked: str, start: int, end: int, prev: str) -> list[tuple[int, int]]: + """两边都是字的一段(_VOID_RUN,中间只隔着空白): - -def _wordish(ch: str) -> bool: - """数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。""" - return ch.isdecimal() or (ch.isascii() and ch.isalnum()) - - -def _run_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, ...]]: - """两边都是字的一段依据(_SEE_RUN,中间只隔着空白;或者一对只装着依据的括号): - - - 几个依据之间的空白,和依据前面的空白(_blank_cut) + - 中间的空白,和前面的空白(_blank_cut) - 夹在两个分隔标点中间(「结果一致,[[see:Q1]],原报告…」):去掉后面那个(连同它前面的空白,后面的空白留着 - 隔开下文:「a, [[see:Q1]], b」→「a, b」)。前面那个是半角标点、后面紧跟着字的,换成一个空格:「212,[[see:Q1]],5」 - 不剩「212,5」 + 隔开下文:「a, [[see:Q1]], b」→「a, b」)。去掉之后前一个贴着下文、读成一串的(「212,[[see:Q1]],5」),_glue + 补回一个空格 """ - marks = list(MARKER_RE.finditer(text, start, end)) - cuts: list[tuple[int, ...]] = [(m.end(), n.start()) for m, n in zip(marks, marks[1:]) if m.end() < n.start()] + cuts = [(s.start(), s.end()) for s in _SOLID.finditer(masked, start, end)] cuts += _blank_cut(text, start, end, prev) lo, hi = start, end while lo > 0 and text[lo - 1] in _GAP: @@ -1980,10 +1981,7 @@ def _run_cuts(text: str, start: int, end: int, prev: str) -> list[tuple[int, ... hi += 1 left, right = text[lo:start], text[end:hi] if _SEP & set(left) and _SEP & set(right): - cut = end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1 - after = text[cut:cut + 1] - spaced = left.rstrip(_BLANK)[-1] in _HALF and after not in ("", "\n") and after not in _BLANK - cuts.append((end, cut, " " if spaced else "")) + cuts.append((end, end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1)) return cuts @@ -2000,6 +1998,65 @@ def _blank_cut(text: str, start: int, end: int, prev: str) -> list[tuple[int, in return [] +def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[int]: + """依据周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回这一片里头一个去掉的字 + 的位置,_tidy 在那里补回一个空格。「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,是另一个 + 数,而且哪项核对都查不出来。 + + 往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线。两个括号连着写(「([[see:Q1]]) + ([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**([[see:Q1]])」)都照挨上的字判;依据标记的「]」越过去,不当 + 数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个(「212,[[see:Q1]],[[see:m:gmv]],5」→ + 「212, 5」);挨上的是写作者留着的空白的,不补。 + + 空格补在头一个去掉的字那里。粗体把括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面: + 前端的行内 Markdown 照样认这对星号,组装文档时包着标记的星号也去掉了,读者看到的一样。要挪到星号外面得先知道 + 这对星号从哪开始,流式渲染的一块里不一定有。 + + 越过的这些字(空白、括号、分隔标点、依据、星号、下划线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE):一片总 + 落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。""" + gone = bytearray(len(text)) + for a, b in see: + gone[a:b] = b"\x01" * (b - a) + + def hidden(i: int) -> bool: + return bool(gone[i]) or masked[i] == _VOID + + spaces: set[int] = set() + seen: set[tuple[int, int]] = set() + for run in re.finditer(rb"\x01+", gone): + p, q = run.start(), run.end() + while p > 0: + if hidden(p - 1): + p -= 1 + elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] == "__"): + mark = text[p - 1] + while p > 0 and text[p - 1] == mark: + p -= 1 + else: + break + while q < len(text): + if hidden(q): + q += 1 + elif emphasis := _EMPHASIS.match(text, q): + q = emphasis.end() + else: + break + if (p, q) in seen: + continue + seen.add((p, q)) + left = text[p - 1] if p > 0 else prev + right = text[q:q + 1] + sticks = (right == "[" and MARKER_RE.match(text, q) is not None) or _wordish(right) + if sticks and (_wordish(left) or left in _STICKY): + spaces.add(run.start()) + return spaces + + +def _wordish(ch: str) -> bool: + """数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。""" + return ch.isdecimal() or (ch.isascii() and ch.isalnum()) + + def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str: text = _tidy(_expand_tables(text, catalog, snaps, cells_allowed, prev), catalog, snaps, cells_allowed, prev) out: list[str] = [] @@ -2023,9 +2080,9 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool = # 流式渲染:让 llm.token 里出现的就是最终的数字 # -------------------------------------------------------------------------- -#: 文字末尾的空白、括号、分隔标点和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy(依据周围的这些字 -#: 去不去掉,要看再往后的那个字,见 _see_cuts) -_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + "(())" + _SEPS + r"]|" + _SEE_MARK + r")+$") +#: 文字末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy(依据 +#: 周围的这些字去不去掉、去掉之后补不补空格,要看再往后的那个字,见 _see_cuts、_glue) +_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + "(())" + _SEPS + "*_" + r"]|" + _SEE_MARK + r")+$") def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int: @@ -2036,9 +2093,9 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non 开头可能是另一个 [。 整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。 - _tidy 要往后看的也先不放:末尾的空白、括号、分隔标点和 [[see:…]](后面来的字决定它们去不去掉), - 以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断一个 - 标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。 + _tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]](后面来的字决定它们去不 + 去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断 + 一个标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。 """ cut = len(text) start = text.rfind("[[") diff --git a/backend/tests/test_challenge_rationale_rules.py b/backend/tests/test_challenge_rationale_rules.py index 9b9ee6a6..1bf5317f 100644 --- a/backend/tests/test_challenge_rationale_rules.py +++ b/backend/tests/test_challenge_rationale_rules.py @@ -51,6 +51,21 @@ async def test_see_in_brackets_does_not_break_the_rationale(monkeypatch, shop): 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_placeholder_rationale_is_recorded_as_no_rationale(monkeypatch, shop): """反方交回的理由只是占位词:记录里理由为空(审批卡据此写「反方没有给出理由」),判「被推翻」的照常不予采信。""" plays = {"华南销售额超过华东": Play([NET], "refuted", "placeholder"), diff --git a/backend/tests/test_dual_check_rationale_rules.py b/backend/tests/test_dual_check_rationale_rules.py index a4fb3d7e..e606ef32 100644 --- a/backend/tests/test_dual_check_rationale_rules.py +++ b/backend/tests/test_dual_check_rationale_rules.py @@ -36,6 +36,16 @@ async def test_see_in_brackets_does_not_break_the_blind_rationale(monkeypatch, s 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_placeholder_rationale_is_not_shown_as_the_explanation(monkeypatch, shop): mine = await _blind_input(monkeypatch, shop, "placeholder") assert mine["value"] == 330.0, "数照样采用" diff --git a/backend/tests/test_evidence_see_brackets.py b/backend/tests/test_evidence_see_brackets.py index 2c7c53a8..3cafa116 100644 --- a/backend/tests/test_evidence_see_brackets.py +++ b/backend/tests/test_evidence_see_brackets.py @@ -5,9 +5,11 @@ [[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。 - 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉;去掉之后整对括号当一个依据,外面照下面几条再收拾 - 一遍(括号前悬着的停顿标点、括号前后的空白) -- 去掉括号之后两边的数字、字母会粘成一串的(「4,919.5([[see:Q1]])2 月」),括号换成一个空格:粘起来读成另一个数, - 哪项核对都查不出来;半角逗号夹着依据、去掉后一个之后同理(「212,[[see:Q1]],5」→「212, 5」) + 一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾 +- 去掉之后两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」),补回一个空格:粘起来读成另一个数, + 哪项核对都查不出来。找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线;依据标记的「]」 + 不当数字。一片里最多补一个,挨上的是写作者留着的空白的不补;半角逗号夹着依据、去掉后一个之后同理 + (「212,[[see:Q1]],5」→「212, 5」) - 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理 - 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」 「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动 @@ -187,6 +189,132 @@ def test_a_glued_number_would_have_slipped_through_the_checks(catalog): 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 月" + + +@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]]", "**", " ", ","] +_RIGHTS = ["2 月", "[[v:Q1.r0.订单数]] 笔", "**[[v:Q1.r0.订单数]] 笔**", "x 区"] +#: 写作者原本就隔开了两边的中间段:括号(去掉依据之后还看得见)、空白、标点 +_SEPARATING = {"([[see:Q1]])", "([[see:m:gmv]])", "(([[see:Q1]]))", " ", ","} + + +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 for m in middle)] + + +#: 左边那个数渲染出来的字,后面紧跟着数字、字母,或者半角的逗号、句点再跟数字:读成了另一个数、另一个词 +_GLUED = re.compile(r"(?:4,919\.5|12\.5%|Q1)(?=[\dA-Za-z]|[,.]\d)") + + +def test_a_separator_the_writer_wrote_never_disappears_between_two_numbers(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]],华东第二。", "华南居首,华东第二。"), @@ -299,6 +427,12 @@ def _stream(catalog, raw: str, sizes: list[int]) -> list[str]: "华东 [[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", ]) @pytest.mark.parametrize("size", range(1, 9)) def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size): @@ -310,7 +444,8 @@ def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size): def _corpus(n: int) -> list[str]: rng = random.Random(20261006) pieces = ["[[see:Q1]]", "[[see:m:gmv]]", "[[v:Q1.r0.销售额]]", "[[m:gmv]]", "(", ")", "(", ")", ",", "、", - ";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "[", "2", "x", "%", "!"] + ";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "[", "2", "x", "%", "!", + "**", "*", "__", "_", "([[see:Q1]])"] return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)] @@ -321,3 +456,13 @@ def test_stream_matches_the_full_render_on_random_brackets(catalog, raw): 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] diff --git a/backend/tests/test_inquiry_rationale_rules.py b/backend/tests/test_inquiry_rationale_rules.py index e2710343..0cde2ab1 100644 --- a/backend/tests/test_inquiry_rationale_rules.py +++ b/backend/tests/test_inquiry_rationale_rules.py @@ -11,6 +11,8 @@ """ 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 @@ -38,6 +40,11 @@ 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,华东仍居各区域之首。" async def _asked(client, shop) -> tuple[dict, list]: @@ -48,15 +55,17 @@ async def _asked(client, shop) -> tuple[dict, list]: return r.json()["inquiry"], await events(paused.id, "approval.inquiry") -async def test_see_in_brackets_does_not_break_the_answer(monkeypatch, shop, client): - Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], BRACKETED)}) +@pytest.mark.parametrize(("said", "shown"), [(BRACKETED, BRACKETED_TEXT), (TOGETHER, TOGETHER_TEXT)], + ids=["bracketed", "together"]) +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"] == BRACKETED_TEXT + assert answer["ok"] is True and answer["text"] == shown doc = artifact_store.load(answer["doc_artifact"]) - assert doc["markdown"] == BRACKETED_TEXT and doc["source"] == BRACKETED + assert doc["markdown"] == shown and doc["source"] == said assert doc["violations"] == [] - assert event.data["answer_text"] == BRACKETED_TEXT + assert event.data["answer_text"] == shown async def test_a_final_answer_that_runs_out_of_time_reports_the_time_limit(monkeypatch, shop, client): diff --git a/backend/tests/test_verify_rationale_rules.py b/backend/tests/test_verify_rationale_rules.py index a97b27d3..217638dc 100644 --- a/backend/tests/test_verify_rationale_rules.py +++ b/backend/tests/test_verify_rationale_rules.py @@ -90,6 +90,24 @@ async def test_see_written_in_brackets_renders_clean_in_the_rationale(monkeypatc 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"] == [] + + # -------------------------------------------------------------------------- # 收尾超时 # -------------------------------------------------------------------------- From 60be16a0bb12d43796448cc289e2437bc002022f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 12 Oct 2026 12:24:14 +0800 Subject: [PATCH 7/7] =?UTF-8?q?M1=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=203=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE=E4=B8=A4=E8=BE=B9=E4=BB=80=E4=B9=88=E9=83=BD=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=97=B6=E4=B9=9F=E5=88=A4=E7=B2=98=E8=BF=9E=EF=BC=8C?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E6=95=B0=E4=B8=8D=E5=86=8D=E5=B9=B6=E6=88=90?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=9B=E5=8F=AA=E8=A3=85=E7=9D=80=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E6=8B=AC=E5=8F=B7=E8=AE=A4=E3=80=90=E3=80=91?= =?UTF-8?q?=EF=BC=BB=EF=BC=BD=E3=80=94=E3=80=95[]=EF=BC=9B=E5=8F=B3?= =?UTF-8?q?=E8=BE=B9=E7=B4=A7=E8=B4=B4=E7=9D=80=E4=B8=8B=E6=96=87=E7=9A=84?= =?UTF-8?q?=E5=8D=8A=E8=A7=92=E9=80=97=E5=8F=B7=E5=90=8E=E9=9D=A2=E8=A1=A5?= =?UTF-8?q?=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渲染(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 万份和基线比没有新并成的数,流式逐字、随机分块都等于整篇 渲染。 --- backend/app/engine/evidence.py | 123 +++++++++----- .../tests/test_challenge_rationale_rules.py | 15 ++ .../tests/test_dual_check_rationale_rules.py | 9 + backend/tests/test_evidence_see_brackets.py | 158 ++++++++++++++++-- backend/tests/test_inquiry_rationale_rules.py | 7 +- backend/tests/test_verify_rationale_rules.py | 15 ++ 6 files changed, 268 insertions(+), 59 deletions(-) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 2f2c329a..023f9ff6 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1813,8 +1813,10 @@ _UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$") _BLANK = " \t\u3000" #: 依据标记 [[see:…]](同 MARKER_RE,只认 MARKER_MAX 以内的) _SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]" -#: 括号:全角、半角(模型偶尔一对里混用,也算一对) -_OPEN, _CLOSE = frozenset("(("), frozenset("))") +#: 括号,每类一对:圆括号、方括号(全角、半角,模型偶尔一对里混用,也算一对)、方头括号、六角括号。核查 agent 拿到的 +#: 查询结果都以「【证据 Qn】」开头,模型照着写成「【[[see:Q1]]】」并不意外 +_PAIRS = (("((", "))"), ("[[", "]]"), ("【", "】"), ("〔", "〕")) +_OPEN, _CLOSE = frozenset("".join(o for o, _ in _PAIRS)), frozenset("".join(c for _, c in _PAIRS)) #: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」) _SEPS = ",、;,;::" _SEP = frozenset(_SEPS) @@ -1826,23 +1828,26 @@ _PAUSE = _SEP - frozenset("::") _ENDING = frozenset("。!?!?") #: 依据周围、渲染后会挤在一起的字:空白和分隔标点 _GAP = frozenset(_BLANK) | _SEP -#: 去掉依据周围的字之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染出一个数)、 -#: 百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、字母,或者 -#: 一个渲染出字的标记。中文和数字之间不加空格也读得对,不算 +#: 依据渲染成空、周围的字去掉之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染 +#: 出一个数)、百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、 +#: 字母、一个渲染出字的标记,或者紧贴着这几样的半角逗号。中文和数字之间不加空格也读得对,不算 _STICKY = frozenset("]%‰.,;:") #: 渲染出来什么都没有的东西(依据标记、只装着依据的括号)在遮盖文本里换成这个字,位置和原文一一对应(_voids) _VOID = "\x00" -#: 只装着依据的括号:里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。头一个依据前面只认空白、 -#: 标点,免得一长串依据后面没有右括号时来回试 -_VOID_BRACKET = re.compile("[((][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*[))]") +#: 只装着依据的括号(_PAIRS 里同一类的一对):里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。 +#: 头一个依据前面只认空白、标点,免得一长串依据后面没有右括号时来回试 +_VOID_BRACKET = re.compile("|".join( + "[" + re.escape(o) + "][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*" + "[" + re.escape(c) + "]" for o, c in _PAIRS)) #: 连着写的几个:中间只隔着空白、分隔标点 _VOID_GROUP = re.compile(_VOID + "+(?:[" + _BLANK + _SEPS + "]*" + _VOID + "+)*") #: 其中只隔着空白的一段(两边都是字时,中间的分隔标点可能就是分句的标点,按它断开各算各的) _VOID_RUN = re.compile(_VOID + "+(?:[" + _BLANK + "]*" + _VOID + "+)*") #: 遮盖文本里看得见的一段:一组里夹着的空白、标点,括号连同里面的空白、标点 _SOLID = re.compile("[^" + _VOID + "]+") -#: 粗体、斜体的星号和粗体的两个下划线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体也去掉了星号) -_EMPHASIS = re.compile(r"\*+|__+") +#: 粗体、斜体的星号,粗体的两个下划线,删除线的两个波浪线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体 +#: 也去掉了星号)。删除线画出来的字照样挨着后面的字读 +_EMPHASIS = re.compile(r"\*+|__+|~~+") def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, @@ -1861,8 +1866,8 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时 不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来; - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_voids、_see_cuts):「最高月份 [[see:Q2]]。」会剩下 - 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。去掉之后两边挨上的字会读成 - 一串的,补回一个空格(_glue)。 + 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。依据(连同去掉的字)两边 + 挨上的字会读成一串的,补一个空格(_glue):「4,919.5[[see:Q1]]212 笔」会读成「4,919.5212 笔」。 prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。 """ @@ -1885,18 +1890,21 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: for group in _VOID_GROUP.finditer(masked): see += _see_cuts(text, masked, group.start(), group.end(), prev) spaces = _glue(text, masked, see, prev) - if not cuts and not see: + if not cuts and not see and not spaces: return text - # 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格 + # 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格; + # 补空格的地方没有要去掉的字(依据两边本来就什么都没有、紧贴着下文的半角逗号后面),在那里插一个 merged: list[list[int]] = [] for a, b in sorted(cuts + see): if merged and a <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], b) else: merged.append([a, b]) + edits = [(a, b, " " if any(a <= x < b for x in spaces) else "") for a, b in merged] + edits += [(x, x, " ") for x in spaces if not any(a <= x < b for a, b in merged)] out, last = [], 0 - for a, b in merged: - out += [text[last:a], " " if any(a <= x < b for x in spaces) else ""] + for a, b, put in sorted(edits): + out += [text[last:a], put] last = b out.append(text[last:]) return "".join(out) @@ -1999,43 +2007,47 @@ def _blank_cut(text: str, start: int, end: int, prev: str) -> list[tuple[int, in def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[int]: - """依据周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回这一片里头一个去掉的字 - 的位置,_tidy 在那里补回一个空格。「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,是另一个 - 数,而且哪项核对都查不出来。 + """依据渲染成空、它周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回要补空格的 + 位置,_tidy 在那里补一个(位置上的字是要去掉的就换成空格,不是就插在它前面)。 + 「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,「[[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] + 笔」读成「4,919.5212 笔」,是另一个数,而且哪项核对都查不出来。 - 往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线。两个括号连着写(「([[see:Q1]]) - ([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**([[see:Q1]])」)都照挨上的字判;依据标记的「]」越过去,不当 - 数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个(「212,[[see:Q1]],[[see:m:gmv]],5」→ - 「212, 5」);挨上的是写作者留着的空白的,不补。 + 每个依据(连着写的一串、只装着依据的括号都算)两边都判,不只看去掉了字的地方:依据前后什么都没有时,两边一样 + 直接挨上。往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线、删除线的波浪线。 + 两个括号连着写(「([[see:Q1]])([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**[[see:Q1]]2 月」)都照挨上 + 的字判;依据标记的「]」越过去,不当数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个 + (「212,[[see:Q1]],[[see:m:gmv]],5」→「212, 5」);挨上的是写作者留着的空白的,不补。右边挨上的是紧贴着下文的 + 半角逗号的(「1,200([[see:Q1]]),900」读成「1,200,900」),空格补在逗号后面,和上面那种读起来一样。 - 空格补在头一个去掉的字那里。粗体把括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面: + 空格补在这一片头一个去掉的字那里;依据两边本来什么都没有、没有去掉的字的,补在头一个依据前面。粗体收尾的星号 + 在它左边,粗体照样成对。粗体把依据、括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面: 前端的行内 Markdown 照样认这对星号,组装文档时包着标记的星号也去掉了,读者看到的一样。要挪到星号外面得先知道 这对星号从哪开始,流式渲染的一块里不一定有。 - 越过的这些字(空白、括号、分隔标点、依据、星号、下划线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE):一片总 - 落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。""" - gone = bytearray(len(text)) + 越过的这些字(空白、括号、分隔标点、依据、星号、下划线、波浪线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE): + 一片总落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。""" + cut = bytearray(len(text)) for a, b in see: - gone[a:b] = b"\x01" * (b - a) - - def hidden(i: int) -> bool: - return bool(gone[i]) or masked[i] == _VOID + cut[a:b] = b"\x01" * (b - a) + gone = bytearray(cut) + for void in re.finditer(_VOID + "+", masked): + gone[void.start():void.end()] = b"\x01" * (void.end() - void.start()) spaces: set[int] = set() seen: set[tuple[int, int]] = set() for run in re.finditer(rb"\x01+", gone): p, q = run.start(), run.end() while p > 0: - if hidden(p - 1): + if gone[p - 1]: p -= 1 - elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] == "__"): + elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] in ("__", "~~")): mark = text[p - 1] while p > 0 and text[p - 1] == mark: p -= 1 else: break while q < len(text): - if hidden(q): + if gone[q]: q += 1 elif emphasis := _EMPHASIS.match(text, q): q = emphasis.end() @@ -2045,13 +2057,31 @@ def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[ continue seen.add((p, q)) left = text[p - 1] if p > 0 else prev - right = text[q:q + 1] - sticks = (right == "[" and MARKER_RE.match(text, q) is not None) or _wordish(right) - if sticks and (_wordish(left) or left in _STICKY): - spaces.add(run.start()) + if not (_wordish(left) or left in _STICKY): + continue + if _shows_word(text, q): + first = cut.find(b"\x01", run.start(), q) + spaces.add(first if first != -1 else run.start()) + elif text[q:q + 1] == ",": + # 右边是写作者紧贴着下文写的半角逗号(「1,200([[see:Q1]]),900」):去掉之后读成「1,200,900」,空格照半角 + # 逗号的排版习惯补在它后面(同「212,[[see:Q1]],5」→「212, 5」)。下文包着粗体的越过星号看。逗号、星号都是 + # _TRAILING_SEE 攒着的字,再往后那个字和它们落在同一块里 + after = q + 1 + while emphasis := _EMPHASIS.match(text, after): + after = emphasis.end() + if _shows_word(text, after): + spaces.add(q + 1) return spaces +def _shows_word(text: str, i: int) -> bool: + """text[i] 起头的是数字、字母,或者一个渲染出字的标记(依据不算):挨在一个数后面会读成一串。""" + if text[i:i + 1] == "[": + marker = MARKER_RE.match(text, i) + return marker is not None and marker.group("kind") != "see" + return _wordish(text[i:i + 1]) + + def _wordish(ch: str) -> bool: """数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。""" return ch.isdecimal() or (ch.isascii() and ch.isalnum()) @@ -2080,9 +2110,11 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool = # 流式渲染:让 llm.token 里出现的就是最终的数字 # -------------------------------------------------------------------------- -#: 文字末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy(依据 -#: 周围的这些字去不去掉、去掉之后补不补空格,要看再往后的那个字,见 _see_cuts、_glue) -_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + "(())" + _SEPS + "*_" + r"]|" + _SEE_MARK + r")+$") +#: 文字末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy +#: (依据周围的这些字去不去掉、补不补空格,要看再往后的那个字,见 _see_cuts、_glue)。半角方括号也在里面:标记收尾的 +#: 「]]」不算,_safe_cut 另外挑出来 +_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + re.escape("".join(sorted(_OPEN | _CLOSE))) + _SEPS + "*_~" + r"]|" + + _SEE_MARK + r")+$") def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int: @@ -2093,8 +2125,8 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non 开头可能是另一个 [。 整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。 - _tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]](后面来的字决定它们去不 - 去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断 + _tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]](后面来的字决定它们 + 去不去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断 一个标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。 """ cut = len(text) @@ -2112,7 +2144,10 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non if m and m.end() == held and m.group("kind") == "table": held = start if (trail := _TRAILING_SEE.search(text, 0, held)) and trail.start() < held: - held = trail.start() + # 攒的一段从一个标记中间开始(标记收尾的「]]」、标记里末尾的空白也是那几种字):标记整个放出去,从它后面攒起 + s = text.rfind("[[", 0, trail.start()) + inside = MARKER_RE.match(text, s, held) if s != -1 else None + held = inside.end() if inside and inside.end() > trail.start() else trail.start() numeric = [m for m in MARKER_RE.finditer(text, 0, held) if m.group("kind") in _UNIT_KINDS and (has_unit is None or has_unit(parse_markers(m.group(0))[0]))] if numeric and len(text[numeric[-1].end():held]) < UNIT_LOOKAHEAD \ diff --git a/backend/tests/test_challenge_rationale_rules.py b/backend/tests/test_challenge_rationale_rules.py index 1bf5317f..530e0812 100644 --- a/backend/tests/test_challenge_rationale_rules.py +++ b/backend/tests/test_challenge_rationale_rules.py @@ -66,6 +66,21 @@ async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch 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"), diff --git a/backend/tests/test_dual_check_rationale_rules.py b/backend/tests/test_dual_check_rationale_rules.py index e606ef32..3fe40f61 100644 --- a/backend/tests/test_dual_check_rationale_rules.py +++ b/backend/tests/test_dual_check_rationale_rules.py @@ -46,6 +46,15 @@ async def test_see_brackets_written_together_do_not_glue_the_number(monkeypatch, 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, "数照样采用" diff --git a/backend/tests/test_evidence_see_brackets.py b/backend/tests/test_evidence_see_brackets.py index 3cafa116..021954c6 100644 --- a/backend/tests/test_evidence_see_brackets.py +++ b/backend/tests/test_evidence_see_brackets.py @@ -4,12 +4,14 @@ [[see:Q1]]」,模型就把它写进括号、句中:「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计 [[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。 -- 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉;去掉之后整对括号当一个依据,外面照下面几条再收拾 - 一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾 -- 去掉之后两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」),补回一个空格:粘起来读成另一个数, - 哪项核对都查不出来。找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线;依据标记的「]」 +- 括号(圆括号、方括号的全角半角,一对里混用也算;方头括号【】、六角括号〔〕)里只有依据的,连括号一起去掉;去掉之后 + 整对括号当一个依据,外面照下面几条再收拾一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个 + (「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾 +- 依据渲染成空、周围的字去掉之后,两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」,依据两边什么都 + 没有的「4,919.5[[see:Q1]]212 笔」也是),补一个空格:粘起来读成另一个数,哪项核对都查不出来。每个依据两边都判, + 找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线、删除线的波浪线;依据标记的「]」 不当数字。一片里最多补一个,挨上的是写作者留着的空白的不补;半角逗号夹着依据、去掉后一个之后同理 - (「212,[[see:Q1]],5」→「212, 5」) + (「212,[[see:Q1]],5」→「212, 5」),右边是紧贴着下文的半角逗号的(「1,200([[see:Q1]]),900」)补在逗号后面 - 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理 - 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」 「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动 @@ -266,6 +268,125 @@ 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 月起回升。"), @@ -281,30 +402,33 @@ def test_a_space_is_put_back_once_and_only_where_it_sticks(catalog, raw, shown): assert compose(raw, catalog)["markdown"] == shown -#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 1–3 段依据、只装着依据的括号、空白、星号,右边以数字、 -#: 字母或者渲染出数的标记开头 +#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 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]]", "**", " ", ","] +_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]]))", " ", ","} +_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 for m in middle)] + 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_a_separator_the_writer_wrote_never_disappears_between_two_numbers(catalog): - """写作者原本隔开了的两边(括号、空白、标点),收拾依据之后不能挨成一串;也不会多出两个连着的空格。""" +def test_two_numbers_never_run_together_once_see_is_dropped(catalog): + """写作者隔开了的两边(括号、空白、标点),或者中间只隔着依据的,收拾依据之后不能挨成一串;也不会多出两个连着的 + 空格。""" bad = [] for left, middle, right in _combos(): raw = f"{left}{middle}{right}。" @@ -433,6 +557,13 @@ def _stream(catalog, raw: str, sizes: list[int]) -> list[str]: "华南居首。[[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): @@ -445,7 +576,8 @@ 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:Q1]]】", + "[[[see:m:gmv]]]"] return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)] diff --git a/backend/tests/test_inquiry_rationale_rules.py b/backend/tests/test_inquiry_rationale_rules.py index 0cde2ab1..a051ad02 100644 --- a/backend/tests/test_inquiry_rationale_rules.py +++ b/backend/tests/test_inquiry_rationale_rules.py @@ -45,6 +45,9 @@ BRACKETED_TEXT = "仍居各区域之首(已排除测试账号):华东为 1 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]: @@ -55,8 +58,8 @@ async def _asked(client, shop) -> tuple[dict, list]: return r.json()["inquiry"], await events(paused.id, "approval.inquiry") -@pytest.mark.parametrize(("said", "shown"), [(BRACKETED, BRACKETED_TEXT), (TOGETHER, TOGETHER_TEXT)], - ids=["bracketed", "together"]) +@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) diff --git a/backend/tests/test_verify_rationale_rules.py b/backend/tests/test_verify_rationale_rules.py index 217638dc..ff914876 100644 --- a/backend/tests/test_verify_rationale_rules.py +++ b/backend/tests/test_verify_rationale_rules.py @@ -108,6 +108,21 @@ async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch 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"] == [] + + # -------------------------------------------------------------------------- # 收尾超时 # --------------------------------------------------------------------------