diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 63483b68..a6da3ece 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -490,45 +490,87 @@ _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"}) +#: 写的是文字的代码块:信息串为空、markdown、md(报告节点去整篇外层围栏时认的那几个,report._PROSE_INFO),另加 text、 +#: plaintext、txt、plain 这几种纯文字的写法。sql、python、json 这类是模型真要贴的代码 +_PROSE_FENCES = frozenset({"", "markdown", "md", "text", "plaintext", "txt", "plain"}) _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*(?:--|//|/\*|\*/)") +#: 英文的词串:单词之间只隔空格、逗号。五个以上的单词紧跟句末标点算一句(_en_sentence) +_EN_RUN = re.compile(r"[A-Za-z]+(?:[ ,]+[A-Za-z]+)*") +_EN_WORD = re.compile(r"[A-Za-z]+") +#: 列表项:- * + • 或「1.」「1)」开头。中文报告的列表项常不带句读,后面有两个以上汉字就算在写句子 +_LIST_ITEM = re.compile(r"\s*(?:[-*+•]|\d+[.)])\s+(.*)") +#: 整行是注释(-- // # /* */ 开头;# 开头的 Markdown 标题本来也不是句子),和行尾隔着空白的注释:写着中文句子也是 +#: 代码的一部分 +_COMMENT_LINE = re.compile(r"\s*(?:--|//|#|/\*|\*/)") +_TRAILING_COMMENT = re.compile(r"\s(?:--|//|#).*") +#: 代码里的字符串字面量('…'、"…",可带 f、r、b、u、N 这类前缀):里面的中文标点是数据。引号前面紧挨着字母、数字的 +#: 不算开头(英文的 it's、don't) +_STRING_LITERAL = re.compile(r"(? bool: - if _CODE_COMMENT.match(line): +def _en_sentence(line: str) -> bool: + """一句英文:五个以上的单词(之间只隔空格、逗号)紧跟句末标点,后面是空白或行尾。按词串逐个数,不靠正则回溯: + 回溯的写法遇上很长、不带句末标点的一行,耗时随行长平方增长,出口复核又是在事件循环上同步执行的。""" + for run in _EN_RUN.finditer(line): + end = run.end() + if line[end:end + 1] in (".", "!", "?") and (end + 1 == len(line) or line[end + 1].isspace()) \ + and len(_EN_WORD.findall(run.group())) >= 5: + return True + return False + + +def _prose_line(line: str, *, lists: bool = True) -> bool: + """这一行是不是在写句子:去掉字符串字面量和行尾注释之后,有两个以上汉字又有中文句读,或者是一句英文;lists 为真时, + 列表项后面有两个以上汉字也算。整行是注释的不算。""" + if _COMMENT_LINE.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 + line = _TRAILING_COMMENT.sub("", _STRING_LITERAL.sub(" ", line), count=1) + if (len(_CJK.findall(line)) >= 2 and _CJK_PUNCT.search(line) is not None) or _en_sentence(line): + return True + item = _LIST_ITEM.match(line) if lists else None + return item is not None and len(_CJK.findall(item.group(1))) >= 2 + + +def _drop_block_comments(text: str) -> str: + """去掉 /* … */(可以跨行):块注释里的中文句子也是代码的一部分。没收尾的不去,开头那一行照注释行跳过。""" + kept: list[str] = [] + at = 0 + while (start := text.find("/*", at)) != -1 and (end := text.find("*/", start + 2)) != -1: + kept += [text[at:start], "\n"] + at = end + 2 + return "".join(kept) + text[at:] def _fenced_body(doc: dict[str, Any]) -> bool: - """报告的正文写在代码块里:文档里一句结论都没有,却有信息串为空 / markdown / md / text 的代码块,里面是成句的文字 - (_prose_line)或者写着引用([[m:…]] 这类标记,代码里不会这样写)。 + """报告的正文写在代码块里:文档里一句结论都没有,却有写文字的代码块(_PROSE_FENCES),里面写着引用(片段里的 + [[m:…]] 这类标记、句子上挂的 [[see:…]] 依据,代码里不会这样写)或者成句的文字(_prose_line)。 报告节点只在整篇正好是一个代码块时去掉外层围栏(report._unfenced),围栏外有前言、只有开头没有收尾、信息串写成 text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据 的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。 - 信息串为空的代码块里也常是 SQL:看内容是不是成句,代码注释里的中文句子不算。""" + 信息串为空的代码块里也常是 SQL、Python:看内容是不是成句,注释、字符串字面量里的中文句子不算;整块看得出是代码 + (_CODE_LINE)的,以 * + - 开头的行是运算的续行,不当列表项。""" 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: + lang = str(block.get("lang") or "").lower() + if block.get("type") != "code" or lang 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): + if unit.get("see") or 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")): + lines = _drop_block_comments("".join(str(s.get("text") or "") for s in segments)).split("\n") + lists = bool(lang) or not any(_CODE_LINE.match(line) for line in lines) + if any(_prose_line(line, lists=lists) for line in lines): return True return False diff --git a/backend/tests/test_contract_fenced_body.py b/backend/tests/test_contract_fenced_body.py index be6006b5..0c832d48 100644 --- a/backend/tests/test_contract_fenced_body.py +++ b/backend/tests/test_contract_fenced_body.py @@ -5,15 +5,18 @@ K5 让报告节点去掉整篇外层的 markdown 围栏(report._unfenced), 照样完整出具,读的人以为结论都核对过。 - 结论句策略为 judge 或 require_citation(报告节点的,或出具契约自己写的)、结论句 0 句、又有信息串为空 / markdown / - md / text 的代码块,里面是成句的文字或写着引用:记一条缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部 - 完成」下(run/decode.ts 的 splitIssuanceGaps) -- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的(信息串为空、里面是代码的也算,代码注释里的中文句子不算); - 结论句策略为 off 的 + md / text / plaintext / txt / plain 的代码块,里面是成句的文字(带句读的中文、成句的英文、不带句读的列表项)或写着 + 引用(片段里的引用、只挂依据的 [[see:]]):记一条缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部完成」 + 下(run/decode.ts 的 splitIssuanceGaps) +- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的(信息串为空、里面是代码的也算:注释、字符串里的中文标点 + 不算句子,SQL 以 * + - 开头的续行不算列表项);结论句策略为 off 的 模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。 """ from __future__ import annotations +import time + import pytest from langchain_core.messages import AIMessage @@ -42,8 +45,14 @@ from tests.test_report_fenced import ( # noqa: F401 - engine_up、shop 是夹 PREAMBLE = f"以下是本周的销售报告:\n\n{fenced(TEXT)}" UNCLOSED = f"```markdown\n{TEXT}" AS_TEXT = fenced(TEXT, "text") +#: 只用 [[see:]] 挂依据的列表:依据记在句子上(unit 的 see),片段里没有引用;列表项也不带句读 +SEE_LIST = "## 本周结论\n\n- 华东销售额居各区域之首 [[see:Q1]]\n- 华南销售额超过华北 [[see:Q1]]" +#: 不带句读、也没挂依据的列表:中文报告的列表项常这样写 +BARE_LIST = "## 本周结论\n\n- 华东销售额居各区域之首\n- 华南销售额超过华北" HIDDEN = [pytest.param(PREAMBLE, id="preamble"), pytest.param(UNCLOSED, id="unclosed"), - pytest.param(AS_TEXT, id="text")] + pytest.param(AS_TEXT, id="text"), pytest.param(fenced(TEXT, "plaintext"), id="plaintext"), + pytest.param(f"以下是本周周报:\n\n{fenced(SEE_LIST)}", id="see-only-list"), + pytest.param(f"以下是本周周报:\n\n{fenced(BARE_LIST)}", id="list-without-punctuation")] def doc_of(raw: str) -> dict: @@ -62,8 +71,22 @@ def doc_of(raw: str) -> dict: 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("```markdown\nEast region's sales rose sharply this week.\n```", id="english-apostrophe"), + # 正文里用半角引号括起一个词:去掉代码字符串的那一步不能把句子吃掉 + pytest.param('```markdown\n华东的"销售额"居各区域之首,华南次之。\n```', id="quoted-word"), + # 纯文字的另几种写法 + pytest.param("```plaintext\n华东销售额 1500,居各区域之首。\n```", id="plaintext"), + pytest.param("```txt\n华东销售额 1500,居各区域之首。\n```", id="txt"), + pytest.param("```Plain\n华东销售额 1500,居各区域之首。\n```", id="plain"), # 只有列表、没有句读,可写着报告的引用:代码里不会这样写 pytest.param("以下是周报:\n\n```markdown\n- 华东销售额 [[m:gmv]]\n- 华南销售额 [[m:gmv_s]]\n```", id="markers"), + # 只挂了依据([[see:]] 记在句子上,片段里没有引用),列表项短得不成句也认 + pytest.param("以下是周报:\n\n```markdown\n- East [[see:m:gmv]]\n- South [[see:m:gmv]]\n```", id="see-only"), + # 不带句读的列表项:标记后面有两个以上汉字 + pytest.param("以下是周报:\n\n```markdown\n## 本周结论\n\n- 华东销售额居各区域之首\n- 华南销售额超过华北\n```", + id="bullets"), + pytest.param("```text\n1. 华东销售额居各区域之首\n2) 华南销售额超过华北\n```", id="numbered"), + pytest.param("```\n* 华东销售额居各区域之首\n+ 华南销售额超过华北\n• 华北销售额持平\n```", id="bare-bullets"), ]) def test_a_report_body_in_a_prose_code_block_is_recognised(raw): doc = doc_of(raw) @@ -81,6 +104,20 @@ def test_a_report_body_in_a_prose_code_block_is_recognised(raw): 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("```\n/*\n * 按支付日统计,排除退款。\n 已退款的单不计入:见口径说明。\n */\nSELECT SUM(amount) FROM orders\n```", + id="multi-line-comment"), + # 行尾注释(-- // #)、# 开头的注释行,里面写着中文句子也是代码的一部分 + pytest.param("```\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region -- 按区域汇总,排除退款\n```", + id="trailing-sql-comment"), + pytest.param("```\n# 按区域汇总,排除退款。\ntotal = sum(rows) # 单位:元,不含税\n```", id="hash-comments"), + pytest.param("```\nconst total = sum(rows); // 按区域汇总,排除退款\n```", id="trailing-slash-comment"), + # 字符串字面量里的中文标点是数据(带 f、N 这类前缀的也是) + pytest.param('```\nprint("销售额:", total)\nprint(f"订单数:{n},客单价:{avg}")\n```', id="string-literals"), + pytest.param("```\nSELECT * FROM orders WHERE note = '已退款,不计入:测试' OR memo = N'作废,重开'\n```", + id="sql-strings"), + # 信息串为空、看得出是 SQL 的块:以 * + - 开头的是运算的续行,后面是中文列名也不是列表项 + pytest.param("```\nSELECT 单价\n * 数量 AS 金额\n + 运费 AS 合计\n - 折扣金额 AS 实付\nFROM 订单\n```", + id="sql-continuation"), pytest.param("```text\n区域 销售额\n华东 1500\n华南 900\n```", id="table-of-numbers"), # 有正常的结论句:正文没被藏起来 pytest.param("华东销售额超过华南。\n\n```markdown\n华东销售额 1500,居各区域之首。\n```", id="claims-outside"), @@ -91,6 +128,17 @@ 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 test_a_long_line_without_a_full_stop_takes_linear_time(): + """很长、不带句末标点的一行英文单词:逐串数单词,耗时随行长线性增长。按正则回溯认英文句子时,四万多字的一行要十几 + 秒(行长翻倍、耗时翻四倍),出口复核在事件循环上同步执行,整个服务都会卡住。""" + line = "alpha beta " * 4000 + doc = {"blocks": [{"type": "code", "lang": "", "units": [ + {"kind": "code", "see": [], "segments": [{"kind": "text", "text": line}]}]}]} + started = time.perf_counter() + assert _fenced_body(doc) is False + assert time.perf_counter() - started < 1.0 + + # -------------------------------------------------------------------------- # 端到端:正式运行(裁判 + 反方审稿 + 出具契约) # -------------------------------------------------------------------------- @@ -174,6 +222,17 @@ async def test_require_citation_records_the_gap_too(monkeypatch, claims, contrac assert issuance["claims"]["uncited_claims"] == 0 +async def test_sql_with_a_trailing_comment_is_issued_as_before(monkeypatch): + """一句结论都没有的报告(只有口径说明)贴了一段不带信息串的 SQL,行尾注释里写着中文句子:那是代码,不是藏起来的 + 正文,照常完整出具,不记这条缺口。""" + writer(monkeypatch, "## 口径说明\n\n本周报告按支付日期统计。\n\n```\n" + "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region -- 按区域汇总,排除退款\n```") + row = await finish(weekly(claims="require_citation")) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance + + async def test_without_a_claims_policy_nothing_changes(monkeypatch): """结论句策略为 off:出口不核对结论句,同样的报告照旧完整出具(数字都在代码块里、也都有出处)。""" writer(monkeypatch, CITED) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 2ebcd811..f371282e 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2280,6 +2280,17 @@ await section('核查类日志与出具缺口(期 4):无法复算、未能 check('……两路不一致(选了盲算、两边都不对、没作出选择)和被推翻的归「核查未通过」,无法复算的归「核查未完成」', served.failed.length === 4 && served.unfinished.length === 1 && served.unfinished[0].startsWith('双盲复算:指标「订单数」无法复算:') && served.other.length === 0, JSON.stringify(served)) + // 出口契约自己报的「正文写在代码块里、结论句未能核对」(nodes/io.py 的 GAP_FENCED_BODY)不是核查节点报的:不带核查 + // 节点的前缀,横幅归「校验未全部完成」、时间线归「校验不完整」。服务端改了说法、加了前缀,或者这边改了分组,这里先变红 + const fencedGap = ioPy.match(/^GAP_FENCED_BODY = "([^"]+)"$/m)?.[1]?.replace('{title}', '写周报') + const fencedRow = flatten(mod.decodeRun([ + E(1, 'node.started', 'done', { node_type: 'output', label: '成果' }), + E(2, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, gaps: [fencedGap] }), + E(3, 'node.finished', 'done', { duration_ms: 5 }), + ])).find((s) => s.kind === 'issuance') + check('出口契约报的「正文写在代码块里」:读出了服务端的写法,归「校验不完整」,不进核查的两组', !!fencedGap + && JSON.stringify(mod.splitIssuanceGaps([fencedGap])) === JSON.stringify({ failed: [], unfinished: [], other: [fencedGap] }) + && fencedRow?.title === `降档出具:校验不完整:${fencedGap}`, `${fencedGap} / ${fencedRow?.title}`) // dual_check_catalog 的 written 带了写进去的值(期 4):时间线写「…记为「值」」,值的写法和审批卡上的「会记为「值」」 // 同一个函数(catalogPatch 的 patchValueText),两处一字不差