From 6d9d31395b2602ff8c992647eee8e38803a89a8d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 19:51:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6=E8=AE=A4?= =?UTF-8?q?=E8=97=8F=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=94=B9=E4=B8=BA=E9=80=90=E8=A1=8C=E6=A0=87?= =?UTF-8?q?=E5=87=BA=E4=BB=A3=E7=A0=81=EF=BC=9A=E5=86=99=E6=98=8E=E6=98=AF?= =?UTF-8?q?=E6=96=87=E5=AD=97=E7=9A=84=E5=9D=97=E4=B8=8D=E5=86=8D=E5=9B=A0?= =?UTF-8?q?=E4=B8=BA=E4=B8=80=E8=A1=8C=20SQL=E3=80=81=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E5=83=8F=20YAML=20=E7=9A=84=E9=94=AE=E6=88=96=20+=20/=20-=20?= =?UTF-8?q?=E8=A6=81=E7=82=B9=E5=B0=B1=E6=95=B4=E5=9D=97=E5=BD=93=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E5=85=AC=E5=BC=8F=E3=80=81JSON=E3=80=81?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=A0=91=E8=BF=99=E7=B1=BB=E5=83=8F=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E8=A1=8C=E7=85=A7=E4=BB=A3=E7=A0=81=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮先判整块:只要有一行以 SQL / Python 关键字或小写键开头,或者每行都以 + - 空格 @ 开头,整块就当代码,只认去掉 注释、字符串之后还成句的行。写成 markdown、text 的块里,+ / - 开头的正负面要点、正文后面另嵌的 ~~~sql、缩进的 select、 一行「- note: …」都让不带句读的结论认不出,藏进代码块反倒完整出具。反过来,写明是文字的块不去字符串,六字连写、结论词 又对公式、JSON、目录树、print、流程箭头这些行生效:结论句为 0 的报告贴一段带「占比」的公式就降档,缺口还说正文写在 代码块里。 - 逐行标出代码(_code_lines),不再整块判:块里再嵌、收得了的代码块(~~~sql、````,开了没收的那一行照普通的行看); SQL / Python 语句从开头那一行到这一段结束,续行也算;整段是 YAML 的(第一行是小写键,其余是键或缩进的行;写明是 文字的块里另要有值为空的键,一行一个「- note: 一句话」是正文里的标签);像数据、公式的行(前面有字的等号和箭头、 画目录树的制表符、JSON 的开头和「"键":」、函数调用、文件名、逗号隔开的表头)。代码的行只认去掉注释、字符串之后 成句的,别的行照旧:写明是文字的按 _written,信息串为空的按 _prose_line - diff:写明是文字的块里 + - 后面空一格的是列表项,紧挨着字写的才是 diff;信息串为空的块里第一个改动行是 + 的是 正负面要点(diff 先列删掉的行),有 @@ 块头的照旧是 diff - 一段到空行为止,只写着块注释的行不算空行;写明是文字的块里顶格的标题另起一段。块注释、三个引号括起的多行字符串 涂成空格,行数不变 - SQL 关键字只认全小写、全大写,def / class 要写成定义的样子:「From 数据看……」「class A 门店……」是正文。代码的行里 -- // 紧挨着前面的字也算注释 - 取舍写进 _fenced_body 的说明:一块里只要有一行在写正文就记缺口,拿不准的行宁可当代码,藏起来的正文总有别的行认得出 测试:复核里的写法逐一补上:+ / - 要点(markdown、text、信息串为空)、嵌 ~~~sql、缩进的 select、小写键的标签,正式 运行降档并只带这条缺口;带「占比」「增长率」的公式、写成 text 的 JSON 和目录树,在结论句为 0 的报告里照常完整出具。 单元参数另补 print、groupby、流程箭头、文件名、逗号表头、JSON 数组、多行 SQL 夹块注释、docstring、嵌套的 YAML、开了 没收和收尾更长的嵌套围栏、行首箭头、首字母大写的关键字、先列 + 的带 @@ diff,并钉住两条取舍:写成 text 又空一格的 diff 照列表看;信息串为空、整段都是小写键的照 YAML 看。长行耗时用例扩到七种长行、两种多行围栏、两种信息串。逐一去掉 36 处规则(diff、嵌套围栏、语句、关键字大小写、def / class、YAML、分段、像数据的七种写法、注释、块注释、多行字符串、 正文判法、结构片段、挂钩),各有用例变红。 --- backend/app/engine/nodes/io.py | 176 ++++++++++++++++----- backend/tests/test_contract_fenced_body.py | 157 +++++++++++++++--- 2 files changed, 271 insertions(+), 62 deletions(-) diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 4dd08310..febbedfb 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -501,20 +501,37 @@ _CJK_RUN = re.compile(r"[\u4e00-\u9fff]{6,}") #: 英文的词串:单词之间只隔空格、逗号。五个以上的单词紧跟句末标点算一句(_en_sentence) _EN_RUN = re.compile(r"[A-Za-z]+(?:[ ,]+[A-Za-z]+)*") _EN_WORD = re.compile(r"[A-Za-z]+") -#: Markdown 标题:正文里的标题切块时记为 heading,从不算结论句(evidence._scan_blocks),代码块里的也不算在写正文 -_HEADING = re.compile(r"\s*#{1,6}\s") -#: 整行是注释(-- // # /* */ 开头;# 开头的 Markdown 标题本来也不是句子),和行尾隔着空白的注释:写着中文句子也是 -#: 代码的一部分 +#: 整行是注释(-- // # /* */ 开头),和行尾隔着空白的注释:写着中文句子也是代码的一部分。# 开头的 Markdown 标题本来 +#: 也不算结论句(evidence._scan_blocks 把它切成 heading) _COMMENT_LINE = re.compile(r"\s*(?:--|//|#|/\*|\*/)") _TRAILING_COMMENT = re.compile(r"\s(?:--|//|#).*") +#: 看得出是代码的行里,-- // 紧挨着前面的字也是注释(SELECT SUM(amount)--按区域汇总):字符串已经先去掉了 +_CODE_COMMENT = re.compile(r"(?:--|//|\s#).*") +#: 写明是文字的块里顶格写的 Markdown 标题:标题前后是两段(_code_lines) +_MD_HEADING = re.compile(r"#{1,6}\s") #: 代码里的字符串字面量('…'、"…",可带 f、r、b、u、N 这类前缀):里面的中文标点是数据。引号前面紧挨着字母、数字的 #: 不算开头(英文的 it's、don't) _STRING_LITERAL = re.compile(r"(? 」是列表的 +#: 记号,不算 +_DATA_LINE = re.compile(r"\S\s*(?:[==→⟶⇒]|->)|[\u2500-\u257f]|^\s*[\[{]\s*[\"'\[{]|[\"']\s*:" + r"|(? bool: @@ -543,41 +560,116 @@ def _concluding(line: str) -> bool: def _written(line: str) -> bool: - """信息串写明是文字(markdown、text 这类)、里面也看不出是代码的块里,这一行在写正文:成句、按正文的规矩会算结论, - 或者一行里连着六个以上汉字。不看列表标记:标记的写法太多(- 1. 1、 一、 (1) · ●,也常不带标记),补一种漏一种; - 也不当代码看,# -- 和半角引号都是正文里的符号。标题不算。""" - if _HEADING.match(line): + """信息串写明是文字(markdown、text 这类)的块里,不是代码的这一行在写正文:成句、按正文的规矩会算结论,或者一行里 + 连着六个以上汉字。不看列表标记:标记的写法太多(- + 1. 1、 一、 (1) · ●,也常不带标记),补一种漏一种;也不去 + 字符串、行尾注释,# -- 和半角引号都是正文里的符号。整行是注释、标题的不算。""" + if _COMMENT_LINE.match(line): return False return _sentence(line) or _CJK_RUN.search(line) is not None or _concluding(line) -def _code_like(lines: list[str]) -> bool: - """块里看得出是代码:有 SQL、Python 开头的行(_CODE_LINE)、YAML 的键(_YAML_KEY),或者整块是 diff(有 + 开头的 - 行也有 - 开头的行,其余是空格开头的上下文行、@@ 开头的块头)。""" - if any(_CODE_LINE.match(line) or _YAML_KEY.match(line) for line in lines): - return True - heads = {line[:1] for line in lines if line.strip()} - return {"+", "-"} <= heads <= {"+", "-", " ", "@"} - - def _prose_line(line: str, *, code: bool) -> bool: - """信息串为空、或者看得出是代码的块里,这一行在写正文:去掉字符串字面量和行尾注释之后成句;整块看不出是代码时, - 按正文的规矩会算结论的也算(不带句读的列表项、不带标记的行)。不带句读、也不像结论的不算:YAML 里的列名、diff 里 - 改的口径和列表项没有分别。整行是注释的不算。""" + """代码的行(code),或者信息串为空的块里的行,在写正文:去掉字符串字面量和行尾注释之后成句;不是代码的行另认按正文 + 的规矩会算结论的(不带句读的列表项、不带标记的行)。不带句读、也不像结论的不算:YAML 里的列名、diff 里改的口径和 + 列表项没有分别。整行是注释的不算。""" if _COMMENT_LINE.match(line): return False - line = _TRAILING_COMMENT.sub("", _STRING_LITERAL.sub(" ", line), count=1) + line = (_CODE_COMMENT if code else _TRAILING_COMMENT).sub("", _STRING_LITERAL.sub(" ", line), count=1) return _sentence(line) or (not code and _concluding(line)) -def _drop_block_comments(text: str) -> str: - """去掉 /* … */(可以跨行):块注释里的中文句子也是代码的一部分。没收尾的不去,开头那一行照注释行跳过。""" - kept: list[str] = [] +def _blank_spans(text: str) -> str: + """块注释(/* … */)、三个引号括起的多行字符串涂成空格,换行留着,块里还是原来那几行:里面写着中文句子也是代码的 + 一部分。没收尾的不动,开头那一行照注释行跳过。""" + out: 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:] + while (start := _SPAN_OPEN.search(text, at)) is not None: + end = text.find(_SPAN_CLOSE[start.group()], start.end()) + if end == -1: + break + stop = end + len(_SPAN_CLOSE[start.group()]) + out += [text[at:start.start()], "\n".join(" " * len(part) for part in text[start.start():stop].split("\n"))] + at = stop + return "".join(out) + text[at:] + + +def _diff(lines: list[str], *, labelled: bool) -> bool: + """整块是 diff:有 - 开头的行也有 + 开头的行,其余是空格开头的上下文行、@@ 开头的块头。diff 里一处改动先列删掉的行、 + 再列加上的行,第一个改动行就是 + 的,是 + / - 开头的正负面要点(有 @@ 块头的照旧算 diff)。信息串写明是文字的块里, + + - 后面空一格的是 Markdown 的列表项,只有紧挨着字写的(-按下单日期统计)才是 diff。""" + heads = [line[:1] for line in lines if line.strip()] + if not {"+", "-"} <= set(heads) <= {"+", "-", " ", "@"}: + return False + if labelled and any(line[:2] in ("+ ", "- ") for line in lines): + return False + return "@" in heads or next(h for h in heads if h in ("+", "-")) == "-" + + +def _data_like(line: str) -> bool: + """像数据、公式的一行(_DATA_LINE),或者逗号隔开的一行表头、数据(两个以上半角逗号,逗号后面都不空格)。""" + return _DATA_LINE.search(line) is not None or (line.count(",") >= 2 and ", " not in line) + + +def _yaml(lines: list[str], *, labelled: bool) -> bool: + """一段是 YAML:第一行是小写开头的键,其余每一行都是键或者缩进的行(键下面的列表项、子键),注释行不看。写明是文字的 + 块里另要有值为空、往下缩进的键:一行一个「键: 一句话」的(「- note: 华南销售额超过华北」)多半是正文里的标签。""" + body = [line for line in lines if line.strip() and not _COMMENT_LINE.match(line)] + if not body or not _YAML_KEY.match(body[0]): + return False + if not all(_YAML_KEY.match(line) or line[:1].isspace() for line in body[1:]): + return False + return not labelled or any(_YAML_NEST.match(line) for line in body) + + +def _bare_fence(line: str) -> str: + """整行只写着围栏(``` 或 ~~~,不带信息串)的,返回这串围栏,能给块里再嵌的代码块收尾;别的行返回空串。""" + fence = _NESTED_FENCE.match(line) + return fence.group(1) if fence and not fence.group(2).strip() else "" + + +def _code_lines(lines: list[str], raw: list[str], *, labelled: bool) -> list[bool]: + """逐行标出哪些是代码:块里再嵌的代码块(~~~sql),SQL、Python 的语句(从开头那一行到这一段结束,续行也算;前面的 + 注释行本来就不算正文),整段是 YAML 的(_yaml),像数据、公式的行(_data_like)。 + + lines 是涂掉块注释、多行字符串之后的行,raw 是原样的行:一段到空行为止,只写着注释的行不算空行;写明是文字的块里, + 顶格的标题也另起一段。不把整块当代码:正文里另贴一段 SQL、嵌一个 ~~~sql,或者有一行像 YAML 的键,别的行照样按正文认。 + 再嵌的代码块要收得了才算(后面有同一种符号、不比开头短的收尾),开了没收的那一行照普通的行看。""" + code = [False] * len(lines) + paragraph: list[int] = [] + fence = "" + # 每一行后面最长的收尾围栏(整行只有 ``` 或 ~~~),按符号分开记:开了收不了的围栏不算,照普通的行看,免得后面的 + # 正文都被当成代码 + reach = {"`": [0] * (len(lines) + 1), "~": [0] * (len(lines) + 1)} + for k in range(len(lines) - 1, -1, -1): + for after in reach.values(): + after[k] = after[k + 1] + if bare := _bare_fence(lines[k]): + reach[bare[0]][k] = max(reach[bare[0]][k], len(bare)) + + def close() -> None: + statement = _yaml([lines[k] for k in paragraph], labelled=labelled) + for k in paragraph: + statement = statement or _CODE_LINE.match(lines[k]) is not None + code[k] = statement or _data_like(lines[k]) + paragraph.clear() + + for k, line in enumerate(lines): + if fence: + code[k] = True + closing = _bare_fence(line) + if closing[:1] == fence[0] and len(closing) >= len(fence): + fence = "" + continue + if (opening := _NESTED_FENCE.match(line)) and reach[opening.group(1)[0]][k + 1] >= len(opening.group(1)): + close() + fence, code[k] = opening.group(1), True + continue + if not raw[k].strip() or (labelled and _MD_HEADING.match(line)): + close() + continue + paragraph.append(k) + close() + return code def _fenced_body(doc: dict[str, Any]) -> bool: @@ -588,9 +680,10 @@ def _fenced_body(doc: dict[str, Any]) -> bool: text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据 的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。 - 在不在写正文逐行看:信息串写明是文字、里面也看不出是代码的按 _written。信息串为空的块里也常是 SQL、Python、YAML、 - diff,信息串写成 text 的也偶尔是代码,这些按 _prose_line:注释、字符串字面量里的中文句子不算;整块看得出是代码 - (_code_like)的只认成句的,以 * + - 开头的 SQL 续行、中文别名里的「最高」这类词都不算。""" + 在不在写正文逐行看。先标出哪些行是代码(_code_lines;信息串为空、整块是 diff 的全是代码),代码的行只认去掉注释、 + 字符串之后成句的(_prose_line),中文别名、YAML 的值、公式里的「最高」「占比」这类词都不算。其余的行:信息串写明 + 是文字的按 _written,信息串为空的块里也常是代码,去掉注释、字符串之后再看(_prose_line)。一块里只要有一行在写正文 + 就算,所以宁可把拿不准的行当代码:藏起来的正文总有别的行认得出,贴的一段代码、公式里却一行认错就会记错缺口。""" from app.engine.evidence import iter_units units = list(iter_units(doc)) @@ -603,14 +696,13 @@ def _fenced_body(doc: dict[str, Any]) -> bool: segments = [s for s in unit.get("segments") or [] if isinstance(s, dict)] if unit.get("see") or any(s.get("ref") for s in segments): return True - # 围栏那两行是挂在块上的结构片段,不是块里写的内容:整块是不是 diff 要看里面每一行的开头 + # 围栏那两行是挂在块上的结构片段,不是块里写的内容:算进来的话,开头那行 ```markdown 会被当成块里再嵌的代码块, + # 整块是不是 diff 也看不准 text = "".join(str(s.get("text") or "") for s in segments if s.get("kind") != "structural") - lines = _drop_block_comments(text).split("\n") - code = _code_like(lines) - if lang and not code: - if any(_written(line) for line in text.split("\n")): - return True - elif any(_prose_line(line, code=code) for line in lines): + raw, lines = text.split("\n"), _blank_spans(text).split("\n") + code = [True] * len(lines) if _diff(raw, labelled=bool(lang)) else _code_lines(lines, raw, labelled=bool(lang)) + if any(_written(line) if lang and not is_code else _prose_line(line, code=is_code) + for line, is_code in zip(lines, code)): return True return False diff --git a/backend/tests/test_contract_fenced_body.py b/backend/tests/test_contract_fenced_body.py index 09818132..0f6b5078 100644 --- a/backend/tests/test_contract_fenced_body.py +++ b/backend/tests/test_contract_fenced_body.py @@ -7,11 +7,16 @@ K5 让报告节点去掉整篇外层的 markdown 围栏(report._unfenced), - 结论句策略为 judge 或 require_citation(报告节点的,或出具契约自己写的)、结论句 0 句、又有信息串为空 / markdown / md / text / plaintext / txt / plain 的代码块,里面写着引用(片段里的引用、只挂依据的 [[see:]])或者在写正文:记一条 缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部完成」下(run/decode.ts 的 splitIssuanceGaps) -- 在写正文:带句读的中文、成句的英文,或者按正文的规矩会算一句结论(方向词、因果词);信息串写明是文字的,一行里连着 - 六个以上汉字也算。列表不论用什么标记(- 1. 1、 一、 (1) · ●),也不论带不带标记,只看字 -- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的;信息串为空、里面是代码的(注释、字符串里的中文标点不算 - 句子;有 SQL / Python 开头的行、YAML 的键,或者整块是 diff 时,方向词也不算),信息串写成 text、markdown 而里面 - 看得出是代码的同样;信息串为空的块里不带句读、也不像结论的标签和列名;结论句策略为 off 的 +- 逐行看,先标出代码的行:块里再嵌的代码块(~~~sql)、SQL / Python 的语句连同续行、整段是 YAML 的(写明是文字的块里 + 要有值为空的键)、像数据的行(公式、JSON、目录树、流程箭头、函数调用、文件名、逗号隔开的表头),信息串为空、整块是 + diff 的全是代码。正文里另有一段代码、一行像 YAML 键的标签,不妨碍认出别的行 +- 在写正文:代码的行只认去掉注释、字符串之后成句的;别的行带句读的中文、成句的英文,或者按正文的规矩会算一句结论 + (方向词、因果词);信息串写明是文字的,一行里连着六个以上汉字也算。列表不论用什么标记(- + 1. 1、 一、 (1) · ● + →),也不论带不带标记,只看字 +- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的;写文字的块里只有代码、数据、公式的(注释、字符串里的 + 中文句子不算,别名、公式里的方向词也不算);信息串为空的块里不带句读、也不像结论的标签和列名;结论句策略为 off 的 +- 取舍:写成 text、markdown 的 diff,+ - 后面又空一格的,和列表分不开,照列表看;信息串为空的块里先列 - 再列 + 的 + 要点、整段都是「小写键: 值」的,和 diff、YAML 分不开,照代码看 模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。 """ @@ -62,6 +67,15 @@ def listed(first: str, second: str) -> str: return f"## 本周结论\n\n{first}{LINES[0]}\n{second}{LINES[1]}" +#: + / - 开头的正负面要点:不包代码块时两句都是结论(「增长」「回落」) +PROS_CONS = "+ 华东销售额增长明显\n- 华北销售额回落" +#: 正文后面另贴一段取数的 SQL:块里再嵌一个 ~~~sql 代码块,或者缩进四格 +WITH_TILDE = f"{BARE_LIST}\n\n## 取数\n\n~~~sql\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region\n~~~" +WITH_INDENTED = f"{BARE_LIST}\n\n## 取数\n\n select region, sum(amount) as gmv from orders group by region" +#: 列表项前面写着小写的英文标签,看着像 YAML 的键 +WITH_LABEL = "## 本周结论\n\n- 华东销售额居各区域之首\n- note: 华南销售额超过华北" + + HIDDEN = [pytest.param(PREAMBLE, id="preamble"), pytest.param(UNCLOSED, id="unclosed"), 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"), @@ -73,7 +87,15 @@ HIDDEN = [pytest.param(PREAMBLE, id="preamble"), pytest.param(UNCLOSED, id="uncl pytest.param(f"以下是本周周报:\n\n{fenced(listed('● ', '● '))}", id="black-circle"), pytest.param(f"以下是本周周报:\n\n{fenced(UNMARKED)}", id="lines-without-markers"), # 信息串为空:第二句按正文的规矩是一句结论 - pytest.param(f"以下是本周周报:\n\n{fenced(listed('1、', '2、'), '')}", id="enumerated-no-info")] + pytest.param(f"以下是本周周报:\n\n{fenced(listed('1、', '2、'), '')}", id="enumerated-no-info"), + # + / - 开头的正负面要点:写明是文字的块里是列表;信息串为空的块里先列 + 的也不是 diff + pytest.param(f"以下是本周周报:\n\n{fenced(PROS_CONS)}", id="pros-and-cons"), + pytest.param(f"以下是本周周报:\n\n{fenced(PROS_CONS, 'text')}", id="pros-and-cons-text"), + pytest.param(f"以下是本周周报:\n\n{fenced(PROS_CONS, '')}", id="pros-and-cons-no-info"), + # 正文里另有几行代码、像 YAML 键的标签:只有那几行当代码看,正文照样认得出 + pytest.param(f"以下是本周周报:\n\n{fenced(WITH_TILDE)}", id="tilde-block-inside"), + pytest.param(f"以下是本周周报:\n\n{fenced(WITH_INDENTED)}", id="indented-sql-inside"), + pytest.param(f"以下是本周周报:\n\n{fenced(WITH_LABEL)}", id="lowercase-label")] def doc_of(raw: str) -> dict: @@ -130,6 +152,43 @@ def doc_of(raw: str) -> dict: pytest.param("```\n华北销售额回落\n```", id="no-info-conclusion"), # 首字母大写的「Note:」是正文里的标签,不是 YAML 的键 pytest.param("```markdown\nNote: 以下为初步结论\n- 华南销售额超过华北\n```", id="capitalised-label"), + # + / - 开头的正负面要点:写明是文字的块里是列表,不论哪个在前;信息串为空的块里先列 + 的不是 diff(diff 里一处 + # 改动先列删掉的行),+ 下面缩进的子项也一样 + pytest.param(f"```markdown\n{PROS_CONS}\n```", id="plus-minus-markdown"), + pytest.param("```text\n- 华北销售额回落\n+ 华东销售额增长明显\n```", id="minus-plus-text"), + # 取舍:写成 text、markdown 的 diff,+ - 后面又空一格,和列表分不开,照列表看(diff 该写 diff 信息串) + pytest.param("```text\n- 按下单日期统计\n+ 按支付日期统计\n```", id="spaced-diff-labelled-text"), + pytest.param(f"```\n{PROS_CONS}\n```", id="plus-minus-no-info"), + pytest.param("```\n+ 华东销售额增长明显\n - 主要来自新店\n- 华北销售额回落\n```", id="plus-minus-nested-no-info"), + # 正文里另有几行代码:块里再嵌的 ~~~sql、缩进的 SQL 只是那几行,不把整块都当代码 + pytest.param(f"```markdown\n{WITH_TILDE}\n```", id="tilde-block-inside"), + pytest.param(f"```\n{WITH_TILDE}\n```", id="tilde-block-inside-no-info"), + pytest.param(f"```markdown\n{WITH_INDENTED}\n```", id="indented-sql-inside"), + pytest.param(f"```\n{WITH_INDENTED}\n```", id="indented-sql-inside-no-info"), + pytest.param("```text\n- 华南销售额超过华北\n\nselect region from orders\n```", id="sql-in-another-paragraph"), + pytest.param("```markdown\n~~~sql\nSELECT region FROM orders\n~~~\n\n- 华南销售额超过华北\n```", + id="prose-after-nested-block"), + # 块里再嵌的代码块开了没收:不当围栏,后面的正文照样认 + pytest.param("```markdown\n## 取数\n\n~~~sql\nSELECT region FROM orders\n\n## 本周结论\n\n- 华南销售额超过华北\n```", + id="nested-block-left-open"), + pytest.param("```markdown\n~~~~sql\nSELECT region FROM orders\n~~~\n\n- 华南销售额超过华北\n```", id="nested-close-too-short"), + # 收尾的围栏比开头长也收得了 + pytest.param("```markdown\n~~~~sql\nSELECT region FROM orders\n~~~~~\n\n- 华南销售额超过华北\n```", id="nested-close-longer"), + # 写明是文字的块里,顶格的标题另起一段:上一段的 SQL 管不到标题下面的列表 + pytest.param("```markdown\n## 取数\nselect region from orders\n## 本周结论\n- 华南销售额超过华北\n```", id="heading-ends-sql"), + # 行首的箭头是列表的记号,不是连起流程的箭头 + pytest.param("```markdown\n→ 华东销售额居各区域之首\n→ 华南销售额超过华北\n```", id="arrow-bullets"), + # 小写英文键开头的行:一段里有不缩进、又不是键的行,不是 YAML;写明是文字的块里,没有值为空的键(下面缩进着 + # 列表、子键)的「键: 一句话」也是正文里的标签 + pytest.param(f"```markdown\n{WITH_LABEL}\n```", id="lowercase-label-after"), + pytest.param("```\n- note: 华南销售额超过华北\n- 华东销售额居各区域之首\n```", id="lowercase-label-first-no-info"), + pytest.param("```text\n- top: 华东销售额居各区域之首\n- note: 华南销售额超过华北\n```", id="lowercase-labels-only"), + pytest.param("```markdown\nsummary: 华南销售额超过华北\n```", id="key-and-conclusion"), + # 首字母大写的英文词开头是正文里的英文,不是 SQL 的关键字 + pytest.param("```markdown\nFrom 数据看 华南销售额超过华北\n```", id="capitalised-keyword"), + pytest.param("```\nUpdate 华南销售额超过华北\n```", id="capitalised-keyword-no-info"), + # def、class 不写成定义的样子,是正文里的词(A 类门店) + pytest.param("```markdown\nclass A 门店销售额超过 class B 门店\n```", id="class-of-stores"), ]) def test_a_report_body_in_a_prose_code_block_is_recognised(raw): doc = doc_of(raw) @@ -174,12 +233,52 @@ def test_a_report_body_in_a_prose_code_block_is_recognised(raw): pytest.param("```\nSELECT MAX(amount) AS 最高金额, MIN(amount) AS 最低金额 FROM orders\n```", id="sql-aliases"), pytest.param("```\nmetrics:\n - 退款占比\n - 销售额增长率\n```", id="yaml-with-conclusion-words"), pytest.param("```\n- name: 退款占比\n unit: 百分比\n```", id="yaml-mappings"), + # 取舍:信息串为空、整段都是「小写键: 值」的照 YAML 看,值里写着结论也一样(写明是文字的块里照正文看) + pytest.param("```\n- top: 华东销售额居各区域之首\n- note: 华南销售额超过华北\n```", id="all-keys-no-info"), pytest.param("```\n- 退款率下降\n+ 退款率上升\n```", id="diff-with-conclusion-words"), pytest.param("```\n@@ -1,2 +1,2 @@\n 口径说明\n-销售额按下单日期统计后下降\n+销售额按支付日期统计后上升\n```", id="diff-hunk"), + # 写成 text、markdown 的 diff:+ - 紧挨着字写,不是 Markdown 的列表项 + pytest.param("```text\n-退款率下降时提醒\n+退款率上升时提醒\n```", id="diff-labelled-text"), + pytest.param("```markdown\n@@ -1 +1 @@\n-销售额按下单日期统计后下降\n+销售额按支付日期统计后上升\n```", + id="diff-hunk-labelled-markdown"), + # 有 @@ 块头的,先列加上的行也是 diff + pytest.param("```\n@@ -1,2 +1,2 @@\n+销售额按支付日期统计后上升\n-销售额按下单日期统计后下降\n```", id="diff-hunk-plus-first"), + # 代码的行里,-- 紧挨着前面的字也是注释 + pytest.param("```\nSELECT SUM(amount)--按区域汇总,排除退款\nFROM orders\n```", id="sql-comment-without-space"), # 信息串写成 text、markdown,里面却看得出是代码:照代码看,注释里的句子、别名里的方向词不算 pytest.param("```text\nSELECT region AS 区域, MAX(amount) AS 最高金额 FROM orders -- 按区域汇总,排除退款\n```", id="sql-labelled-text"), pytest.param("```markdown\nmetrics:\n - 退款占比\n - 销售额增长率\n```", id="yaml-labelled-markdown"), + pytest.param("```text\n# 指标\nmetrics:\n # 核心指标\n - 退款占比\n```", id="yaml-with-comments"), + pytest.param("```\n# 核心指标\nmetrics:\n - 退款占比\n```", id="yaml-with-comments-no-info"), + # 写成 text 的 SQL 跨好几行:以语句开头的一段连同续行都是代码,开头的注释行也是;块注释、多行字符串整段不算, + # 只写着块注释的一行也不把一段断开 + pytest.param("```text\nSELECT region,\n amount AS 销售额增长率\n FROM orders\n```", id="sql-continuation-text"), + pytest.param("```text\nSELECT region,\n/* 别名 */\n amount AS 销售额增长率\n FROM orders\n```", + id="sql-with-block-comment-text"), + pytest.param("```text\n-- 按区域汇总,排除退款。\nSELECT region, amount AS 最高金额 FROM orders\n```", id="sql-leading-comment"), + pytest.param('```\ndef summary():\n """\n 按支付日统计,排除退款。\n """\n return 1\n```', id="docstring"), + # 像数据、公式的行(等号、JSON、画目录树的制表符、箭头连起的流程、函数调用、文件名、逗号隔开的表头)照代码看: + # 去掉字符串之后不成句的不算正文,结论词、六字连写只看写正文的行 + pytest.param("```\n退款占比 = 退款金额 / 销售额\n```", id="formula-no-info"), + pytest.param("```text\n销售额增长率 = (本期销售额 - 上期销售额) / 上期销售额\n```", id="formula-text"), + pytest.param('```text\n{"区域": "华东", "说明": "按支付日期统计"}\n```', id="json-text"), + pytest.param('```markdown\n "说明": "按支付日期统计,排除退款",\n```', id="json-member"), + pytest.param('```text\n["订单编号", "支付日期", "销售额增长率"]\n```', id="json-array"), + pytest.param("```text\n报表/\n├── 华东门店销售周报.xlsx\n└── 华南门店销售周报.xlsx\n```", id="tree-text"), + pytest.param("```text\n周报\n├── 华东门店销售周报\n│ └── 第四周销售明细\n└── 华南门店销售周报\n```", id="tree-of-folders"), + pytest.param("```text\n上传表格导入 → 字段映射校验 → 生成周报\n```", id="flow-text"), + pytest.param("```text\nprint('导入完成,请查收')\n```", id="call-with-string-text"), + pytest.param("```markdown\nrows.groupby(区域).agg(销售额增长率)\n```", id="call-text"), + pytest.param("```markdown\n华东门店销售周报.xlsx\n华南门店销售周报.xlsx\n```", id="file-names"), + pytest.param("```text\n订单编号,支付日期,销售额增长率\n```", id="csv-header"), + # 块里再嵌的代码块照代码看:注释里的句子、别名里的结论词、列着指标名的 YAML 都不算 + pytest.param("```markdown\n## 取数\n\n~~~sql\nSELECT MAX(amount) AS 最高金额 FROM orders -- 按区域汇总,排除退款\n~~~\n```", + id="tilde-block-only"), + pytest.param("```markdown\n## 口径\n\n~~~yaml\n- 退款占比\n- 销售额增长率\n~~~\n```", id="nested-yaml-block"), + pytest.param("```markdown\n~~~~\n- 退款占比\n- 销售额增长率\n~~~~~\n```", id="nested-yaml-longer-close"), + # 开了没收的那一行照普通的行看,里面的 SQL 照样认得出 + pytest.param("```markdown\n## 取数\n\n~~~sql\nSELECT MAX(amount) AS 最高金额 FROM orders\n```", id="nested-sql-left-open"), # 有正常的结论句:正文没被藏起来 pytest.param("华东销售额超过华南。\n\n```markdown\n华东销售额 1500,居各区域之首。\n```", id="claims-outside"), # 没有代码块 @@ -189,14 +288,27 @@ 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": [ +@pytest.mark.parametrize("lang", ["", "markdown"]) +@pytest.mark.parametrize("line", [ + pytest.param("alpha beta " * 4000, id="words"), + # 逐行认代码、认数据的几条规则:一长串字母后面没有括号、冒号,一长串空格、横线,一长串汉字、逗号、引号 + pytest.param("a" * 40000, id="one-long-word"), + pytest.param(" " * 20000 + "-" + " " * 20000 + "a", id="spaces-and-a-dash"), + pytest.param("华" * 40000, id="cjk"), + pytest.param("a," * 20000, id="commas"), + pytest.param(' "a' * 13000, id="open-quotes"), + pytest.param('"""' * 13000, id="triple-quotes"), + # 好多行围栏:收得了的、开了没收的 + pytest.param("~~~\n" * 13000, id="bare-fences"), + pytest.param("~~~sql\n" * 13000, id="fences-left-open"), +]) +def test_a_long_line_without_a_full_stop_takes_linear_time(lang, line): + """很长、不带句末标点的一行:逐串数单词、逐行认代码,耗时随行长线性增长。按正则回溯认英文句子时,四万多字的一行要 + 十几秒(行长翻倍、耗时翻四倍),出口复核在事件循环上同步执行,整个服务都会卡住。""" + doc = {"blocks": [{"type": "code", "lang": lang, "units": [ {"kind": "code", "see": [], "segments": [{"kind": "text", "text": line}]}]}]} started = time.perf_counter() - assert _fenced_body(doc) is False + _fenced_body(doc) assert time.perf_counter() - started < 1.0 @@ -283,16 +395,21 @@ async def test_require_citation_records_the_gap_too(monkeypatch, claims, contrac assert issuance["claims"]["uncited_claims"] == 0 -@pytest.mark.parametrize("code", [ - pytest.param("SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region -- 按区域汇总,排除退款", +@pytest.mark.parametrize("info, code", [ + pytest.param("", "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region -- 按区域汇总,排除退款", id="sql-trailing-comment"), - pytest.param("columns:\n - 订单编号\n - 支付日期", id="yaml-list"), - pytest.param("- 按下单日期统计\n+ 按支付日期统计", id="diff"), + pytest.param("", "columns:\n - 订单编号\n - 支付日期", id="yaml-list"), + pytest.param("", "- 按下单日期统计\n+ 按支付日期统计", id="diff"), + pytest.param("", "退款占比 = 退款金额 / 销售额", id="formula"), + pytest.param("text", "销售额增长率 = (本期销售额 - 上期销售额) / 上期销售额", id="formula-text"), + pytest.param("text", '{"区域": "华东", "说明": "按支付日期统计"}', id="json-text"), + pytest.param("text", "报表/\n├── 华东门店销售周报.xlsx\n└── 华南门店销售周报.xlsx", id="tree-text"), ]) -async def test_code_in_a_report_without_claims_is_issued_as_before(monkeypatch, code): - """一句结论都没有的报告(只有口径说明)贴了一段不带信息串的代码:行尾注释里写着中文句子的 SQL、列着中文列名的 - YAML、改口径的 diff。那是代码,不是藏起来的正文,照常完整出具,不记这条缺口。""" - writer(monkeypatch, f"## 口径说明\n\n本周报告按支付日期统计。\n\n```\n{code}\n```") +async def test_code_in_a_report_without_claims_is_issued_as_before(monkeypatch, info, code): + """一句结论都没有的报告(只有口径说明)贴了一段代码、公式或数据:行尾注释里写着中文句子的 SQL、列着中文列名的 + YAML、改口径的 diff(这三种不带信息串),带「占比」「增长率」的公式,写成 text 的 JSON、目录树。那不是藏起来的 + 正文,照常完整出具,不记这条缺口。""" + writer(monkeypatch, f"## 口径说明\n\n本周报告按支付日期统计。\n\n```{info}\n{code}\n```") row = await finish(weekly(claims="require_citation")) assert row.status == "succeeded", row.error issuance = row.output["_issuance"]