mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
出口契约认藏在代码块里的正文:只挂 [[see:]] 依据的、不带句读的列表项也认,代码里的注释和字符串不算句子,英文句子改成线性扫描
上一轮只看片段里的引用和带全角句读的行,几种写法仍会让正文藏在代码块里照样完整出具,另有几种代码被当成正文: - [[see:]] 记在句子的 see 上、不在片段里:只用 see 挂依据的列表现在也认 - 中文报告的列表项常不带句读:以 - * + • 或「1.」「1)」开头、后面有两个以上汉字的行算成句;信息串为空、看得出是 SQL / Python 的块里(有 SELECT、FROM、def、import 这类开头的行),以 * + - 开头的是运算的续行,不当列表项 - 信息串写成 plaintext、txt、plain 的和 text 一样算写文字的代码块 - 判句之前去掉字符串字面量(可带 f、r、b、u、N 前缀;英文的 it's 这类不动)和行尾注释(-- // #),# 开头的行、 跨行的 /* … */ 块注释都不算:SQL 行尾的中文注释、print 里带全角标点的字符串不再让 0 句的报告降档 - 认英文句子的正则对很长、不带句末标点的一行是平方级回溯(出口复核在事件循环上同步执行),改成按词串逐个数单词, 耗时随行长线性增长 check-decode 补一项:从 io.py 读出 GAP_FENCED_BODY,钉住它归「校验不完整」、不进核查的两组。补参数化用例和端到端 用例:只挂 see 的列表、不带句读的列表、plaintext 包住正文的正式运行降档并带这条缺口;带行尾注释的 SQL 照常完整出具; 四万多字的长行一秒内判完。
This commit is contained in:
@@ -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"(?<![A-Za-z0-9_])[rRbBuUfFnN]{0,2}(?:'[^'\n]*'|\"[^\"\n]*\")")
|
||||
#: 看得出是 SQL、Python 的行。信息串为空的代码块里有这样的行时,以 * + - 开头的是运算的续行(「* 数量 AS 金额」),
|
||||
#: 不当列表项
|
||||
_CODE_LINE = re.compile(r"\s*(?:select|with|from|where|join|group\s+by|order\s+by|having|union|insert|update"
|
||||
r"|delete|create|def|class|import|return)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _prose_line(line: str) -> 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),两处一字不差
|
||||
|
||||
Reference in New Issue
Block a user