From 3e0244654294efaa10fc91632ba2f29081bc948d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:01:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6=EF=BC=9A?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E7=9A=84=E6=AD=A3=E6=96=87=E4=BB=8D=E5=86=99?= =?UTF-8?q?=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C=E3=80=81=E4=B8=80?= =?UTF-8?q?=E5=8F=A5=E7=BB=93=E8=AE=BA=E9=83=BD=E6=B2=A1=E5=88=87=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E6=97=B6=E8=AE=B0=E5=87=BA=E5=85=B7=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=AE=8C=E6=95=B4=E5=87=BA=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K5 让报告节点去掉整篇外层的 markdown 围栏,规格有意不去的写法(围栏外有前言、只有开头没有收尾、信息串写成 text)仍会让整段正文落在代码块里:结论句 0 句,裁判不调用、反方审稿记 no_claims,没挂依据的、证据相矛盾的都数出 0 句,正式运行照样完整出具,读的人以为结论都核对过。 - nodes/io.py 的 _fenced_body:文档里一句结论都没有,却有信息串为空 / markdown / md / text 的代码块,里面是成句的文字(一行里有汉字又有中文句读,或五个以上英文单词以句末标点收尾)或者写着引用标记,就是正文被藏进了代码块。信息串为空的代码块里也常是 SQL:看内容,代码注释行(--、//、/* */)里的中文句子不算;sql、python、json 这类信息串不看 - _claims 在结论句策略为 judge 或 require_citation(报告节点的或出具契约自己写的)时,碰到这样的报告记一条缺口 GAP_FENCED_BODY「报告「X」的正文写在代码块里,结论句未能核对」,出具降档。缺口不带核查节点的前缀,时间线写在「校验不完整」下、出具横幅归「校验未全部完成」(run/decode.ts 的 splitIssuanceGaps,报告标题像核查节点名的也一样,界面不用改) - 行为不变:有正常结论句的、代码块是代码的、结论句策略为 off 的 测试(新文件 test_contract_fenced_body):认得出的几种(前言、没收尾、text、无信息串、大写 MD、英文、只有列表但写着引用)与认不出的几种(sql、python、json、无信息串的 SQL、SQL 注释里的中文句子、数字表、外面有结论句、没有代码块);正式运行(裁判 + 反方审稿 + 出具契约)里前言、没收尾、text 三种降档并只带这一条缺口,以前是完整出具;正常报告、整篇外层围栏被去掉的、带 SQL 代码块的照常完整出具;require_citation 写在报告节点或契约上同样记,off 不管。去掉挂钩、去掉 0 句前提、去掉注释行排除、去掉信息串过滤、去掉引用信号、去掉句读要求各做过一次,测试变红。 --- backend/app/engine/nodes/io.py | 50 ++++++ backend/tests/test_contract_fenced_body.py | 183 +++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 backend/tests/test_contract_fenced_body.py diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index d1c83c2d..63483b68 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -488,6 +488,50 @@ _ON_UNSUPPORTED = ("degrade", "withhold") #: 要求结论句挂依据的策略:judge 在这一点上和 require_citation 一样严,另外再按裁判的判定判档 _CITING = ("require_citation", "judge") +#: 报告的正文写在代码块里、一句结论都没切出来时的缺口(_fenced_body)。不带核查节点的前缀:界面归在「校验未全部完成」下 +GAP_FENCED_BODY = "报告「{title}」的正文写在代码块里,结论句未能核对" +#: 写的是文字的代码块:信息串为空、markdown、md(报告节点去整篇外层围栏时认的那几个,report._PROSE_INFO),另加 text。 +#: sql、python、json 这类是模型真要贴的代码 +_PROSE_FENCES = frozenset({"", "markdown", "md", "text"}) +_CJK = re.compile(r"[\u4e00-\u9fff]") +#: 中文的句读:一行里有汉字、又有它们的,是在写句子(SQL 里的中文列名、别名不带这些) +_CJK_PUNCT = re.compile(r"[,。!?;:]") +#: 英文句子:五个以上的单词,以句末标点收尾 +_EN_SENTENCE = re.compile(r"[A-Za-z]+(?:[ ,]+[A-Za-z]+){4,}[.!?](?=\s|$)") +#: 代码里的注释行:写着中文句子也是代码的一部分 +_CODE_COMMENT = re.compile(r"\s*(?:--|//|/\*|\*/)") + + +def _prose_line(line: str) -> bool: + if _CODE_COMMENT.match(line): + return False + return (len(_CJK.findall(line)) >= 2 and _CJK_PUNCT.search(line) is not None) \ + or _EN_SENTENCE.search(line) is not None + + +def _fenced_body(doc: dict[str, Any]) -> bool: + """报告的正文写在代码块里:文档里一句结论都没有,却有信息串为空 / markdown / md / text 的代码块,里面是成句的文字 + (_prose_line)或者写着引用([[m:…]] 这类标记,代码里不会这样写)。 + + 报告节点只在整篇正好是一个代码块时去掉外层围栏(report._unfenced),围栏外有前言、只有开头没有收尾、信息串写成 + text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据 + 的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。 + 信息串为空的代码块里也常是 SQL:看内容是不是成句,代码注释里的中文句子不算。""" + from app.engine.evidence import iter_units + + units = list(iter_units(doc)) + if any(unit.get("kind") == "claim" for _, unit in units): + return False + for block, unit in units: + if block.get("type") != "code" or str(block.get("lang") or "").lower() not in _PROSE_FENCES: + continue + segments = [s for s in unit.get("segments") or [] if isinstance(s, dict)] + if any(s.get("ref") for s in segments): + return True + if any(_prose_line(line) for line in "".join(str(s.get("text") or "") for s in segments).split("\n")): + return True + return False + def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, Any], *, evidence_on: bool, governed: bool = False, uncited: list[dict[str, Any]], doc: dict[str, Any] | None = None, @@ -507,6 +551,9 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An 数的是这里重新核对出来的 uncited,不信文档里记的 cites。升级前写的报告(节点产出里没有 claims 这个键, 包括跨着升级还没跑完的运行)一律不管,契约写了也只记一条缺口:报告生成于升级前,结论句未核对。 + + 要核对结论句、正文却写在代码块里(一句结论都没切出来,_fenced_body)的,另记一条缺口:数出 0 句不是「核对过、 + 没问题」。 """ written = payload.get("claims") if isinstance(payload, dict) else None # 报告节点升级后才在产出里记 claims(off 也记):没有这个键就是升级前写的报告 @@ -526,6 +573,9 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An out["gaps"].append(f"出具契约要求结论句由模型裁判,但报告「{title}」未开启模型裁判,结论句未裁判") if not asks: return + if doc is not None and _fenced_body(doc): + # 正文在代码块里,一句结论都没切出来:没挂依据的、证据相矛盾的都是 0 句,不能当成「核对过、没问题」 + out["gaps"].append(GAP_FENCED_BODY.format(title=title)) on_uncited = max(asks, key=_ON_UNCITED.index) if governed and on_uncited == "ignore": on_uncited = "degrade" diff --git a/backend/tests/test_contract_fenced_body.py b/backend/tests/test_contract_fenced_body.py new file mode 100644 index 00000000..d6c30c61 --- /dev/null +++ b/backend/tests/test_contract_fenced_body.py @@ -0,0 +1,183 @@ +"""出口契约:报告的正文仍写在代码块里、一句结论都没切出来时记出具缺口(nodes/io.py 的 _fenced_body)。 + +K5 让报告节点去掉整篇外层的 markdown 围栏(report._unfenced),规格有意不去的写法仍会让正文落在代码块里:围栏外有 +前言、只有开头没有收尾、信息串写成 text。这时结论句是 0 句:裁判没有句子可判、反方审稿记 no_claims,以前正式运行 +照样完整出具,读的人以为结论都核对过。 + +- 结论句策略为 judge 或 require_citation(报告节点的,或出具契约自己写的)、结论句 0 句、又有信息串为空 / markdown / + md / text 的代码块,里面是成句的文字或写着引用:记一条缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部 + 完成」下(run/decode.ts 的 splitIssuanceGaps) +- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的(信息串为空、里面是代码的也算,代码注释里的中文句子不算); + 结论句策略为 off 的 + +模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。 +""" +from __future__ import annotations + +import pytest +from langchain_core.messages import AIMessage + +from app.engine.evidence import compose_doc +from app.engine.nodes.io import GAP_FENCED_BODY, _fenced_body +from app.providers.mock_model import MockChatModel +from tests.test_report_fenced import ( # noqa: F401 - engine_up、shop 是夹具,导入即可用 + EAST, + SOUTH, + SQL, + TEXT, + Models, + audited, + chain, + engine_up, + fenced, + finish, + logs, + node, + output_of, + report_of, + shop, +) + +#: 写作者把正文包进代码块、又不是整篇只有一个代码块(报告节点不去围栏)的几种写法 +PREAMBLE = f"以下是本周的销售报告:\n\n{fenced(TEXT)}" +UNCLOSED = f"```markdown\n{TEXT}" +AS_TEXT = fenced(TEXT, "text") +HIDDEN = [pytest.param(PREAMBLE, id="preamble"), pytest.param(UNCLOSED, id="unclosed"), + pytest.param(AS_TEXT, id="text")] + + +def doc_of(raw: str) -> dict: + return compose_doc(raw, {}, node_id="write") + + +# -------------------------------------------------------------------------- +# 认得出哪些代码块里写的是正文 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("raw", [ + pytest.param("以下是本周的销售报告:\n\n```markdown\n华东销售额 1500,居各区域之首。\n```", id="preamble"), + pytest.param("```markdown\n## 本周概览\n\n华东销售额 1500,居各区域之首。", id="unclosed"), + pytest.param("```text\n华东销售额 1500,居各区域之首。\n```", id="text"), + pytest.param("```\n华东销售额 1500,居各区域之首。\n```", id="no-info"), + pytest.param("```MD\n华东销售额 1500,居各区域之首。\n```", id="upper-md"), + pytest.param("```markdown\nEast region sales rose sharply this week.\n```", id="english"), + # 只有列表、没有句读,可写着报告的引用:代码里不会这样写 + pytest.param("以下是周报:\n\n```markdown\n- 华东销售额 [[m:gmv]]\n- 华南销售额 [[m:gmv_s]]\n```", id="markers"), +]) +def test_a_report_body_in_a_prose_code_block_is_recognised(raw): + doc = doc_of(raw) + assert doc["stats"]["claims"] == 0, "背景:正文在代码块里,一句结论都切不出来" + assert _fenced_body(doc) is True + + +@pytest.mark.parametrize("raw", [ + # 代码:信息串写明了语言 + pytest.param("```sql\nSELECT region, SUM(amount) AS gmv FROM orders WHERE amount > 100 GROUP BY region\n```", id="sql"), + pytest.param("```python\nprint('华东销售额最高,华南次之。')\n```", id="python"), + pytest.param('```json\n{"区域": "华东,华南"}\n```', id="json"), + # 信息串为空、里面是代码:没有成句的文字;代码注释里的中文句子不算 + pytest.param("```\nSELECT region, SUM(amount) FROM orders GROUP BY region\n```", id="bare-sql"), + pytest.param("```\n-- 按支付日统计,排除退款。\nSELECT SUM(amount) FROM orders\n```", id="sql-comment"), + pytest.param("```\n/* 按支付日统计,排除退款。 */\nSELECT SUM(amount) FROM orders\n```", id="block-comment"), + pytest.param("```text\n区域 销售额\n华东 1500\n华南 900\n```", id="table-of-numbers"), + # 有正常的结论句:正文没被藏起来 + pytest.param("华东销售额超过华南。\n\n```markdown\n华东销售额 1500,居各区域之首。\n```", id="claims-outside"), + # 没有代码块 + pytest.param("以下是本周的销售报告。", id="no-code"), +]) +def test_code_or_a_report_with_claims_is_not_taken_for_a_hidden_body(raw): + assert _fenced_body(doc_of(raw)) is False + + +# -------------------------------------------------------------------------- +# 端到端:正式运行(裁判 + 反方审稿 + 出具契约) +# -------------------------------------------------------------------------- + + +def gap(title: str = "写周报") -> str: + return GAP_FENCED_BODY.format(title=title) + + +@pytest.mark.parametrize("raw", HIDDEN) +async def test_a_body_left_in_a_code_block_degrades_the_formal_issuance(monkeypatch, shop, raw): + """正文落在代码块里:裁判没有句子可判(不调用)、反方审稿记 no_claims,出口契约不再完整出具,记一条缺口说清原因。""" + models = Models(monkeypatch, shop, raw) + row = await finish(audited(shop), run_class="formal") + assert row.status == "succeeded", row.error + _, doc = await report_of(row.id) + assert doc["source"] == raw and doc["stats"]["claims"] == 0, "背景:报告节点不去这几种围栏" + assert "code" in [b["type"] for b in doc["blocks"]] + assert models.judges == [] and models.reviewed == [] + assert [d["reason"] for d in await logs(row.id, "challenge_skipped")] == ["no_claims"] + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded", issuance + assert issuance["gaps"] == [gap()], issuance["gaps"] + + +@pytest.mark.parametrize("raw", [ + pytest.param(TEXT, id="plain"), + pytest.param(fenced(TEXT), id="whole-report-unwrapped"), + pytest.param(f"华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]\n\n{SQL}\n\n华南销售额超过华北。[[see:Q1]]", + id="sql-block"), +]) +async def test_a_report_with_claims_is_issued_as_before(monkeypatch, shop, raw): + """有正常的结论句(整篇外层围栏已由报告节点去掉的也是)、代码块是 SQL 的:判过、审过,照常完整出具,不记这条缺口。""" + models = Models(monkeypatch, shop, raw) + row = await finish(audited(shop), run_class="formal") + assert row.status == "succeeded", row.error + assert sorted(models.reviewed) == sorted([EAST, SOUTH]) + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance + + +# -------------------------------------------------------------------------- +# 结论句策略:require_citation 同样记,off 不管 +# -------------------------------------------------------------------------- + +KPI = "{'gmv': 45678.5, 'orders': 1234}" +METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}] +#: 前言 + 代码块包住正文,正文里的引用都解析得了 +CITED = "以下是本周周报:\n\n```markdown\n本周销售额 [[m:gmv]],较上周有所增长。[[see:m:gmv]]\n```" + + +def weekly(*, claims: str | None = None, contract_claims: str | None = None) -> dict: + """输入 → 取数 → 口径卡 → 报告(claims 是报告节点的结论句策略)→ 成果(引用模式的出具契约)。""" + return chain( + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v1", metrics=METRICS), + node("write", "report", "写周报", instructions="写周报", max_repairs=0, + **({"claims": claims} if claims else {})), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["caliber"], + **({"claims": contract_claims} if contract_claims else {})}), + ) + + +def writer(monkeypatch, text: str) -> None: + monkeypatch.setattr(MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + + +@pytest.mark.parametrize("claims, contract_claims", [ + pytest.param("require_citation", None, id="report-node"), + pytest.param(None, "require_citation", id="contract"), +]) +async def test_require_citation_records_the_gap_too(monkeypatch, claims, contract_claims): + """要求结论句挂依据(报告节点或出具契约写的):同样数不出结论句,记同一条缺口。""" + writer(monkeypatch, CITED) + row = await finish(weekly(claims=claims, contract_claims=contract_claims)) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" and issuance["gaps"] == [gap()], issuance + assert issuance["claims"]["uncited_claims"] == 0 + + +async def test_without_a_claims_policy_nothing_changes(monkeypatch): + """结论句策略为 off:出口不核对结论句,同样的报告照旧完整出具(数字都在代码块里、也都有出处)。""" + writer(monkeypatch, CITED) + row = await finish(weekly()) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance + assert "claims" not in issuance