From 2cf329648dd3e75f7fa4ff094e2853797c98f4ce Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 16:54:30 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E6=9C=9F=E5=88=97=E6=8C=89=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E6=97=B6=E9=85=8D=E6=96=B9=E9=87=8C=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=AE=A4=EF=BC=9A=E5=8F=8C=E7=9B=B2=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=E9=80=89=E4=BA=86=E6=97=B6=E9=97=B4=E5=88=97=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E7=9A=84=E4=B8=80=E8=BE=B9=E6=97=B6=EF=BC=8C=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=BA=90=E4=B9=9F=E8=83=BD=E5=86=99=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J1 给业务日期加了类型门槛(两边的时间列都在表结构里声明为日期 / 时间类型才写),而上传表格导入时日期一律按文字保存、表结构里总是 TEXT,上传源因此永远写不了业务日期。导入时配方里其实记着每一列的逻辑类型(导入确认时界面上标的「日期」):列表块里类型为 DATE 的列、交叉表的日期轴。查过别处:构建回执里只有存储类型(列表的 DATE 列和文字列一样记 TEXT、role 都是 text),简单导入的回执只有 INTEGER / REAL / TEXT,所以只按配方认。 - dual_check_facts.recipe_dates:一份配方里逻辑类型是日期的列({表: [列]}),表和列按 recipe_types.derive_tables 推,和建表同一份;日期轴写进它各分段的表,列表合计行另存的表不带日期列;类型按 _DATED_TYPE 认,只有时刻的不算 - dual_check_facts.upload_dates:只读,按数据源当前快照找配方(source_versions.snapshot_recipe_id,期 3 之前的快照取最后一期导入的),和 schema_cache 出自同一个版本;手工源、简单导入、快照或配方记录不在、配方解析不了一律给空 - 节点的 _source_info 带上 upload_dates,放进候选:审批卡的预告(preview / preview_joint)和裁决后的真写认的是发起审批时读的同一份,逐字一致;旧的 checkpoint 里没有这一项,照旧不写 - _dated_problem:上传源里按文字保存的列在 upload_dates 里的算日期列(按表结构认出的表名、列名去对,大小写不论);不在的、读不出配方的照现在的原因说「来自上传的表格,表格里的日期一律按文字保存」;声明成别的类型的、只有时刻的照旧 测试:derive 一级补导入认作日期的写、只有一边是日期 / 读不出配方 / 记在别的表上的照旧说按文字保存、手工源不认 upload_dates、按表结构的名字对、preview_joint 同样认;新文件 test_dual_check_upload 经真实导入接口建上传源(暂存 → 改配方 → 试运行 → 提交、简单导入),配方里两列都是日期的审核人选盲算后业务日期记「支付日期」(人工确认、备注写运行),支付日期是文字的选哪边都不写、预告和跳过原因一字不差,配方记录改坏了的照旧不写;去掉认日期、去掉候选里的 upload_dates、去掉日期轴各做过一次,测试变红。 --- backend/app/engine/dual_check_facts.py | 115 ++++++++-- backend/app/engine/nodes/dual_check.py | 14 +- backend/tests/test_dual_check_facts.py | 97 +++++++- backend/tests/test_dual_check_upload.py | 287 ++++++++++++++++++++++++ 4 files changed, 489 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_dual_check_upload.py diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index ba227b3d..09a11808 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -9,8 +9,9 @@ 文本列(SQLite 常把日期存成文字)、整数列(Unix 秒、20260901 这类日期键)、名字带 time 的时长列都可能被说成「按它筛 时间」,认不准存的是不是日期,不记。只有时刻的 TIME 也不记:一天里的几点几分(MySQL 还常拿它存时长)不是日期,和 catalog.is_date_column(剖析和 SQL 检查共用的日期列判断)同一个口径——记成业务日期,SQL 检查会对按真实日期列统计的 - 查询一直报错误级。上传表格建的数据源,导入时日期一律按文字保存,这一条门槛下它们的业务日期都不由这里记(原因照实说 - 是上传的表格按文字保存,不说成用户声明了 TEXT) + 查询一直报错误级。上传表格建的数据源,导入时日期一律按文字保存,表结构里总是 TEXT:这样的列看导入时配方里记的逻辑 + 类型(upload_dates,导入确认时界面上标的「日期」就是它),是日期的算日期列;配方读不出来(简单导入没有配方、配方 + 记录坏了)或者配方里不是日期的,照旧不记,原因照实说是上传的表格按文字保存,不说成用户声明了 TEXT - 一边多一个排除无效记录的条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件 - 聚合只差去不去重、被采纳的一边去重 → 去重规则(dedup)。采纳了不去重的一边不记:那是这个指标自己的算法(数行), 写成表的「不去重」会把别的指标带偏 @@ -144,11 +145,13 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, """一个候选(审核人在某个指标上选了 a 或 b)→ (要写的目录项, None) 或 (None, 为什么不写)。 candidate:{metric 指标名, choice "a" | "b", source 数据源名, dialect, sql: {a, b}(比较的两条 SQL), diff: - {equivalent, fallback, items: [FacetDiff.as_dict()]}, scope: {texts, columns}, uploaded}。scope 是这个指标自己的 - 范围:texts 是口径说明、所在分组的取值、本次运行的输入(指标名称总算在内),columns 是分组的列;有效记录条件用它 - 排除指标自己的筛选范围。uploaded 为真是上传表格建的数据源:业务日期不记时,文本列的原因照实说是上传的表格按文字 - 保存日期。schema_cache 是数据源的表结构:认表,业务日期还要看两边的列声明的类型。notes 是这个数据源的目录 - ({表名: notes}),认码值的意思用。只看差异说明,不重新比 SQL:审核人看到的就是这份说明。 + {equivalent, fallback, items: [FacetDiff.as_dict()]}, scope: {texts, columns}, uploaded, upload_dates}。scope 是 + 这个指标自己的范围:texts 是口径说明、所在分组的取值、本次运行的输入(指标名称总算在内),columns 是分组的列;有效 + 记录条件用它排除指标自己的筛选范围。uploaded 为真是上传表格建的数据源,upload_dates 是它导入时认作日期的列 + ({表名: [列名]},由 upload_dates 从配方读出):按文字保存的列在这里面的算日期列,不在的业务日期不记、原因照实说 + 是上传的表格按文字保存日期。两项都由节点发起审批时备好、放进候选:预告和审批之后的真写认的是同一份。schema_cache + 是数据源的表结构:认表,业务日期还要看两边的列声明的类型。notes 是这个数据源的目录({表名: notes}),认码值的意思 + 用。只看差异说明,不重新比 SQL:审核人看到的就是这份说明。 """ name = str(candidate.get("metric") or "") side = candidate.get("choice") @@ -164,7 +167,9 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, item = items[0] aspect = str(item.get("aspect") or "") if aspect == "time": - fact, why = _business_date(item, side, schema_cache, uploaded=candidate.get("uploaded") is True) + dates = candidate.get("upload_dates") + fact, why = _business_date(item, side, schema_cache, uploaded=candidate.get("uploaded") is True, + dates=dates if isinstance(dates, Mapping) else None) elif aspect == "filter": fact, why = _valid_filter(item, side, candidate, schema_cache, notes or {}) elif aspect == "aggregate": @@ -187,11 +192,12 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, def _business_date(item: Mapping[str, Any], side: str, schema_cache: Mapping[str, Any] | None, *, - uploaded: bool = False) -> tuple[tuple[str, str, Any] | None, str | None]: + uploaded: bool = False, dates: Mapping[str, Any] | None = None + ) -> tuple[tuple[str, str, Any] | None, str | None]: """两边按不同的列筛时间、被采纳的一边按哪一列:(表, business_date, {column}) 或 (None, 为什么不记)。两边的列都要在 - 表结构里声明为带日期的类型(_dated_problem),被采纳的一边先看:它有问题说它;没问题再看未被采纳的一边,另说清为什么 - 也要看它(审核人看到的是自己选的那一列,不说就看不懂另一列为什么要紧)。数据源还没有可用的表结构时(没探查、探查 - 失败)只说表结构不可用:那时并不知道有没有这一列,说「不在表结构里」是在误导。""" + 表结构里声明为带日期的类型、或者是上传表格导入时认作日期的列(_dated_problem),被采纳的一边先看:它有问题说它;没 + 问题再看未被采纳的一边,另说清为什么也要看它(审核人看到的是自己选的那一列,不说就看不懂另一列为什么要紧)。数据源 + 还没有可用的表结构时(没探查、探查失败)只说表结构不可用:那时并不知道有没有这一列,说「不在表结构里」是在误导。""" other = "b" if side == "a" else "a" detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {} @@ -212,7 +218,7 @@ def _business_date(item: Mapping[str, Any], side: str, schema_cache: Mapping[str if not isinstance(tables, Mapping) or not tables: return None, "所在数据源还没有可用的表结构(尚未探查或探查失败),无法确认两边按时间筛的列存的是日期" for adopted, ref in ((True, mine[0]), (False, theirs[0])): - if (problem := _dated_problem(tables, ref, uploaded)) is None: + if (problem := _dated_problem(tables, ref, uploaded, dates)) is None: continue said = f"按时间筛的列「{ref['table']}.{ref['column']}」{problem}" if adopted: @@ -221,15 +227,20 @@ def _business_date(item: Mapping[str, Any], side: str, schema_cache: Mapping[str return (str(mine[0]["table"]), "business_date", {"column": str(mine[0]["column"])}), None -def _dated_problem(tables: Mapping[str, Any], ref: Mapping[str, Any], uploaded: bool) -> str | None: +def _dated_problem(tables: Mapping[str, Any], ref: Mapping[str, Any], uploaded: bool, + dates: Mapping[str, Any] | None = None) -> str | None: """按时间筛的这一列在表结构里是不是声明成了带日期的类型(_DATED_TYPE):是的为 None,不是的交回它怎么了(查不到、没 声明类型、只有时刻、声明成了别的类型)连同结论,接在「按时间筛的列「表.列」」后面读。 表按 catalog.resolve_table_name 认,列先按原样、再按 name_key 认(差异说明里的名字是规范化过的,SQLite 不加引号的 名字一律小写)。按 name_key 认出好几列的说对应好几列:哪一列是它说不准。类型照表结构里记的说(「类型是」,不说 「声明为」):introspect 记的是 SQLAlchemy 反射出来的类型,SQLite 里写成 TIMESTAMPTZ 的列反射出来是 NUMERIC;没写 - 类型的列反射出来是 NullType,记成「NULL」,按没有声明类型说。上传表格建的数据源(uploaded)日期一律存成文字,文字 - 类型不说「类型是 TEXT」:那不是用户声明的,导入确认时这一列标的还是「日期」,表结构也改不了。""" + 类型的列反射出来是 NullType,记成「NULL」,按没有声明类型说。 + + 上传表格建的数据源(uploaded)日期一律存成文字,表结构改不了,类型只能看导入时配方里记的(dates,见 upload_dates): + 按文字保存的列在里面的就是日期列(按表结构里认出的表名、列名去对,大小写不论)。不在里面的(配方读不出来、配方里 + 不是日期)不说「类型是 TEXT」:那不是用户声明的,说清是上传的表格按文字保存。声明成别的类型的列照常说类型,只认 + 文字类型:配方里的日期列只会存成文字,别的类型的列存的就不是配方里那一列。""" found_table = catalog.resolve_table_name({"tables": tables}, str(ref["table"])) meta = tables.get(found_table) if found_table is not None else None columns = [c for c in meta.get("columns") or [] if isinstance(c, Mapping) and c.get("name")] \ @@ -249,10 +260,79 @@ def _dated_problem(tables: Mapping[str, Any], ref: Mapping[str, Any], uploaded: if _TIME_ONLY_TYPE.fullmatch(declared): return f"在表结构里的类型是 {declared},只有时刻,不含日期" if uploaded and any(word in declared.upper() for word in catalog._TEXT_TYPES): + if _imported_as_date(dates, str(found_table), str(found[0]["name"])): + return None return "来自上传的表格,表格里的日期一律按文字保存,无法确认存的是日期" return f"在表结构里的类型是 {declared},无法确认存的是日期" +def _imported_as_date(dates: Mapping[str, Any] | None, table: str, column: str) -> bool: + """表结构里的这一列是不是上传表格导入时认作日期的列(dates:{表名: [列名]})。表、列都先按原样、再按 name_key 对: + 名字出自同一份配方,本来一字不差;按 name_key 对上好几个的不算,说不准是哪一个。""" + if not isinstance(dates, Mapping): + return False + + def one(names: Any, wanted: str) -> str | None: + names = [str(n) for n in names if isinstance(n, str)] + if wanted in names: + return wanted + loose = [n for n in names if name_key(n) == name_key(wanted)] + return loose[0] if len(loose) == 1 else None + + key = one(list(dates), table) + columns = dates.get(key) if key is not None else None + return isinstance(columns, (list, tuple)) and one(columns, column) is not None + + +# ---- 上传表格导入时认作日期的列:配方里记的逻辑类型 + +def recipe_dates(recipe: Any) -> dict[str, list[str]]: + """一份配方(recipe_types.Recipe)里逻辑类型是日期的列:{表名: [列名]},没有日期列的表不列。 + + 就是导入确认时界面上标「日期」的那些(recipe_notes 给列写日期格式说明认的也是这两种):交叉表的日期轴(Axis.type 恒为 + 日期),列表块里类型记为 DATE 的列。执行器把它们一律存成 YYYY-MM-DD 的文字,表结构里只剩 TEXT。哪张表有哪几列按 + recipe_types.derive_tables 推(建表用的同一份):日期轴写进它各分段的表,列表合计行另存的表只有合计项和数字列。 + 列表的类型按 _DATED_TYPE 认,配方以后添了日期时间也算;只有时刻的不算。""" + from app.data.recipe_types import ListBlock, derive_tables + + tables, _ = derive_tables(recipe) + listed = {(block.table, column.name) for sheet in recipe.sheets for block in sheet.blocks + if isinstance(block, ListBlock) for column in block.columns if _DATED_TYPE.fullmatch(column.type)} + out: dict[str, list[str]] = {} + for table, columns in tables.items(): + names = [c.name for c in columns if c.role == "axis" or (table, c.name) in listed] + if names: + out[table] = list(dict.fromkeys(names)) + return out + + +async def upload_dates(session: Any, row: DataSource | None) -> dict[str, list[str]]: + """上传表格建的数据源当前版本里,导入时认作日期的列:{表名: [列名]}(recipe_dates)。只读,不碰导入流程。 + + 按当前快照找它的配方(source_versions.snapshot_recipe_id:快照记着按哪一版配方建的表,期 3 之前的快照取最后一期导入 + 的):数据源的表结构(schema_cache)也出自当前快照,两者是同一个版本。读不出来的一律给空——手工登记的源、简单导入 + (没有配方,回执里只有存储类型)、快照或配方记录不在了、配方解析不了:业务日期照旧不记,原因说按文字保存。""" + from app.data.recipe_types import Recipe + from app.data.source_versions import snapshot_recipe_id + from app.db.models import SourceSnapshot, TableImport, TableRecipe + + if row is None or (row.origin or "manual") != "upload" or not row.current_snapshot_id: + return {} + snap = await session.get(SourceSnapshot, row.current_snapshot_id) + if snap is None or snap.source_id != row.id: + return {} + last = await session.get(TableImport, str(snap.imports[-1])) if snap.imports else None + recipe_id = snapshot_recipe_id(snap, {last.id: last} if last is not None else {}) + record = await session.get(TableRecipe, recipe_id) if recipe_id else None + if record is None or record.source_id != row.id: + return {} + try: + return recipe_dates(Recipe.model_validate(record.recipe)) + except ValueError: + logger.warning("上传表格的配方记录解析不了,业务日期按文字保存处理:source=%s recipe=%s", row.id, recipe_id) + return {} + + def _valid_filter(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, notes: Mapping[str, Mapping[str, Any]]) -> tuple[tuple[str, str, Any] | None, str | None]: @@ -980,4 +1060,5 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node return None, [*reasons, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认"] -__all__ = ["FACT_ITEMS", "JOINT_LIMIT", "Fact", "derive", "note_of", "preview", "preview_joint", "record_choices"] +__all__ = ["FACT_ITEMS", "JOINT_LIMIT", "Fact", "derive", "note_of", "preview", "preview_joint", "recipe_dates", + "record_choices", "upload_dates"] diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 04607ec8..fc5a0492 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -69,7 +69,7 @@ from app.db.models import DataSource from app.engine import deadline, recompute from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError -from app.engine.dual_check_facts import preview, preview_joint, record_choices +from app.engine.dual_check_facts import preview, preview_joint, record_choices, upload_dates from app.engine.evidence import build_catalog, is_number, next_exec from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cell_value, column_kind, numeric_text, \ parse_expression @@ -1126,6 +1126,7 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], facts[row["metric_id"]] = { "metric_id": row["metric_id"], "metric": row["name"], "source": first.get("source"), "dialect": info.get("kind"), "uploaded": bool(info.get("uploaded")), + "upload_dates": info.get("upload_dates") or {}, "sql": {"a": single[0], "b": single[1]} if single else {}, "diff": {"equivalent": merged["equivalent"], "fallback": merged["fallback"] or single is None, "items": merged["items"]}, @@ -1165,19 +1166,22 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], async def _source_info(sources: _Sources, name: str) -> dict[str, Any]: """比较两条 SQL、预告目录写入要用的:方言、表结构、列的中文名、整份数据目录,以及是不是上传表格建的数据源 - (uploaded:导入时日期一律存成文字,业务日期不记时原因照实说,见 dual_check_facts._dated_problem)。数据源没了就 - 都不给,照样比原文。""" + (uploaded:导入时日期一律存成文字)和它导入时认作日期的列(upload_dates,按当前版本的配方读,见 + dual_check_facts.upload_dates)——业务日期据此认上传表格的日期列,认不出的原因照实说(_dated_problem)。两项都进 + 候选:审批卡的预告和裁决后的真写认的是发起审批时读的这一份。数据源没了就都不给,照样比原文。""" from app.data import catalog from app.data.sql_semantics import catalog_labels row = await sources.source(name) if row is None: - return {"kind": None, "schema": None, "names": {}, "notes": {}, "uploaded": False} + return {"kind": None, "schema": None, "names": {}, "notes": {}, "uploaded": False, "upload_dates": {}} async with SessionLocal() as session: entries = await catalog.read_catalog(session, row.id) + dates = await upload_dates(session, row) notes = {table: entry.notes for table, entry in entries.items()} return {"kind": row.kind, "schema": row.schema_cache if isinstance(row.schema_cache, dict) else None, - "names": catalog_labels(notes), "notes": notes, "uploaded": (row.origin or "manual") == "upload"} + "names": catalog_labels(notes), "notes": notes, "uploaded": (row.origin or "manual") == "upload", + "upload_dates": dates} async def _explain(original: Any, blind: Any, info: dict[str, Any]) -> dict[str, Any]: diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 54cc8bd1..b13851fa 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -4,8 +4,9 @@ 条件,聚合只差去不去重、被采纳的一边去重 → 去重规则;按被采纳那一边的写法记 - 业务日期只在两边的列都在表结构里声明为带日期的类型(DATE、DATETIME、TIMESTAMP,带精度、带时区的写法也算)时才写: 文本、整数(Unix 秒、20260901 这类日期键)、只有时刻的 TIME、表结构里查不到、没声明类型(SQLite 探出来是 NULL)的 - 不写,原因说清是哪一列、哪一边(未被采纳的那一边另说清为什么也要看它);上传表格建的数据源说清日期是按文字保存的; - 数据源还没有可用的表结构时说表结构不可用 + 不写,原因说清是哪一列、哪一边(未被采纳的那一边另说清为什么也要看它);上传表格建的数据源,按文字保存的列看导入时 + 配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有 + 可用的表结构时说表结构不可用 - 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写, 照实说原因。同一列上的时间范围不同、跨两张表的条件,差异说明只给兜底,同样不写;说明以后真把跨两张表的条件说成一条 筛选条件,derive 自己那道「只涉及一张表」的检查也拦得下 @@ -367,6 +368,98 @@ def test_an_uploaded_table_says_its_dates_are_stored_as_text(): candidate(BY_ORDER_DATE, RIGHT, "b"), texts)[1] +#: 上传表格的列按文字保存、配方里又没认作日期时的原因(同上一个用例) +STORED_AS_TEXT = "来自上传的表格,表格里的日期一律按文字保存,无法确认存的是日期" + + +def uploaded(original: str, blind: str, choice: str, dates: dict | None) -> dict: + """上传表格建的数据源的候选:upload_dates 是节点从当前版本的配方读出的、导入时认作日期的列({表: [列]})。""" + return {**candidate(original, blind, choice), "uploaded": True, + **({"upload_dates": dates} if dates is not None else {})} + + +def test_an_uploaded_column_the_import_took_as_a_date_is_a_date(): + """上传表格建的数据源,表结构里的日期列总是 TEXT(导入时一律按文字保存),可导入时配方里这两列的类型是「日期」(候选的 + upload_dates):照常记成业务日期,采纳哪一边记哪一列,预告是同一套判断。""" + texts = schema_with(order_date="TEXT", paid_date="TEXT") + both = {"orders": ["order_date", "paid_date"]} + for choice, column in (("a", "order_date"), ("b", "paid_date")): + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, choice, both), texts) + assert why is None, why + assert (fact.table, fact.item, fact.value, fact.choice) == ("orders", "business_date", {"column": column}, choice) + said = preview(uploaded(BY_ORDER_DATE, RIGHT, choice, both), texts, {}) + assert said[choice] == {"write": True, "source": "shop", "table": "orders", "item": "business_date", + "value": {"column": column}} + + +@pytest.mark.parametrize("dates, column, owner", [ + # 配方读不出来(简单导入没有配方、配方记录坏了):两列都照旧说按文字保存 + (None, "orders.paid_date", "b"), + ({}, "orders.paid_date", "b"), + # 配方里只有一列是日期,另一列是文字:没认作日期的那一列照旧说 + ({"orders": ["paid_date"]}, "orders.order_date", "a"), + ({"orders": ["order_date"]}, "orders.paid_date", "b"), + # 认作日期的是别的表的同名列:对不上这一列 + ({"customers": ["order_date", "paid_date"]}, "orders.paid_date", "b"), +]) +def test_an_uploaded_column_the_import_did_not_take_as_a_date_keeps_the_reason(dates, column, owner): + """配方里不是日期、或者读不出配方的列:照现在的原因不写(说按文字保存,不说「类型是 TEXT」),被采纳的一边先说; + 被采纳的一边是日期、未被采纳的不是的,说未被采纳的那一边、另说清为什么也要看它。""" + texts = schema_with(order_date="TEXT", paid_date="TEXT") + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", dates), texts) + assert fact is None + if owner == "b": + assert why == f"指标「销售额」被采纳的盲算按时间筛的列「{column}」{STORED_AS_TEXT},不写入数据目录", why + else: + assert why == (f"指标「销售额」未被采纳的原流程按时间筛的列「{column}」{STORED_AS_TEXT}:{BOTH_DATES}," + "不写入数据目录"), why + assert preview(uploaded(BY_ORDER_DATE, RIGHT, "b", dates), texts, {})["b"] == {"write": False, "reason": why} + + +def test_the_import_dates_only_speak_for_text_columns_of_an_uploaded_source(): + """upload_dates 只回答「按文字保存的这一列,导入时是不是日期」: + - 不是上传源(手工登记的库)的文本列照旧说类型,候选里带着 upload_dates 也不认 + - 上传源里声明成别的类型的列(整数)照旧说类型;只有时刻的仍不算日期""" + texts = schema_with(order_date="TEXT", paid_date="TEXT") + both = {"orders": ["order_date", "paid_date"]} + fact, why = derive({**candidate(BY_ORDER_DATE, RIGHT, "b"), "upload_dates": both}, texts) + assert fact is None and "「orders.paid_date」在表结构里的类型是 TEXT,无法确认存的是日期" in why + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", both), schema_with(order_date="INTEGER", paid_date="TEXT")) + assert fact is None and "未被采纳的原流程按时间筛的列「orders.order_date」在表结构里的类型是 INTEGER" in why + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", both), schema_with(order_date="TEXT", paid_date="TIME")) + assert fact is None and "「orders.paid_date」在表结构里的类型是 TIME,只有时刻,不含日期" in why + + +def test_the_import_dates_follow_the_names_in_the_table_structure(): + """差异说明里的名字是规范化过的(不加引号的一律小写),配方里、表结构里的写法可能带大写:按表结构认出的那一列去对 + upload_dates,大小写不同照样对得上。""" + upper = {"tables": {"Orders": {"name": "Orders", "columns": [ + {"name": "Status", "type": "TEXT"}, {"name": "Order_Date", "type": "TEXT"}, {"name": "Paid_Date", "type": "TEXT"}, + {"name": "Amount", "type": "REAL"}]}}} + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", {"Orders": ["Order_Date", "Paid_Date"]}), upper) + assert why is None and (fact.table, fact.value) == ("Orders", {"column": "paid_date"}) + fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", {"orders": ["order_date", "paid_date"]}), upper) + assert why is None and (fact.table, fact.value) == ("Orders", {"column": "paid_date"}) + + +def test_the_joint_preview_reads_the_import_dates_too(): + """几个指标落到同一张上传表的业务日期上:preview_joint 和单个指标的预告、真写一样从候选认 upload_dates。""" + from app.engine.dual_check_facts import preview_joint + + texts = schema_with(order_date="TEXT", paid_date="TEXT") + both = {"orders": ["order_date", "paid_date"]} + picked = [{**uploaded(BY_ORDER_DATE, RIGHT, "a", both), "metric": "销售额", "metric_id": "gmv"}, + {**uploaded(BY_ORDER_DATE, RIGHT, "a", both), "metric": "订单金额", "metric_id": "total"}] + by = {tuple((m["metric_id"], m["choice"]) for m in e["members"]): e + for e in preview_joint(picked, {"shop": (texts, {})})} + assert by[(("gmv", "b"), ("total", "b"))]["outcomes"] == { + "gmv": {"write": True, "value": {"column": "paid_date"}}, + "total": {"write": True, "value": {"column": "paid_date"}}} + # 配方读不出来的同一组候选:一项都认不出,没有可预告的组合 + plain = [{**c, "upload_dates": {}} for c in picked] + assert preview_joint(plain, {"shop": (texts, {})}) == [] + + async def test_an_undeclared_column_read_from_a_real_database_is_said_to_have_no_type(tmp_path): """真探查一个 SQLite 库(introspect 存的是 str(列的类型)):没写类型的列记成「NULL」(SQLAlchemy 的 NullType), 说成没有声明类型,不说「类型是 NULL」。""" diff --git a/backend/tests/test_dual_check_upload.py b/backend/tests/test_dual_check_upload.py new file mode 100644 index 00000000..a538a646 --- /dev/null +++ b/backend/tests/test_dual_check_upload.py @@ -0,0 +1,287 @@ +"""上传表格建的数据源上的双盲复算:业务日期按导入时配方里记的逻辑类型认日期列(engine/dual_check_facts.upload_dates)。 + +上传表格导入时日期一律按文字保存,表结构里总是 TEXT,以前这类数据源的业务日期永远写不了。导入时配方里其实记着每一列 +的逻辑类型(导入确认时界面上标的「日期」): + +- 配方里是日期的两列(列表块里类型为 DATE 的列、交叉表的日期轴):审核人选了哪一边,业务日期就记哪一列;审批卡的预告 + 和真写一字不差 +- 配方里是文字的列不写;配方读不出来(简单导入没有配方、配方记录坏了)的照旧不写,原因都照实说是上传的表格按文字保存 +- 只认数据源当前版本的配方:表结构出自同一个版本 + +数据源一律经真实的导入接口建(暂存 → 改配方 → 试运行 → 提交,或者简单导入),工作簿是现造的:表名、地区、金额都是 +虚构的。盲算模型是 MockChatModel 剧本(tests/fixtures/dual_check_shop.Blind)。 +""" +from __future__ import annotations + +import datetime as dt +import json +import uuid +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient +from openpyxl import Workbook +from sqlalchemy import delete, select + +from app.data.recipe_types import Recipe +from app.db.base import SessionLocal +from app.db.models import DataSource, Setting, SourceSnapshot, TableRecipe +from app.engine import spend +from app.engine.dual_check_facts import recipe_dates, upload_dates +from app.engine.runner import run_manager +from app.main import app +from tests.fixtures.dual_check_shop import ROWS, Blind +from tests.fixtures.sources import drop_source +from tests.fixtures.xlsx import save +from tests.test_dual_check_node import decide, events, graph, logs, notes_of, pending, start + +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +API = "/api/datasources" +SHEET = "订单明细" +TABLE = "订单" +HEADER = ("订单号", "地区", "客户", "金额", "状态", "下单日期", "支付日期") +#: 同 dual_check_shop 的五笔单:退款行、下单日和支付日不在同一周的单,只是表名、列名、状态换成中文 +STATUS = {"paid": "已支付", "refunded": "已退款"} +WEEK = "BETWEEN '2026-09-01' AND '2026-09-07'" +#: 口径说明要的写法:按支付日、排除退款(1、2、5 号单,330.0) +RIGHT = f"SELECT SUM(\"金额\") AS gmv FROM \"订单\" WHERE \"状态\" <> '已退款' AND \"支付日期\" {WEEK}" +#: 只错在时间列:按下单日(2、4、5 号单,310.0) +BY_ORDER_DATE = f"SELECT SUM(\"金额\") AS gmv FROM \"订单\" WHERE \"状态\" <> '已退款' AND \"下单日期\" {WEEK}" +DEFINITION = "已支付订单的金额合计:按支付日期归属到当周,不含退款" +#: 配方里不是日期、读不出配方时的原因:照旧说按文字保存 +STORED_AS_TEXT = "来自上传的表格,表格里的日期一律按文字保存,无法确认存的是日期" +FLOW_RECIPE = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text("utf-8")) + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_(["verify", spend.SPEND_KEY]))) + await session.commit() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def workbook() -> bytes: + """订单明细:第 1 行表头,日期写成 Excel 的日期格(不是文字)。""" + wb = Workbook() + ws = wb.active + ws.title = SHEET + ws.append(list(HEADER)) + for oid, region, customer, amount, status, ordered, paid, *_ in ROWS: + ws.append([oid, region, customer, amount, STATUS[status], + dt.date.fromisoformat(ordered), dt.date.fromisoformat(paid)]) + return save(wb) + + +def list_recipe(*, ordered: str = "DATE", paid: str = "DATE") -> dict: + """列表配方:两个日期列各按给定的类型导入(DATE 是界面上的「日期」,TEXT 是「文字」)。""" + types = {"订单号": "INTEGER", "地区": "TEXT", "客户": "INTEGER", "金额": "REAL", "状态": "TEXT", + "下单日期": ordered, "支付日期": paid} + return {"recipe_format": "agentlab-recipe/2", + "sheets": [{"id": "s1", "match": {"name": SHEET}, "blocks": [{ + "id": "orders", "layout": "list", "table": TABLE, + "columns": [{"header": h, "name": h, "type": types[h]} for h in HEADER]}]}], + "tables": [{"name": TABLE, "grain": ["订单号"]}]} + + +async def recipe_import(client, recipe: dict) -> tuple[str, str]: + """按配方导入:暂存 → 换成给定的配方 → 试运行 → 勾全部确认项提交。返回 (数据源名, id)。""" + name = f"dc_upload_{uuid.uuid4().hex[:8]}" + r = await client.post(f"{API}/imports/stage", files={"file": (f"{SHEET}_2026-09.xlsx", workbook(), XLSX)}, + data={"name": name}) + assert r.status_code == 201, r.text + staging = r.json()["id"] + r = await client.put(f"{API}/imports/{staging}/recipe", json={"recipe": recipe}) + assert r.status_code == 200 and r.json()["recipe_problems"] == [], r.text + r = await client.post(f"{API}/imports/{staging}/trial", json={}) + assert r.status_code == 200, r.text + trial = r.json()["trial"] + assert trial["status"] in ("passed", "needs_decision"), trial["problems"] + r = await client.post(f"{API}/imports/{staging}/commit", json={ + "trial_id": trial["trial_id"], "confirmations": [i["id"] for i in trial.get("confirm_items") or []]}) + assert r.status_code == 201, r.text + return name, r.json()["source"]["id"] + + +async def simple_import(client) -> tuple[str, str]: + """期 1 的简单导入:按表头行导入,没有配方。""" + name = f"dc_simple_{uuid.uuid4().hex[:8]}" + r = await client.post(f"{API}/upload", files={"file": (f"{SHEET}.xlsx", workbook(), XLSX)}, data={"name": name}) + assert r.status_code == 201, r.text + async with SessionLocal() as session: + source_id = (await session.execute(select(DataSource.id).where(DataSource.name == name))).scalar_one() + return name, source_id + + +async def dates_of(source_id: str) -> dict: + async with SessionLocal() as session: + return await upload_dates(session, await session.get(DataSource, source_id)) + + +async def break_recipe(source_id: str) -> None: + """把数据源当前版本的配方记录改坏(造「配方读不出来」)。""" + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + snap = await session.get(SourceSnapshot, row.current_snapshot_id) + record = await session.get(TableRecipe, snap.recipe_id) + record.recipe = {"recipe_format": "agentlab-recipe/0"} + await session.commit() + + +@pytest.fixture +async def dated(client): + """两个日期列在配方里都是「日期」的上传源。""" + name, source_id = await recipe_import(client, list_recipe()) + yield name, source_id + await drop_source(source_id) + + +@pytest.fixture +async def paid_as_text(client): + """支付日期在配方里是「文字」、下单日期是「日期」的上传源。""" + name, source_id = await recipe_import(client, list_recipe(paid="TEXT")) + yield name, source_id + await drop_source(source_id) + + +# -------------------------------------------------------------------------- +# 配方里哪些列是日期 +# -------------------------------------------------------------------------- + + +def test_the_date_columns_of_a_list_recipe_are_the_ones_typed_as_dates(): + assert recipe_dates(Recipe.model_validate(list_recipe())) == {TABLE: ["下单日期", "支付日期"]} + assert recipe_dates(Recipe.model_validate(list_recipe(paid="TEXT"))) == {TABLE: ["下单日期"]} + assert recipe_dates(Recipe.model_validate(list_recipe(ordered="TEXT", paid="TEXT"))) == {} + + +def test_the_axis_of_a_crosstab_is_a_date_column_of_every_table_it_writes(): + """交叉表的日期轴(类型恒为日期)写进各分段的表:宽表、长表、表内合计另存的表都有这一列。""" + assert recipe_dates(Recipe.model_validate(FLOW_RECIPE)) == { + "日客流": ["日期"], "时段客流": ["日期"], "时段客流_表内合计": ["日期"]} + + +def test_a_list_total_kept_as_a_table_has_no_date_column(): + """列表的合计行另存成表时只有合计项和数字列:日期列不在里面。""" + recipe = list_recipe() + recipe["sheets"][0]["blocks"][0]["rows"] = {"total_row": {"label_column": "地区", "pick": "合计", + "keep_as": "订单合计"}} + recipe["tables"].append({"name": "订单合计", "kind": "reported_total"}) + assert recipe_dates(Recipe.model_validate(recipe)) == {TABLE: ["下单日期", "支付日期"]} + + +# -------------------------------------------------------------------------- +# 读当前版本的配方 +# -------------------------------------------------------------------------- + + +async def test_an_upload_source_reads_the_date_columns_from_its_current_recipe(client, dated, paid_as_text): + """真实导入流程建的源:表结构里两列都是 TEXT,配方里是日期的那几列读得出来。""" + async with SessionLocal() as session: + schema = (await session.get(DataSource, dated[1])).schema_cache + types = {c["name"]: c["type"] for c in schema["tables"][TABLE]["columns"]} + assert (types["下单日期"], types["支付日期"]) == ("TEXT", "TEXT"), "背景:导入时日期一律按文字保存" + assert await dates_of(dated[1]) == {TABLE: ["下单日期", "支付日期"]} + assert await dates_of(paid_as_text[1]) == {TABLE: ["下单日期"]} + + +async def test_without_a_readable_recipe_no_column_is_taken_as_a_date(client, dated, tmp_path): + """读不出配方的都不认:简单导入没有配方,配方记录坏了,手工登记的库,不存在的数据源。""" + name, source_id = await simple_import(client) + try: + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + assert row.origin == "upload" and row.current_recipe_id is None + assert await dates_of(source_id) == {} + finally: + await drop_source(source_id) + await break_recipe(dated[1]) + assert await dates_of(dated[1]) == {} + async with SessionLocal() as session: + manual = DataSource(name=f"dc_manual_{uuid.uuid4().hex[:8]}", kind="sqlite", database=str(tmp_path / "x.db")) + assert await upload_dates(session, manual) == {} + assert await upload_dates(session, None) == {} + + +async def test_the_node_hands_the_import_dates_over_with_the_source(client, dated): + """节点认数据源来历的那一步(_source_info)带上 upload_dates,候选据此交给 derive。""" + from app.engine.nodes.dual_check import _source_info, _Sources + + info = await _source_info(_Sources(), dated[0]) + assert info["uploaded"] is True and info["upload_dates"] == {TABLE: ["下单日期", "支付日期"]} + assert (await _source_info(_Sources(), "dc_no_such_source"))["upload_dates"] == {} + + +# -------------------------------------------------------------------------- +# 双盲复算:审批卡的预告、裁决后的真写 +# -------------------------------------------------------------------------- + + +def upload_graph(name: str, sql: str) -> dict: + return graph(name, sql, dual={"definitions": {"gmv": DEFINITION}}) + + +async def test_dates_the_import_took_as_dates_are_written_as_the_business_date(monkeypatch, client, dated): + """两列在配方里都是「日期」:审批卡预告选哪边记哪一列,审核人选盲算,业务日期记「支付日期」,人工确认、备注写明出处。""" + name, source_id = dated + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + run = await start(upload_graph(name, BY_ORDER_DATE)) + assert run.status == "interrupted", run.error + [item] = (await pending(run.id)).payload["summary"] + assert len(item["diff"]) == 1 and "下单日期" in item["diff"][0] and "支付日期" in item["diff"][0] + where = {"source": name, "table": TABLE, "item": "business_date"} + assert item["catalog"] == {"a": {"write": True, **where, "value": {"column": "下单日期"}}, + "b": {"write": True, **where, "value": {"column": "支付日期"}}} + done = await decide(client, run.id, {"gmv": "b"}) + assert done.status == "succeeded", done.error + [wrote] = logs(await events(run.id), "dual_check_catalog") + assert wrote["written"] == [{**where, "value": {"column": "支付日期"}}] and wrote["skipped"] == [] + entry = await notes_of(source_id, TABLE) + stored = entry.notes["business_date"] + assert stored["value"] == {"column": "支付日期"} + assert (stored["source"], stored["status"]) == ("human", "confirmed") and run.id in stored["note"] + + +async def test_a_column_the_recipe_kept_as_text_is_not_written(monkeypatch, client, paid_as_text): + """支付日期在配方里是「文字」:选哪边都不记,原因照旧说按文字保存——被采纳的是它就说它,被采纳的下单日期是日期的,说 + 未被采纳的支付日期、另说清为什么也要看它。预告和裁决后跳过的原因一字不差,目录里什么都没写。""" + name, source_id = paid_as_text + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + run = await start(upload_graph(name, BY_ORDER_DATE)) + assert run.status == "interrupted", run.error + said = (await pending(run.id)).payload["summary"][0]["catalog"] + column = f"「{TABLE}.支付日期」{STORED_AS_TEXT}" + assert said["b"] == {"write": False, "reason": f"指标「销售额」被采纳的盲算按时间筛的列{column},不写入数据目录"} + assert said["a"] == {"write": False, "reason": f"指标「销售额」未被采纳的盲算按时间筛的列{column}:" + "两边都是日期列时,审核人选的才是业务日期按哪一列,不写入数据目录"} + done = await decide(client, run.id, {"gmv": "b"}) + assert done.status == "succeeded", done.error + [wrote] = logs(await events(run.id), "dual_check_catalog") + assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [said["b"]["reason"]] + assert await notes_of(source_id, TABLE) is None + + +async def test_without_a_readable_recipe_nothing_is_written(monkeypatch, client, dated): + """配方记录坏了、读不出来:两列都照旧说按文字保存,不写。""" + name, source_id = dated + await break_recipe(source_id) + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + run = await start(upload_graph(name, BY_ORDER_DATE)) + assert run.status == "interrupted", run.error + said = (await pending(run.id)).payload["summary"][0]["catalog"] + for side, label, column in (("a", "原流程", "下单日期"), ("b", "盲算", "支付日期")): + assert said[side] == {"write": False, "reason": f"指标「销售额」被采纳的{label}按时间筛的列「{TABLE}.{column}」" + f"{STORED_AS_TEXT},不写入数据目录"} + done = await decide(client, run.id, {"gmv": "a"}) + assert done.status == "succeeded", done.error + [wrote] = logs(await events(run.id), "dual_check_catalog") + assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [said["a"]["reason"]] + assert await notes_of(source_id, TABLE) is None From 3e0244654294efaa10fc91632ba2f29081bc948d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:01:44 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=EF=BC=9A=E6=8A=A5=E5=91=8A=E7=9A=84=E6=AD=A3=E6=96=87=E4=BB=8D?= =?UTF-8?q?=E5=86=99=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C=E3=80=81?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E7=BB=93=E8=AE=BA=E9=83=BD=E6=B2=A1=E5=88=87?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E6=97=B6=E8=AE=B0=E5=87=BA=E5=85=B7=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=AE=8C=E6=95=B4=E5=87=BA?= =?UTF-8?q?=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 From f1e6716d849957d1b293ee88ee66932070270d67 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:01:45 +0800 Subject: [PATCH 3/9] =?UTF-8?q?compose=5Fdoc=20=E7=9A=84=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=AF=B9=E4=B8=8A=20K5=EF=BC=9Adoc["source"]=20=E6=98=AF?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9=E4=BA=A4=E6=9D=A5=E7=9A=84=E5=8E=9F?= =?UTF-8?q?=E6=96=87=EF=BC=88=E6=8A=A5=E5=91=8A=E8=8A=82=E7=82=B9=E4=BA=A4?= =?UTF-8?q?=E6=9D=A5=E5=89=8D=E5=8E=BB=E6=8E=89=E4=BA=86=E6=95=B4=E7=AF=87?= =?UTF-8?q?=E5=A4=96=E5=B1=82=E5=9B=B4=E6=A0=8F=EF=BC=8C=E8=A7=81=20report?= =?UTF-8?q?.=5Funfenced=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=AF=B4?= =?UTF-8?q?=E6=98=AF=E6=A8=A1=E5=9E=8B=E7=9A=84=E5=8E=9F=E6=96=87=EF=BC=9B?= =?UTF-8?q?=E5=8F=AA=E6=94=B9=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=92=8C=E5=86=85=E5=AE=B9=E5=93=88=E5=B8=8C?= =?UTF-8?q?=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 407ac600..6d62b2f5 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -2506,7 +2506,7 @@ def compose_doc( stats / violations 由 verify_doc 算出,和出口契约复核用的是同一个函数。 整表标记先展开成每格一个 [[v:]] 的 Markdown 表,之后和模型自己写的表格走同一条路; - doc["source"] 仍是模型的原文。 + doc["source"] 是调用方交来的原文(报告节点交来前去掉了整篇外层围栏,见 report._unfenced)。 """ source = (raw or "").replace("\r\n", "\n").replace("\r", "\n").strip() snaps = _Snapshots(loader, entities=entities) From 7f7b69030744e20a3a721830c97fbccd537b6720 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:04:42 +0800 Subject: [PATCH 4/9] =?UTF-8?q?upload=5Fdates=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=8F=96=E7=8E=B0=E8=A1=8C=E9=85=8D=E6=96=B9=EF=BC=88current?= =?UTF-8?q?=5Frecipe=5Fid=EF=BC=89=EF=BC=9A=E5=8F=91=E5=B8=83=E3=80=81?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E6=97=A7=E7=89=88=E6=9C=AC=E6=97=B6=E5=AE=83?= =?UTF-8?q?=E5=92=8C=E5=BD=93=E5=89=8D=E5=BF=AB=E7=85=A7=E3=80=81=E8=A1=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=B8=80=E8=B5=B7=E6=8D=A2=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=BF=85=E5=86=8D=E7=BB=8F=E5=BF=AB=E7=85=A7=E5=92=8C=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E6=9C=9F=E5=AF=BC=E5=85=A5=E5=8E=BB=E6=89=BE?= =?UTF-8?q?=EF=BC=9B=E8=A1=A5=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E9=85=8D=E6=96=B9=E6=8A=8A=E6=94=AF=E4=BB=98=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E6=94=B9=E6=88=90=E6=96=87=E5=AD=97=E5=90=8E=E5=8F=AA=E5=89=A9?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E6=97=A5=E6=9C=9F=EF=BC=8C=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=88=B0=E6=97=A7=E7=89=88=E6=9C=AC=E4=B8=A4=E5=88=97=E5=8F=88?= =?UTF-8?q?=E9=83=BD=E6=98=AF=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/dual_check_facts.py | 20 ++++------ backend/tests/test_dual_check_upload.py | 50 +++++++++++++++++++------ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 09a11808..048d47a2 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -309,27 +309,21 @@ def recipe_dates(recipe: Any) -> dict[str, list[str]]: async def upload_dates(session: Any, row: DataSource | None) -> dict[str, list[str]]: """上传表格建的数据源当前版本里,导入时认作日期的列:{表名: [列名]}(recipe_dates)。只读,不碰导入流程。 - 按当前快照找它的配方(source_versions.snapshot_recipe_id:快照记着按哪一版配方建的表,期 3 之前的快照取最后一期导入 - 的):数据源的表结构(schema_cache)也出自当前快照,两者是同一个版本。读不出来的一律给空——手工登记的源、简单导入 - (没有配方,回执里只有存储类型)、快照或配方记录不在了、配方解析不了:业务日期照旧不记,原因说按文字保存。""" + 配方取现行的那一版(current_recipe_id):发布、启用旧版本时它和当前快照、表结构(schema_cache)一起换 + (table_versions.activate_snapshot),配方和表结构出自同一个版本。读不出来的一律给空——手工登记的源、简单导入 + (没有配方,构建回执里只有存储类型)、配方记录不在了、配方解析不了:业务日期照旧不记,原因说按文字保存。""" from app.data.recipe_types import Recipe - from app.data.source_versions import snapshot_recipe_id - from app.db.models import SourceSnapshot, TableImport, TableRecipe + from app.db.models import TableRecipe - if row is None or (row.origin or "manual") != "upload" or not row.current_snapshot_id: + if row is None or (row.origin or "manual") != "upload" or not row.current_recipe_id: return {} - snap = await session.get(SourceSnapshot, row.current_snapshot_id) - if snap is None or snap.source_id != row.id: - return {} - last = await session.get(TableImport, str(snap.imports[-1])) if snap.imports else None - recipe_id = snapshot_recipe_id(snap, {last.id: last} if last is not None else {}) - record = await session.get(TableRecipe, recipe_id) if recipe_id else None + record = await session.get(TableRecipe, row.current_recipe_id) if record is None or record.source_id != row.id: return {} try: return recipe_dates(Recipe.model_validate(record.recipe)) except ValueError: - logger.warning("上传表格的配方记录解析不了,业务日期按文字保存处理:source=%s recipe=%s", row.id, recipe_id) + logger.warning("上传表格的配方记录解析不了,业务日期按文字保存处理:source=%s recipe=%s", row.id, record.id) return {} diff --git a/backend/tests/test_dual_check_upload.py b/backend/tests/test_dual_check_upload.py index a538a646..c5f8caa8 100644 --- a/backend/tests/test_dual_check_upload.py +++ b/backend/tests/test_dual_check_upload.py @@ -92,13 +92,8 @@ def list_recipe(*, ordered: str = "DATE", paid: str = "DATE") -> dict: "tables": [{"name": TABLE, "grain": ["订单号"]}]} -async def recipe_import(client, recipe: dict) -> tuple[str, str]: - """按配方导入:暂存 → 换成给定的配方 → 试运行 → 勾全部确认项提交。返回 (数据源名, id)。""" - name = f"dc_upload_{uuid.uuid4().hex[:8]}" - r = await client.post(f"{API}/imports/stage", files={"file": (f"{SHEET}_2026-09.xlsx", workbook(), XLSX)}, - data={"name": name}) - assert r.status_code == 201, r.text - staging = r.json()["id"] +async def commit_recipe(client, staging: str, recipe: dict) -> dict: + """暂存区换成给定的配方 → 试运行 → 勾全部确认项提交。返回提交的响应。""" r = await client.put(f"{API}/imports/{staging}/recipe", json={"recipe": recipe}) assert r.status_code == 200 and r.json()["recipe_problems"] == [], r.text r = await client.post(f"{API}/imports/{staging}/trial", json={}) @@ -108,7 +103,17 @@ async def recipe_import(client, recipe: dict) -> tuple[str, str]: r = await client.post(f"{API}/imports/{staging}/commit", json={ "trial_id": trial["trial_id"], "confirmations": [i["id"] for i in trial.get("confirm_items") or []]}) assert r.status_code == 201, r.text - return name, r.json()["source"]["id"] + return r.json() + + +async def recipe_import(client, recipe: dict) -> tuple[str, str]: + """按配方导入:暂存 → 换成给定的配方 → 试运行 → 提交。返回 (数据源名, id)。""" + name = f"dc_upload_{uuid.uuid4().hex[:8]}" + r = await client.post(f"{API}/imports/stage", files={"file": (f"{SHEET}_2026-09.xlsx", workbook(), XLSX)}, + data={"name": name}) + assert r.status_code == 201, r.text + out = await commit_recipe(client, r.json()["id"], recipe) + return name, out["source"]["id"] async def simple_import(client) -> tuple[str, str]: @@ -127,11 +132,11 @@ async def dates_of(source_id: str) -> dict: async def break_recipe(source_id: str) -> None: - """把数据源当前版本的配方记录改坏(造「配方读不出来」)。""" + """把数据源当前版本的配方记录改坏(造「配方读不出来」)。当前快照记的就是这一版。""" async with SessionLocal() as session: row = await session.get(DataSource, source_id) - snap = await session.get(SourceSnapshot, row.current_snapshot_id) - record = await session.get(TableRecipe, snap.recipe_id) + assert (await session.get(SourceSnapshot, row.current_snapshot_id)).recipe_id == row.current_recipe_id + record = await session.get(TableRecipe, row.current_recipe_id) record.recipe = {"recipe_format": "agentlab-recipe/0"} await session.commit() @@ -193,6 +198,29 @@ async def test_an_upload_source_reads_the_date_columns_from_its_current_recipe(c assert await dates_of(paid_as_text[1]) == {TABLE: ["下单日期"]} +async def current_snapshot(source_id: str) -> str: + async with SessionLocal() as session: + return (await session.get(DataSource, source_id)).current_snapshot_id + + +async def test_the_dates_follow_the_current_version_of_the_source(client, dated): + """只认数据源当前版本的配方(表结构出自同一个版本):修改配方把支付日期改成「文字」、提交之后只剩下单日期;回滚到 + 旧版本,两列又都是日期。""" + _, source_id = dated + first = await current_snapshot(source_id) + r = await client.post(f"{API}/{source_id}/redraft", json={}) + assert r.status_code == 201, r.text + await commit_recipe(client, r.json()["id"], list_recipe(paid="TEXT")) + second = await current_snapshot(source_id) + assert second != first + assert await dates_of(source_id) == {TABLE: ["下单日期"]} + r = await client.post(f"{API}/{source_id}/snapshots/{first}/activate", + json={"confirm": True, "expected_current_snapshot_id": second}) + assert r.status_code == 200, r.text + assert await current_snapshot(source_id) == first + assert await dates_of(source_id) == {TABLE: ["下单日期", "支付日期"]} + + async def test_without_a_readable_recipe_no_column_is_taken_as_a_date(client, dated, tmp_path): """读不出配方的都不认:简单导入没有配方,配方记录坏了,手工登记的库,不存在的数据源。""" name, source_id = await simple_import(client) From fbcff1a58527caacfa7372828714b7ca13d626dc Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:06:23 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=87=8C=E4=B8=89?= =?UTF-8?q?=E8=A1=8C=E8=B6=85=E8=BF=87=20120=20=E5=AD=97=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E5=86=99=EF=BC=8C=E5=92=8C=E5=91=A8=E5=9B=B4=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E8=A1=8C=E5=AE=BD=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_contract_fenced_body.py | 3 ++- backend/tests/test_dual_check_facts.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_contract_fenced_body.py b/backend/tests/test_contract_fenced_body.py index d6c30c61..be6006b5 100644 --- a/backend/tests/test_contract_fenced_body.py +++ b/backend/tests/test_contract_fenced_body.py @@ -73,7 +73,8 @@ def test_a_report_body_in_a_prose_code_block_is_recognised(raw): @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("```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"), # 信息串为空、里面是代码:没有成句的文字;代码注释里的中文句子不算 diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index b13851fa..0e27e978 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -386,7 +386,8 @@ def test_an_uploaded_column_the_import_took_as_a_date_is_a_date(): for choice, column in (("a", "order_date"), ("b", "paid_date")): fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, choice, both), texts) assert why is None, why - assert (fact.table, fact.item, fact.value, fact.choice) == ("orders", "business_date", {"column": column}, choice) + assert (fact.table, fact.item, fact.value, fact.choice) == \ + ("orders", "business_date", {"column": column}, choice) said = preview(uploaded(BY_ORDER_DATE, RIGHT, choice, both), texts, {}) assert said[choice] == {"write": True, "source": "shop", "table": "orders", "item": "business_date", "value": {"column": column}} @@ -434,8 +435,8 @@ def test_the_import_dates_follow_the_names_in_the_table_structure(): """差异说明里的名字是规范化过的(不加引号的一律小写),配方里、表结构里的写法可能带大写:按表结构认出的那一列去对 upload_dates,大小写不同照样对得上。""" upper = {"tables": {"Orders": {"name": "Orders", "columns": [ - {"name": "Status", "type": "TEXT"}, {"name": "Order_Date", "type": "TEXT"}, {"name": "Paid_Date", "type": "TEXT"}, - {"name": "Amount", "type": "REAL"}]}}} + {"name": "Status", "type": "TEXT"}, {"name": "Order_Date", "type": "TEXT"}, + {"name": "Paid_Date", "type": "TEXT"}, {"name": "Amount", "type": "REAL"}]}}} fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", {"Orders": ["Order_Date", "Paid_Date"]}), upper) assert why is None and (fact.table, fact.value) == ("Orders", {"column": "paid_date"}) fact, why = derive(uploaded(BY_ORDER_DATE, RIGHT, "b", {"orders": ["order_date", "paid_date"]}), upper) From 04606cfe9487a425d291874d1b7fbfa94efc0831 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 18:03:12 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=86=99=E4=B8=9A=E5=8A=A1=E6=97=A5=E6=9C=9F=EF=BC=9A=E7=9C=9F?= =?UTF-8?q?=E5=86=99=E6=8C=89=E5=86=99=E5=85=A5=E6=97=B6=E7=9A=84=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=87=8D=E8=AF=BB=E4=B8=8A=E4=BC=A0=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=97=B6=E8=AE=A4=E4=BD=9C=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E7=9A=84=E5=88=97=EF=BC=8C=E5=87=A0=E4=B8=AA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=9D=97=E5=86=99=E5=90=8C=E4=B8=80=E5=BC=A0=E8=A1=A8=E6=97=B6?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E5=9D=97=E9=83=BD=E8=AE=B0=E4=B8=BA=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=89=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record_choices 的表结构是写入时现读的,候选里的 upload_dates 却是发起审批时读的:审批停着时上传源换了版本、把日期列 改成文字,两份拼在一起会写出一条哪个版本单独看都不会给出的人工确认。现在候选里带 upload_dates 的,真写在读数据源的 同一个会话里按当前版本重读、覆盖候选里的那份,和表结构出自同一个版本,版本没变时预告和真写仍一字不差;候选里没有 这一项的是升级前发起的审批,卡上预告的是不写,照旧不认。 recipe_dates 以前只要有一个列表块把某列记为日期就算日期列:两个工作表的列表块写同一张表、同一列一个日期一个文字时, 表形状一样(都存成 TEXT),配方校验不拦,按文字导入的那一块存的是单元格原文。现在写这一列的块都记为日期才算。 补测试:审批停着时修改配方把支付日期改成文字,裁决后照实说按文字保存、不写;没有 upload_dates 的老候选不重读、照旧 不写,带着的按写入时的版本重读后照写;两块类型不一致的列不算日期。 --- backend/app/engine/dual_check_facts.py | 32 ++++++--- backend/app/engine/nodes/dual_check.py | 3 +- backend/tests/test_dual_check_upload.py | 87 +++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 048d47a2..da1d3d4e 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -47,7 +47,8 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不 哪一项、本来要写什么,卡上才认得出它和别的指标是不是落到同一项上。同一次裁决里几个指标落到同一项上时,写进去的是 合起来的说法(上一段),单个指标的预告说不准:preview_joint 把这几个指标各自选哪一边的每种组合都按真写的办法算一遍 (_merged 归拢、_screen 拿掉驳回的、_plan 对目录),卡上按各指标当前的选择取对应的那一种。和真写是同一套判断、同一句 -原因,只有审批停下期间目录被人改了时才会和最后写的不一样。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。 +原因,只有审批停下期间目录被人改了、数据源换了版本时才会和最后写的不一样:真写按写入时的目录、表结构和导入时认作 +日期的列判断。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。 catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 """ @@ -149,9 +150,10 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, 这个指标自己的范围:texts 是口径说明、所在分组的取值、本次运行的输入(指标名称总算在内),columns 是分组的列;有效 记录条件用它排除指标自己的筛选范围。uploaded 为真是上传表格建的数据源,upload_dates 是它导入时认作日期的列 ({表名: [列名]},由 upload_dates 从配方读出):按文字保存的列在这里面的算日期列,不在的业务日期不记、原因照实说 - 是上传的表格按文字保存日期。两项都由节点发起审批时备好、放进候选:预告和审批之后的真写认的是同一份。schema_cache - 是数据源的表结构:认表,业务日期还要看两边的列声明的类型。notes 是这个数据源的目录({表名: notes}),认码值的意思 - 用。只看差异说明,不重新比 SQL:审核人看到的就是这份说明。 + 是上传的表格按文字保存日期。两项都由节点发起审批时备好、放进候选,审批卡上的预告按它;真写时 record_choices 按 + 写入时的版本重读 upload_dates,和它现读的表结构出自同一个版本。schema_cache 是数据源的表结构:认表,业务日期还要 + 看两边的列声明的类型。notes 是这个数据源的目录({表名: notes}),认码值的意思用。只看差异说明,不重新比 SQL: + 审核人看到的就是这份说明。 """ name = str(candidate.get("metric") or "") side = candidate.get("choice") @@ -292,15 +294,21 @@ def recipe_dates(recipe: Any) -> dict[str, list[str]]: 就是导入确认时界面上标「日期」的那些(recipe_notes 给列写日期格式说明认的也是这两种):交叉表的日期轴(Axis.type 恒为 日期),列表块里类型记为 DATE 的列。执行器把它们一律存成 YYYY-MM-DD 的文字,表结构里只剩 TEXT。哪张表有哪几列按 recipe_types.derive_tables 推(建表用的同一份):日期轴写进它各分段的表,列表合计行另存的表只有合计项和数字列。 - 列表的类型按 _DATED_TYPE 认,配方以后添了日期时间也算;只有时刻的不算。""" + 列表的类型按 _DATED_TYPE 认,配方以后添了日期时间也算;只有时刻的不算。几个列表块写同一张表的,这一列每一块都 + 记为日期才算:日期和文字都存成 TEXT,建出的表形状一样,配方校验不拦,可按文字导入的那一块存的是单元格原文。""" from app.data.recipe_types import ListBlock, derive_tables tables, _ = derive_tables(recipe) - listed = {(block.table, column.name) for sheet in recipe.sheets for block in sheet.blocks - if isinstance(block, ListBlock) for column in block.columns if _DATED_TYPE.fullmatch(column.type)} + listed: dict[tuple[str, str], bool] = {} + for sheet in recipe.sheets: + for block in sheet.blocks: + if isinstance(block, ListBlock): + for column in block.columns: + key = (block.table, column.name) + listed[key] = listed.get(key, True) and _DATED_TYPE.fullmatch(column.type) is not None out: dict[str, list[str]] = {} for table, columns in tables.items(): - names = [c.name for c in columns if c.role == "axis" or (table, c.name) in listed] + names = [c.name for c in columns if c.role == "axis" or listed.get((table, c.name), False)] if names: out[table] = list(dict.fromkeys(names)) return out @@ -806,7 +814,8 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no 进去,都落在 skipped 里照实说。 先把每个候选认成目录项,再按(数据源、表、项)归拢成一项(_merged:说法一样的、有效记录条件合起来写,说法 - 不同的整组不写),逐项写(_write:按读到的目录先拿掉各自带回驳回说法的指标,其余的照写)。""" + 不同的整组不写),逐项写(_write:按读到的目录先拿掉各自带回驳回说法的指标,其余的照写)。认目录项用的表结构、 + 目录、上传表格导入时认作日期的列(upload_dates)都按写入时数据源的现状读。""" skipped: list[dict[str, Any]] = [] facts: list[Fact] = [] for candidate in candidates: @@ -818,6 +827,11 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no skipped.append({"reason": f"数据源「{name}」已不存在,指标「{candidate.get('metric')}」的选择未写入数据目录"}) continue notes = {table: entry.notes for table, entry in (await catalog.read_catalog(session, row.id)).items()} + if "upload_dates" in candidate: + # 表结构是这里现读的,导入时认作日期的列也按写入时的当前版本重读:审批停着时换了版本(日期列改成了 + # 文字),拿发起审批时读的那份去配新的表结构,会写出一条哪个版本单独看都不会给出的人工确认。候选里 + # 没有这一项的是升级前发起的审批,卡上预告的是不写,照旧不认 + candidate = {**candidate, "upload_dates": await upload_dates(session, row)} fact, why = derive(candidate, row.schema_cache, notes) except Exception: # noqa: BLE001 - 目录读不出来不能毁掉审批的结果:缺口照样按选择记 logger.exception("双盲复算的选择核对数据目录失败(数据源 %s)", name) diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index fc5a0492..3ea49f75 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -1168,7 +1168,8 @@ async def _source_info(sources: _Sources, name: str) -> dict[str, Any]: """比较两条 SQL、预告目录写入要用的:方言、表结构、列的中文名、整份数据目录,以及是不是上传表格建的数据源 (uploaded:导入时日期一律存成文字)和它导入时认作日期的列(upload_dates,按当前版本的配方读,见 dual_check_facts.upload_dates)——业务日期据此认上传表格的日期列,认不出的原因照实说(_dated_problem)。两项都进 - 候选:审批卡的预告和裁决后的真写认的是发起审批时读的这一份。数据源没了就都不给,照样比原文。""" + 候选:审批卡的预告按发起审批时读的这一份;裁决后的真写按写入时的版本重读 upload_dates,和现读的表结构一起以写入时 + 为准(dual_check_facts.record_choices)。数据源没了就都不给,照样比原文。""" from app.data import catalog from app.data.sql_semantics import catalog_labels diff --git a/backend/tests/test_dual_check_upload.py b/backend/tests/test_dual_check_upload.py index c5f8caa8..41d55c41 100644 --- a/backend/tests/test_dual_check_upload.py +++ b/backend/tests/test_dual_check_upload.py @@ -5,8 +5,10 @@ - 配方里是日期的两列(列表块里类型为 DATE 的列、交叉表的日期轴):审核人选了哪一边,业务日期就记哪一列;审批卡的预告 和真写一字不差 -- 配方里是文字的列不写;配方读不出来(简单导入没有配方、配方记录坏了)的照旧不写,原因都照实说是上传的表格按文字保存 -- 只认数据源当前版本的配方:表结构出自同一个版本 +- 配方里是文字的列不写;配方读不出来(简单导入没有配方、配方记录坏了)的照旧不写,原因都照实说是上传的表格按文字保存; + 几个列表块写同一张表的,这一列每一块都记为日期才算 +- 只认数据源当前版本的配方:表结构出自同一个版本。审批停着时数据源换了版本,真写按写入时的版本重读(和表结构一样以 + 写入时为准);升级前发起的审批(候选里没有 upload_dates)不重读,照它审批卡上的预告不写 数据源一律经真实的导入接口建(暂存 → 改配方 → 试运行 → 提交,或者简单导入),工作簿是现造的:表名、地区、金额都是 虚构的。盲算模型是 MockChatModel 剧本(tests/fixtures/dual_check_shop.Blind)。 @@ -23,11 +25,12 @@ from httpx import ASGITransport, AsyncClient from openpyxl import Workbook from sqlalchemy import delete, select -from app.data.recipe_types import Recipe +from app.data.recipe_types import Recipe, derive_tables +from app.data.sql_semantics import explain_sql_diff from app.db.base import SessionLocal from app.db.models import DataSource, Setting, SourceSnapshot, TableRecipe from app.engine import spend -from app.engine.dual_check_facts import recipe_dates, upload_dates +from app.engine.dual_check_facts import recipe_dates, record_choices, upload_dates from app.engine.runner import run_manager from app.main import app from tests.fixtures.dual_check_shop import ROWS, Blind @@ -174,6 +177,28 @@ def test_the_axis_of_a_crosstab_is_a_date_column_of_every_table_it_writes(): "日客流": ["日期"], "时段客流": ["日期"], "时段客流_表内合计": ["日期"]} +def test_a_column_two_lists_type_differently_is_not_a_date(): + """两个工作表的列表块写同一张表,支付日期一块记为「日期」、一块记为「文字」:两块建出的表形状一样(日期也存成文字), + 配方校验不拦,可按文字导入的那一块存的是单元格原文(「待定」这类),这一列并不都是日期。写这一列的块都记为日期 + 才算,拿不准就不认;两块都记为日期的下单日期照常认。""" + recipe = list_recipe() + february = json.loads(json.dumps(recipe["sheets"][0])) + february["id"], february["match"] = "s2", {"name": "二月"} + february["blocks"][0]["id"] = "orders_feb" + for column in february["blocks"][0]["columns"]: + if column["name"] == "支付日期": + column["type"] = "TEXT" + recipe["sheets"].append(february) + parsed = Recipe.model_validate(recipe) + assert derive_tables(parsed)[1] == [], "背景:两块写成的表形状一样,不报冲突" + assert recipe_dates(parsed) == {TABLE: ["下单日期"]} + # 两块都记为日期:照常认两列 + both = list_recipe() + both["sheets"].append({**json.loads(json.dumps(both["sheets"][0])), "id": "s2", "match": {"name": "二月"}}) + both["sheets"][1]["blocks"][0]["id"] = "orders_feb" + assert recipe_dates(Recipe.model_validate(both)) == {TABLE: ["下单日期", "支付日期"]} + + def test_a_list_total_kept_as_a_table_has_no_date_column(): """列表的合计行另存成表时只有合计项和数字列:日期列不在里面。""" recipe = list_recipe() @@ -313,3 +338,57 @@ async def test_without_a_readable_recipe_nothing_is_written(monkeypatch, client, [wrote] = logs(await events(run.id), "dual_check_catalog") assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [said["a"]["reason"]] assert await notes_of(source_id, TABLE) is None + + +# -------------------------------------------------------------------------- +# 审批停着时数据源换了版本:真写按写入时的版本认 +# -------------------------------------------------------------------------- + + +async def test_a_new_version_while_waiting_is_what_the_write_reads(monkeypatch, client, dated): + """审批停着时修改配方、把支付日期改成「文字」并提交成新版本:真写时表结构是现读的,导入时认作日期的列也按写入时的 + 当前版本重读,两者出自同一个版本。审批卡上预告会记「支付日期」,裁决后照实说按文字保存、不写——不拿发起审批时读的 + 配方去配新版本的表结构,写出一条哪个版本单独看都不会给出的人工确认。""" + name, source_id = dated + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + run = await start(upload_graph(name, BY_ORDER_DATE)) + assert run.status == "interrupted", run.error + [item] = (await pending(run.id)).payload["summary"] + assert item["catalog"]["b"]["write"] is True, "背景:发起审批时两列在配方里都是日期" + r = await client.post(f"{API}/{source_id}/redraft", json={}) + assert r.status_code == 201, r.text + await commit_recipe(client, r.json()["id"], list_recipe(paid="TEXT")) + assert await dates_of(source_id) == {TABLE: ["下单日期"]} + done = await decide(client, run.id, {"gmv": "b"}) + assert done.status == "succeeded", done.error + [wrote] = logs(await events(run.id), "dual_check_catalog") + assert wrote["written"] == [] + assert [s["reason"] for s in wrote["skipped"]] == [ + f"指标「销售额」被采纳的盲算按时间筛的列「{TABLE}.支付日期」{STORED_AS_TEXT},不写入数据目录"] + assert await notes_of(source_id, TABLE) is None + + +async def candidate_of(name: str, source_id: str, choice: str) -> dict: + """真写收到的候选(节点从 checkpoint 里取出、加上审核人的选择):差异说明按数据源的表结构真算。""" + async with SessionLocal() as session: + schema = (await session.get(DataSource, source_id)).schema_cache + diff = explain_sql_diff(BY_ORDER_DATE, RIGHT, "sqlite", schema=schema, labels=("原流程", "盲算")).as_dict() + return {"metric_id": "gmv", "metric": "销售额", "source": name, "dialect": "sqlite", "uploaded": True, + "sql": {"a": BY_ORDER_DATE, "b": RIGHT}, "diff": diff, "choice": choice} + + +async def test_a_checkpoint_without_the_dates_is_written_as_its_card_said(client, dated): + """升级前发起的审批,checkpoint 的候选里没有 upload_dates:它的审批卡预告的是按文字保存、不写,真写不重读配方,照旧 + 不写,和那张卡说的一致。候选里带着这一项的(升级后发起的)按写入时的版本重读:发起审批时读出来是空的,写入时配方里 + 两列都是日期,照写——和表结构、目录在审批期间变了一样,以写入时为准。""" + name, source_id = dated + old = await candidate_of(name, source_id, "b") + assert [i["aspect"] for i in old["diff"]["items"]] == ["time"], "背景:两边只差按哪一列筛时间" + result = await record_choices([old], run_id="run_old", node_title="复核", actor=None) + assert result["written"] == [] + assert [s["reason"] for s in result["skipped"]] == [ + f"指标「销售额」被采纳的盲算按时间筛的列「{TABLE}.支付日期」{STORED_AS_TEXT},不写入数据目录"] + result = await record_choices([{**old, "upload_dates": {}}], run_id="run_new", node_title="复核", actor=None) + assert result["skipped"] == [] + assert result["written"] == [ + {"source": name, "table": TABLE, "item": "business_date", "value": {"column": "支付日期"}}] From b7da8a9af9343774943ae44ba5b320fe631d2d05 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 18:03:12 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E8=AE=A4=E8=97=8F=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E6=96=87=EF=BC=9A=E5=8F=AA=E6=8C=82=20[[see:?= =?UTF-8?q?]]=20=E4=BE=9D=E6=8D=AE=E7=9A=84=E3=80=81=E4=B8=8D=E5=B8=A6?= =?UTF-8?q?=E5=8F=A5=E8=AF=BB=E7=9A=84=E5=88=97=E8=A1=A8=E9=A1=B9=E4=B9=9F?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C=E4=BB=A3=E7=A0=81=E9=87=8C=E7=9A=84=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=92=8C=E5=AD=97=E7=AC=A6=E4=B8=B2=E4=B8=8D=E7=AE=97?= =?UTF-8?q?=E5=8F=A5=E5=AD=90=EF=BC=8C=E8=8B=B1=E6=96=87=E5=8F=A5=E5=AD=90?= =?UTF-8?q?=E6=94=B9=E6=88=90=E7=BA=BF=E6=80=A7=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮只看片段里的引用和带全角句读的行,几种写法仍会让正文藏在代码块里照样完整出具,另有几种代码被当成正文: - [[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 照常完整出具; 四万多字的长行一秒内判完。 --- backend/app/engine/nodes/io.py | 76 +++++++++++++++++----- backend/tests/test_contract_fenced_body.py | 69 ++++++++++++++++++-- scripts/check-decode.mjs | 11 ++++ 3 files changed, 134 insertions(+), 22 deletions(-) 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),两处一字不差 From 46403a0aacd34137d3f41618ac8241397eb05449 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 18:44:21 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E8=AE=A4=E8=97=8F=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E6=96=87=E6=94=B9=E4=B8=BA=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E7=9C=8B=E5=AD=97=E3=80=81=E4=B8=8D=E7=9C=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=9B=E4=BF=A1=E6=81=AF=E4=B8=B2=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=E5=9D=97=E9=87=8C=20YAML=E3=80=81diff=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=BD=93=E6=88=90=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮按列表标记认不带句读的行,只认 - * + • 和「1.」「1)」:中文报告常见的「1、」「一、」「(1)」、· ● 开头的行和 不带标记的行藏进代码块仍是 0 句、完整出具,不包反倒降档。同一条规则对信息串为空的块都生效,结论句为 0 的报告里贴一段 写着中文列名的 YAML 或改口径的 diff 会降档,缺口还说正文写在代码块里。 - 不再按标记认:标记写法太多,补一种漏一种。一行按正文的规矩会算一句结论(evidence._classify 认文字结论的那条: 方向词、因果词,又不只是在下定义,且有两个以上汉字)就算在写正文,不论标记,与不包代码块时的判法一致 - 信息串写明是文字(markdown、text 这类)、里面也看不出是代码的块:另认一行里连着六个以上汉字的(表格的列名、 门店名这类标签多在五个字以内);不当代码看,# -- 和半角引号都是正文里的符号;标题不算(正文里的标题也从不算结论) - 块里看得出是代码(SQL、Python 开头的行,小写开头的 YAML 键,整块是 diff),不论信息串写的是什么,只认去掉注释、 字符串之后成句的:值里、别名里的「最高」「占比」这类词不算;正文里首字母大写的「Note:」是标签,不算 YAML 的键 - 信息串为空、看不出是代码的块:去掉注释、字符串之后成句,或按正文的规矩会算结论;不带句读也不像结论的列名不算 - 围栏那两行是挂在代码块上的结构片段,判断时只看块里写的内容 测试:1、 一、 (1) · ● 和不带标记的写法藏进代码块(另有信息串为空的一种)正式运行降档并只带这条缺口;YAML 列表、 diff 在结论句为 0 的报告里照常完整出具;单元参数补上引用块、表格、粗体、短结论、正文里的 # -- 引号,以及 YAML 键、 diff、SQL 别名、写成 text / markdown 的代码、不到六个字的标签、只有标题。逐一去掉六字连写、两处结论词、代码判断 (YAML、diff 各一次)、不当代码看、结构片段过滤、小写键、标题跳过、挂钩,以及换回按标记认的旧规则,各有用例变红。 --- backend/app/engine/nodes/io.py | 74 +++++++++++++---- backend/tests/test_contract_fenced_body.py | 92 +++++++++++++++++++--- 2 files changed, 136 insertions(+), 30 deletions(-) diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index a6da3ece..4dd08310 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -496,11 +496,13 @@ _PROSE_FENCES = frozenset({"", "markdown", "md", "text", "plaintext", "txt", "pl _CJK = re.compile(r"[\u4e00-\u9fff]") #: 中文的句读:一行里有汉字、又有它们的,是在写句子(SQL 里的中文列名、别名不带这些) _CJK_PUNCT = re.compile(r"[,。!?;:]") +#: 一行里连着六个以上汉字:信息串写明是文字的块里,这就是在写中文。表格的列名、地区名、门店名这类标签多在五个字以内 +_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]+") -#: 列表项:- * + • 或「1.」「1)」开头。中文报告的列表项常不带句读,后面有两个以上汉字就算在写句子 -_LIST_ITEM = re.compile(r"\s*(?:[-*+•]|\d+[.)])\s+(.*)") +#: Markdown 标题:正文里的标题切块时记为 heading,从不算结论句(evidence._scan_blocks),代码块里的也不算在写正文 +_HEADING = re.compile(r"\s*#{1,6}\s") #: 整行是注释(-- // # /* */ 开头;# 开头的 Markdown 标题本来也不是句子),和行尾隔着空白的注释:写着中文句子也是 #: 代码的一部分 _COMMENT_LINE = re.compile(r"\s*(?:--|//|#|/\*|\*/)") @@ -508,10 +510,11 @@ _TRAILING_COMMENT = re.compile(r"\s(?:--|//|#).*") #: 代码里的字符串字面量('…'、"…",可带 f、r、b、u、N 这类前缀):里面的中文标点是数据。引号前面紧挨着字母、数字的 #: 不算开头(英文的 it's、don't) _STRING_LITERAL = re.compile(r"(? bool: @@ -525,16 +528,46 @@ def _en_sentence(line: str) -> bool: return False -def _prose_line(line: str, *, lists: bool = True) -> bool: - """这一行是不是在写句子:去掉字符串字面量和行尾注释之后,有两个以上汉字又有中文句读,或者是一句英文;lists 为真时, - 列表项后面有两个以上汉字也算。整行是注释的不算。""" +def _sentence(line: str) -> bool: + """成句:两个以上汉字又有中文句读,或者是一句英文。""" + return (len(_CJK.findall(line)) >= 2 and _CJK_PUNCT.search(line) is not None) or _en_sentence(line) + + +def _concluding(line: str) -> bool: + """按正文的规矩这一行会算一句结论(evidence._classify 认文字结论的那条):带方向词、因果词,又不只是在下定义。 + 只认有两个以上汉字的行:drop、rise 这类英文词在代码里也常见。数字不看,代码块里的数字报告节点按裸数字查。""" + from app.engine.evidence import _CAUSAL, _DEFINITION, _DIRECTIONAL + + return len(_CJK.findall(line)) >= 2 and _DIRECTIONAL.search(line) is not None \ + and not (_DEFINITION.search(line) is not None and _CAUSAL.search(line) is None) + + +def _written(line: str) -> bool: + """信息串写明是文字(markdown、text 这类)、里面也看不出是代码的块里,这一行在写正文:成句、按正文的规矩会算结论, + 或者一行里连着六个以上汉字。不看列表标记:标记的写法太多(- 1. 1、 一、 (1) · ●,也常不带标记),补一种漏一种; + 也不当代码看,# -- 和半角引号都是正文里的符号。标题不算。""" + if _HEADING.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 里 + 改的口径和列表项没有分别。整行是注释的不算。""" if _COMMENT_LINE.match(line): return False 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 + return _sentence(line) or (not code and _concluding(line)) def _drop_block_comments(text: str) -> str: @@ -549,13 +582,15 @@ def _drop_block_comments(text: str) -> str: def _fenced_body(doc: dict[str, Any]) -> bool: """报告的正文写在代码块里:文档里一句结论都没有,却有写文字的代码块(_PROSE_FENCES),里面写着引用(片段里的 - [[m:…]] 这类标记、句子上挂的 [[see:…]] 依据,代码里不会这样写)或者成句的文字(_prose_line)。 + [[m:…]] 这类标记、句子上挂的 [[see:…]] 依据,代码里不会这样写)或者在写正文。 报告节点只在整篇正好是一个代码块时去掉外层围栏(report._unfenced),围栏外有前言、只有开头没有收尾、信息串写成 text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据 的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。 - 信息串为空的代码块里也常是 SQL、Python:看内容是不是成句,注释、字符串字面量里的中文句子不算;整块看得出是代码 - (_CODE_LINE)的,以 * + - 开头的行是运算的续行,不当列表项。""" + + 在不在写正文逐行看:信息串写明是文字、里面也看不出是代码的按 _written。信息串为空的块里也常是 SQL、Python、YAML、 + diff,信息串写成 text 的也偶尔是代码,这些按 _prose_line:注释、字符串字面量里的中文句子不算;整块看得出是代码 + (_code_like)的只认成句的,以 * + - 开头的 SQL 续行、中文别名里的「最高」这类词都不算。""" from app.engine.evidence import iter_units units = list(iter_units(doc)) @@ -568,9 +603,14 @@ 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 - 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): + # 围栏那两行是挂在块上的结构片段,不是块里写的内容:整块是不是 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): return True return False diff --git a/backend/tests/test_contract_fenced_body.py b/backend/tests/test_contract_fenced_body.py index 0c832d48..09818132 100644 --- a/backend/tests/test_contract_fenced_body.py +++ b/backend/tests/test_contract_fenced_body.py @@ -5,11 +5,13 @@ K5 让报告节点去掉整篇外层的 markdown 围栏(report._unfenced), 照样完整出具,读的人以为结论都核对过。 - 结论句策略为 judge 或 require_citation(报告节点的,或出具契约自己写的)、结论句 0 句、又有信息串为空 / markdown / - md / text / plaintext / txt / plain 的代码块,里面是成句的文字(带句读的中文、成句的英文、不带句读的列表项)或写着 - 引用(片段里的引用、只挂依据的 [[see:]]):记一条缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部完成」 - 下(run/decode.ts 的 splitIssuanceGaps) -- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的(信息串为空、里面是代码的也算:注释、字符串里的中文标点 - 不算句子,SQL 以 * + - 开头的续行不算列表项);结论句策略为 off 的 + md / text / plaintext / txt / plain 的代码块,里面写着引用(片段里的引用、只挂依据的 [[see:]])或者在写正文:记一条 + 缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部完成」下(run/decode.ts 的 splitIssuanceGaps) +- 在写正文:带句读的中文、成句的英文,或者按正文的规矩会算一句结论(方向词、因果词);信息串写明是文字的,一行里连着 + 六个以上汉字也算。列表不论用什么标记(- 1. 1、 一、 (1) · ●),也不论带不带标记,只看字 +- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的;信息串为空、里面是代码的(注释、字符串里的中文标点不算 + 句子;有 SQL / Python 开头的行、YAML 的键,或者整块是 diff 时,方向词也不算),信息串写成 text、markdown 而里面 + 看得出是代码的同样;信息串为空的块里不带句读、也不像结论的标签和列名;结论句策略为 off 的 模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。 """ @@ -49,10 +51,29 @@ AS_TEXT = fenced(TEXT, "text") SEE_LIST = "## 本周结论\n\n- 华东销售额居各区域之首 [[see:Q1]]\n- 华南销售额超过华北 [[see:Q1]]" #: 不带句读、也没挂依据的列表:中文报告的列表项常这样写 BARE_LIST = "## 本周结论\n\n- 华东销售额居各区域之首\n- 华南销售额超过华北" +#: 同样两句,换成中文报告常见的几种编号、项目符号,或者干脆不带标记。不包代码块时第二句是一句结论(「超过」), +#: 第一句是连接性的话:两种句子都要认得出 +LINES = ("华东销售额居各区域之首", "华南销售额超过华北") +#: 不带标记、一行一句 +UNMARKED = f"## 本周结论\n\n{LINES[0]}\n\n{LINES[1]}" + + +def listed(first: str, second: str) -> str: + return f"## 本周结论\n\n{first}{LINES[0]}\n{second}{LINES[1]}" + + 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"), - pytest.param(f"以下是本周周报:\n\n{fenced(BARE_LIST)}", id="list-without-punctuation")] + pytest.param(f"以下是本周周报:\n\n{fenced(BARE_LIST)}", id="list-without-punctuation"), + pytest.param(f"以下是本周周报:\n\n{fenced(listed('1、', '2、'))}", id="digit-and-comma"), + pytest.param(f"以下是本周周报:\n\n{fenced(listed('一、', '二、'))}", id="chinese-numeral"), + pytest.param(f"以下是本周周报:\n\n{fenced(listed('(1)', '(2)'))}", id="fullwidth-parens"), + pytest.param(f"以下是本周周报:\n\n{fenced(listed('· ', '· '))}", id="middle-dot"), + 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")] def doc_of(raw: str) -> dict: @@ -82,11 +103,33 @@ def doc_of(raw: str) -> dict: 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"), + # 中文报告常见的编号、项目符号,或者不带标记:不看标记,只看字 + pytest.param("以下是周报:\n\n```markdown\n1、华东销售额居各区域之首\n2、华南销售额超过华北\n```", id="digit-and-comma"), + pytest.param("```markdown\n一、华东销售额居各区域之首\n二、华南销售额超过华北\n```", id="chinese-numeral"), + pytest.param("```text\n(1)华东销售额居各区域之首\n(2) 华南销售额超过华北\n```", id="parens"), + pytest.param("```markdown\n· 华东销售额居各区域之首\n● 华南销售额超过华北\n▪ 华北销售额持平\n```", id="dots-circles-squares"), + pytest.param("```markdown\n华东销售额居各区域之首\n\n华南销售额超过华北\n```", id="no-markers"), + pytest.param("```markdown\n> 华东销售额居各区域之首\n```", id="blockquote"), + pytest.param("```markdown\n| 区域 | 结论 |\n|---|---|\n| 华东 | 销售额居各区域之首 |\n```", id="table-row"), + pytest.param("```markdown\n**华东**销售额居各区域之首\n```", id="bold"), + # 写明是文字的块:一行里连着六个以上汉字就是在写中文,不像结论的也算 + pytest.param("```markdown\n华东销售额居各区域之首\n```", id="six-or-more-in-a-row"), + # 不到六个字的短句:按正文的规矩是一句结论(方向词)就算 + pytest.param("```text\n- 华北回落\n- 华南领先\n```", id="short-conclusions"), + # 写明是文字的块不当代码看:# -- 和半角引号都是正文里的符号 + pytest.param("```markdown\n华东排名 #1,销售额居各区域之首。\n```", id="hash-in-prose"), + pytest.param('```markdown\n负责人说"销售额创新高,下周继续推进。"\n```', id="quoted-sentence"), + pytest.param("```markdown\n华东领跑 -- 华南次之,华北第三。\n```", id="dash-in-prose"), + # 信息串为空、看不出是代码:按正文的规矩会算结论的行也算 + pytest.param("```\n1、华东销售额居各区域之首\n2、华南销售额超过华北\n```", id="no-info-enumerated"), + pytest.param("```\n华北销售额回落\n```", id="no-info-conclusion"), + # 首字母大写的「Note:」是正文里的标签,不是 YAML 的键 + pytest.param("```markdown\nNote: 以下为初步结论\n- 华南销售额超过华北\n```", id="capitalised-label"), ]) def test_a_report_body_in_a_prose_code_block_is_recognised(raw): doc = doc_of(raw) @@ -115,10 +158,28 @@ def test_a_report_body_in_a_prose_code_block_is_recognised(raw): 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 的块:以 * + - 开头的是运算的续行,后面是中文列名也不是列表项 + # 信息串为空、看得出是 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("```text\n门店名称 累计销售额\n华东一店 1500\n```", id="labels-under-six"), + # 标题不算正文:不包代码块时,标题也从不算结论句 + pytest.param("```markdown\n## 华南销售额超过华北\n```", id="heading-only"), + # 信息串为空、不带句读的行:不像结论的不算(列名、改了的口径) + pytest.param("## 口径说明\n\n本周报告按支付日期统计。\n\n```\ncolumns:\n - 订单编号\n - 支付日期\n```", id="yaml-list"), + pytest.param("## 口径说明\n\n本周报告按支付日期统计。\n\n```\n- 按下单日期统计\n+ 按支付日期统计\n```", id="diff"), + pytest.param("```\n- 订单编号\n- 支付日期\n```", id="labels"), + # 信息串为空、看得出是代码(SQL、YAML 的键、diff):值里、别名里有方向词也不是结论 + 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"), + 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,里面却看得出是代码:照代码看,注释里的句子、别名里的方向词不算 + 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("华东销售额超过华南。\n\n```markdown\n华东销售额 1500,居各区域之首。\n```", id="claims-outside"), # 没有代码块 @@ -222,11 +283,16 @@ 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```") +@pytest.mark.parametrize("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"), +]) +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```") row = await finish(weekly(claims="require_citation")) assert row.status == "succeeded", row.error issuance = row.output["_issuance"] From 6d9d31395b2602ff8c992647eee8e38803a89a8d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 19:51:29 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E8=AE=A4=E8=97=8F=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E6=96=87=E6=94=B9=E4=B8=BA=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E6=A0=87=E5=87=BA=E4=BB=A3=E7=A0=81=EF=BC=9A=E5=86=99=E6=98=8E?= =?UTF-8?q?=E6=98=AF=E6=96=87=E5=AD=97=E7=9A=84=E5=9D=97=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=9B=A0=E4=B8=BA=E4=B8=80=E8=A1=8C=20SQL=E3=80=81=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E5=83=8F=20YAML=20=E7=9A=84=E9=94=AE=E6=88=96=20+=20/?= =?UTF-8?q?=20-=20=E8=A6=81=E7=82=B9=E5=B0=B1=E6=95=B4=E5=9D=97=E5=BD=93?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=85=AC=E5=BC=8F=E3=80=81JSON?= =?UTF-8?q?=E3=80=81=E7=9B=AE=E5=BD=95=E6=A0=91=E8=BF=99=E7=B1=BB=E5=83=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9A=84=E8=A1=8C=E7=85=A7=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=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"]