出口契约:报告的正文仍写在代码块里、一句结论都没切出来时记出具缺口,不再完整出具

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 句前提、去掉注释行排除、去掉信息串过滤、去掉引用信号、去掉句读要求各做过一次,测试变红。
This commit is contained in:
ThearchyHelios
2026-10-06 17:01:44 +08:00
parent 2cf329648d
commit 3e02446542
2 changed files with 233 additions and 0 deletions
+50
View File
@@ -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"
+183
View File
@@ -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