合入期 5 前置修复 L1:上传表格建的数据源按导入配方里的逻辑类型认日期列,双盲复算的选择能写成业务日期;报告的结论句策略要求核对、正文却写在代码块里(结论句为 0)时,出口契约记一条缺口、不再判完整出具;compose_doc 的说明照实写 source 来自调用方

This commit is contained in:
ThearchyHelios
2026-10-06 22:19:34 +08:00
8 changed files with 1270 additions and 27 deletions
+108 -19
View File
@@ -9,8 +9,9 @@
文本列(SQLite 常把日期存成文字)、整数列(Unix 秒、20260901 这类日期键)、名字带 time 的时长列都可能被说成「按它筛
时间」,认不准存的是不是日期,不记。只有时刻的 TIME 也不记:一天里的几点几分(MySQL 还常拿它存时长)不是日期,和
catalog.is_date_column(剖析和 SQL 检查共用的日期列判断)同一个口径——记成业务日期,SQL 检查会对按真实日期列统计的
查询一直报错误级。上传表格建的数据源,导入时日期一律按文字保存,这一条门槛下它们的业务日期都不由这里记(原因照实说
是上传的表格按文字保存,不说成用户声明了 TEXT)
查询一直报错误级。上传表格建的数据源,导入时日期一律按文字保存,表结构里总是 TEXT:这样的列看导入时配方里记的逻辑
类型(upload_dates,导入确认时界面上标的「日期」就是它),是日期的算日期列;配方读不出来(简单导入没有配方、配方
记录坏了)或者配方里不是日期的,照旧不记,原因照实说是上传的表格按文字保存,不说成用户声明了 TEXT
- 一边多一个排除无效记录的条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件
- 聚合只差去不去重、被采纳的一边去重 → 去重规则(dedup)。采纳了不去重的一边不记:那是这个指标自己的算法(数行),
写成表的「不去重」会把别的指标带偏
@@ -46,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 的乐观锁)写,只多把备注放进项里。
"""
@@ -144,11 +146,14 @@ 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 从配方读出):按文字保存的列在这里面的算日期列,不在的业务日期不记、原因照实说
是上传的表格按文字保存日期。两项都由节点发起审批时备好、放进候选,审批卡上的预告按它;真写时 record_choices 按
写入时的版本重读 upload_dates,和它现读的表结构出自同一个版本。schema_cache 是数据源的表结构:认表,业务日期还要
看两边的列声明的类型。notes 是这个数据源的目录({表名: notes}),认码值的意思用。只看差异说明,不重新比 SQL:
审核人看到的就是这份说明。
"""
name = str(candidate.get("metric") or "")
side = candidate.get("choice")
@@ -164,7 +169,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 +194,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 +220,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 +229,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 +262,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 认,配方以后添了日期时间也算;只有时刻的不算。几个列表块写同一张表的,这一列每一块都
记为日期才算:日期和文字都存成 TEXT,建出的表形状一样,配方校验不拦,可按文字导入的那一块存的是单元格原文。"""
from app.data.recipe_types import ListBlock, derive_tables
tables, _ = derive_tables(recipe)
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 listed.get((table, c.name), False)]
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)。只读,不碰导入流程。
配方取现行的那一版(current_recipe_id):发布、启用旧版本时它和当前快照、表结构(schema_cache)一起换
(table_versions.activate_snapshot),配方和表结构出自同一个版本。读不出来的一律给空——手工登记的源、简单导入
(没有配方,构建回执里只有存储类型)、配方记录不在了、配方解析不了:业务日期照旧不记,原因说按文字保存。"""
from app.data.recipe_types import Recipe
from app.db.models import TableRecipe
if row is None or (row.origin or "manual") != "upload" or not row.current_recipe_id:
return {}
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, record.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]:
@@ -732,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:
@@ -744,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)
@@ -980,4 +1068,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"]
+1 -1
View File
@@ -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)
+10 -5
View File
@@ -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,23 @@ 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)。两项都进
候选:审批卡的预告按发起审批时读的这一份;裁决后的真写按写入时的版本重读 upload_dates,和现读的表结构一起以写入时
为准(dual_check_facts.record_choices)。数据源没了就都不给,照样比原文。"""
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]:
+224
View File
@@ -488,6 +488,224 @@ _ON_UNSUPPORTED = ("degrade", "withhold")
#: 要求结论句挂依据的策略:judge 在这一点上和 require_citation 一样严,另外再按裁判的判定判档
_CITING = ("require_citation", "judge")
#: 报告的正文写在代码块里、一句结论都没切出来时的缺口(_fenced_body)。不带核查节点的前缀:界面归在「校验未全部完成」下
GAP_FENCED_BODY = "报告「{title}」的正文写在代码块里,结论句未能核对"
#: 写的是文字的代码块:信息串为空、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"[,。!?;:]")
#: 一行里连着六个以上汉字:信息串写明是文字的块里,这就是在写中文。表格的列名、地区名、门店名这类标签多在五个字以内
_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 标题本来
#: 也不算结论句(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"(?<![A-Za-z0-9_])[rRbBuUfFnN]{0,2}(?:'[^'\n]*'|\"[^\"\n]*\")")
#: 可以跨行的块注释、多行字符串:开头的写法,和它对应的收尾
_SPAN_OPEN = re.compile(r"/\*|\"\"\"|'''")
_SPAN_CLOSE = {"/*": "*/", '"""': '"""', "'''": "'''"}
#: SQL、Python 语句的开头。SQL 的关键字只认全小写、全大写的写法:「From 数据看……」「Update ……」这类首字母大写的是
#: 正文里的英文词;def、class 要写成定义的样子(def 名字(、class 名字: ),「class A 门店……」说的是门店的分类
_CODE_LINE = re.compile(r"\s*(?:(?:select|with|from|where|join|group\s+by|order\s+by|having|union|insert|update|delete"
r"|create|import|return|SELECT|WITH|FROM|WHERE|JOIN|GROUP\s+BY|ORDER\s+BY|HAVING|UNION|INSERT"
r"|UPDATE|DELETE|CREATE)\b|(?:def|class)\s+[A-Za-z_]\w*\s*[(:])")
#: YAML 的键(「columns:」「- name: 退款占比」),只认小写开头的:正文里的「Note:」「Q1:」是标签。值为空、下面缩进着
#: 列表或子键的另记一种(_YAML_NEST)
_YAML_KEY = re.compile(r"\s*(?:-\s+)?[a-z_][\w.-]*:(?:\s|$)")
_YAML_NEST = re.compile(r"\s*(?:-\s+)?[a-z_][\w.-]*:\s*$")
#: 块里再嵌的代码块:~~~ 或三个以上反引号开头。compose_doc 只认整行三个反引号的围栏,那样的一行外层就收了,块里剩下的
#: 嵌套写法是 ~~~sql、````sql 这些
_NESTED_FENCE = re.compile(r"\s*(`{3,}|~{3,})(.*)")
#: 像数据、公式的一行:前面有字的等号(公式、赋值、比较)和箭头(连起的流程),画目录树的制表符,JSON 的对象、数组开头
#: ({"、[")和「"键":」,函数调用(print(、groupby(),带扩展名的文件名(周报.xlsx)。行首的「→ 」「=> 」是列表的
#: 记号,不算
_DATA_LINE = re.compile(r"\S\s*(?:[==→⟶⇒]|->)|[\u2500-\u257f]|^\s*[\[{]\s*[\"'\[{]|[\"']\s*:"
r"|(?<![\w.])[A-Za-z_][\w.]*\(|(?<=[^\s.])\.[A-Za-z][A-Za-z0-9]{1,4}(?![\w.])")
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 _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 _COMMENT_LINE.match(line):
return False
return _sentence(line) or _CJK_RUN.search(line) is not None or _concluding(line)
def _prose_line(line: str, *, code: bool) -> bool:
"""代码的行(code),或者信息串为空的块里的行,在写正文:去掉字符串字面量和行尾注释之后成句;不是代码的行另认按正文
的规矩会算结论的(不带句读的列表项、不带标记的行)。不带句读、也不像结论的不算:YAML 里的列名、diff 里改的口径和
列表项没有分别。整行是注释的不算。"""
if _COMMENT_LINE.match(line):
return False
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 _blank_spans(text: str) -> str:
"""块注释(/* … */)、三个引号括起的多行字符串涂成空格,换行留着,块里还是原来那几行:里面写着中文句子也是代码的
一部分。没收尾的不动,开头那一行照注释行跳过。"""
out: list[str] = []
at = 0
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:
"""报告的正文写在代码块里:文档里一句结论都没有,却有写文字的代码块(_PROSE_FENCES),里面写着引用(片段里的
[[m:…]] 这类标记、句子上挂的 [[see:…]] 依据,代码里不会这样写)或者在写正文。
报告节点只在整篇正好是一个代码块时去掉外层围栏(report._unfenced),围栏外有前言、只有开头没有收尾、信息串写成
text 的原样交给 compose_doc:整段正文落在代码块里,切不出结论句,裁判没有句子可判、反方审稿记 no_claims,没挂依据
的、证据相矛盾的结论句都数出 0 句,出具就成了完整出具。有一句结论的不算:正文没被整个藏起来,结论句照常核对。
在不在写正文逐行看。先标出哪些行是代码(_code_lines;信息串为空、整块是 diff 的全是代码),代码的行只认去掉注释、
字符串之后成句的(_prose_line),中文别名、YAML 的值、公式里的「最高」「占比」这类词都不算。其余的行:信息串写明
是文字的按 _written,信息串为空的块里也常是代码,去掉注释、字符串之后再看(_prose_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:
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 unit.get("see") or any(s.get("ref") for s in segments):
return True
# 围栏那两行是挂在块上的结构片段,不是块里写的内容:算进来的话,开头那行 ```markdown 会被当成块里再嵌的代码块,
# 整块是不是 diff 也看不准
text = "".join(str(s.get("text") or "") for s in segments if s.get("kind") != "structural")
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
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 +725,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 +747,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"
+426
View File
@@ -0,0 +1,426 @@
"""出口契约:报告的正文仍写在代码块里、一句结论都没切出来时记出具缺口(nodes/io.py 的 _fenced_body)。
K5 让报告节点去掉整篇外层的 markdown 围栏(report._unfenced),规格有意不去的写法仍会让正文落在代码块里:围栏外有
前言、只有开头没有收尾、信息串写成 text。这时结论句是 0 句:裁判没有句子可判、反方审稿记 no_claims,以前正式运行
照样完整出具,读的人以为结论都核对过。
- 结论句策略为 judge 或 require_citation(报告节点的,或出具契约自己写的)、结论句 0 句、又有信息串为空 / markdown /
md / text / plaintext / txt / plain 的代码块,里面写着引用(片段里的引用、只挂依据的 [[see:]])或者在写正文:记一条
缺口,降档。缺口不带核查节点的前缀,界面上归在「校验未全部完成」下(run/decode.ts 的 splitIssuanceGaps)
- 逐行看,先标出代码的行:块里再嵌的代码块(~~~sql)、SQL / Python 的语句连同续行、整段是 YAML 的(写明是文字的块里
要有值为空的键)、像数据的行(公式、JSON、目录树、流程箭头、函数调用、文件名、逗号隔开的表头),信息串为空、整块是
diff 的全是代码。正文里另有一段代码、一行像 YAML 键的标签,不妨碍认出别的行
- 在写正文:代码的行只认去掉注释、字符串之后成句的;别的行带句读的中文、成句的英文,或者按正文的规矩会算一句结论
(方向词、因果词);信息串写明是文字的,一行里连着六个以上汉字也算。列表不论用什么标记(- + 1. 1、 一、 (1) · ●
→),也不论带不带标记,只看字
- 行为不变:有正常结论句的;代码块是 sql、python 这类代码的;写文字的块里只有代码、数据、公式的(注释、字符串里的
中文句子不算,别名、公式里的方向词也不算);信息串为空的块里不带句读、也不像结论的标签和列名;结论句策略为 off 的
- 取舍:写成 text、markdown 的 diff,+ - 后面又空一格的,和列表分不开,照列表看;信息串为空的块里先列 - 再列 + 的
要点、整段都是「小写键: 值」的,和 diff、YAML 分不开,照代码看
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额都是虚构的。
"""
from __future__ import annotations
import time
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")
#: 只用 [[see:]] 挂依据的列表:依据记在句子上(unit 的 see),片段里没有引用;列表项也不带句读
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]}"
#: + / - 开头的正负面要点:不包代码块时两句都是结论(「增长」「回落」)
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"),
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"),
# + / - 开头的正负面要点:写明是文字的块里是列表;信息串为空的块里先列 + 的也不是 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:
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("```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"),
# 中文报告常见的编号、项目符号,或者不带标记:不看标记,只看字
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"),
# + / - 开头的正负面要点:写明是文字的块里是列表,不论哪个在前;信息串为空的块里先列 + 的不是 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)
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("```\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("```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"),
# 取舍:信息串为空、整段都是「小写键: 值」的照 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"),
# 没有代码块
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
@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()
_fenced_body(doc)
assert time.perf_counter() - started < 1.0
# --------------------------------------------------------------------------
# 端到端:正式运行(裁判 + 反方审稿 + 出具契约)
# --------------------------------------------------------------------------
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
@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("", "退款占比 = 退款金额 / 销售额", 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, 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"]
assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance
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
+96 -2
View File
@@ -4,8 +4,9 @@
条件,聚合只差去不去重、被采纳的一边去重 → 去重规则;按被采纳那一边的写法记
- 业务日期只在两边的列都在表结构里声明为带日期的类型(DATE、DATETIME、TIMESTAMP,带精度、带时区的写法也算)时才写:
文本、整数(Unix 秒、20260901 这类日期键)、只有时刻的 TIME、表结构里查不到、没声明类型(SQLite 探出来是 NULL)的
不写,原因说清是哪一列、哪一边(未被采纳的那一边另说清为什么也要看它);上传表格建的数据源说清日期是按文字保存的;
数据源还没有可用的表结构时说表结构不可用
不写,原因说清是哪一列、哪一边(未被采纳的那一边另说清为什么也要看它);上传表格建的数据源,按文字保存的列看导入时
配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有
可用的表结构时说表结构不可用
- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写,
照实说原因。同一列上的时间范围不同、跨两张表的条件,差异说明只给兜底,同样不写;说明以后真把跨两张表的条件说成一条
筛选条件,derive 自己那道「只涉及一张表」的检查也拦得下
@@ -367,6 +368,99 @@ 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」。"""
+394
View File
@@ -0,0 +1,394 @@
"""上传表格建的数据源上的双盲复算:业务日期按导入时配方里记的逻辑类型认日期列(engine/dual_check_facts.upload_dates)。
上传表格导入时日期一律按文字保存,表结构里总是 TEXT,以前这类数据源的业务日期永远写不了。导入时配方里其实记着每一列
的逻辑类型(导入确认时界面上标的「日期」):
- 配方里是日期的两列(列表块里类型为 DATE 的列、交叉表的日期轴):审核人选了哪一边,业务日期就记哪一列;审批卡的预告
和真写一字不差
- 配方里是文字的列不写;配方读不出来(简单导入没有配方、配方记录坏了)的照旧不写,原因都照实说是上传的表格按文字保存;
几个列表块写同一张表的,这一列每一块都记为日期才算
- 只认数据源当前版本的配方:表结构出自同一个版本。审批停着时数据源换了版本,真写按写入时的版本重读(和表结构一样以
写入时为准);升级前发起的审批(候选里没有 upload_dates)不重读,照它审批卡上的预告不写
数据源一律经真实的导入接口建(暂存 → 改配方 → 试运行 → 提交,或者简单导入),工作簿是现造的:表名、地区、金额都是
虚构的。盲算模型是 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, 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, 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
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 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={})
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 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]:
"""期 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)
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()
@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_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()
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 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)
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
# --------------------------------------------------------------------------
# 审批停着时数据源换了版本:真写按写入时的版本认
# --------------------------------------------------------------------------
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": "支付日期"}}]
+11
View File
@@ -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),两处一字不差