Files
AgentLab/backend/app/data/recipe_notes.py
T
ThearchyHelios 2d5e50fa88 Excel 导入改造期 2:配方引擎与确认向导——表从哪一行哪一列开始、哪些是合计、日期怎么补年份,都写进一份可校验的配方;首次导入经人确认,之后每期按配方重放并核对
配方语言(agentlab-recipe/2,新 recipe_types、recipe)
- 封闭、不含正则、保存时静态校验:常量只能从表里的候选文字里选,占位符只能映射成空值,单位从固定词表里取
- 交叉表和列表两种版式;分段各自定位、不依赖先后顺序;合计按标签语义区间核对,关系恒等式、口径不同的声明都写在配方里
- 固定的解析器集合(parsers/2):全角、各类横线、千分位、月日写法宽容归一,规范写法存库、原文另记

执行器(新 xlsx_cells、recipe_engine)
- 两遍读取逐行对账,每个非空格都要有去向(格子账);交叉表逆透视成长表,按统计期补年份并断言覆盖整期
- 合计行改作核对并另存一张表,不和明细混在一列;区域外文字、隐藏行列、合并单元格、空行之后的文字都单独报出
- STRICT 加主键写临时库;按锚点段记溯源,供后续证据下钻用;大表流式读取,扫描在 sheetData 处停止

校验与说明(新 recipe_checks、recipe_notes)
- SQL 核对分通过、不一致、无法核对三种结果,失败分结构、数据质量、需确认三类;结构类直接拒收
- 数据质量类可以写理由接受,接受不改库、只改状态和说明
- 表的说明按核对结果生成、不含数字,经过数字检查;没核对过的不写「已核对」

起草(新 recipe_suggest、recipe_ai)
- 先用规则起草:建议卡片加待确认问题(关系要不要登记、占位符表示什么)
- 规则起草不完整时才提示 AI 兜底:界面先展示要发出的全部文字,用户同意才发;只发结构,数字、隐藏行列、遮罩列不发,公式先脱敏;同意绑定模型和服务方;AI 只写配方,有限次修订,记 token 和费用;截断单独识别
- 无论规则还是 AI 起草,都必须人工确认才能启用

导入流程(新 recipe_imports、recipe_confirm、recipe_diff、api/table_imports)
- 暂存 → 起草 → 回答问题或改配方 → 试运行 → 勾确认项、写接受理由 → 确认并启用;接入期 1 的构建、导入记录、快照和运行固定版本
- 上传新一期按已确认的配方重放,不调用模型,出回执和差异卡,手动启用
- 改配方报破坏性变更(breaking:*)、单位变化、工作表改名的兜底,都要逐项勾选确认

前端导入向导(新 pages/import)
- 原始网格(行列号、去向着色)、建议卡片、配方面板(表单,不露正则)、试运行回执、确认清单、差异卡、AI 起草的同意对话框
2026-10-01 22:00:07 +08:00

650 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按核对结果生成表说明和列说明(P2-SPEC 5.4),再写进要冻结的表结构(schema_cache)。
说明进模型可见文本:introspect.describe_table 把表和列的 comment 拼进 db_schema 的输出,摘要里也有。
所以这里守三条规矩:
- **说明跟着核对结果走,规则写死**:K / T 没通过,这张表的说明里不出现「一致」;一条核对有几种无法核对的
原因,用最弱的「原因混合」说法;找不到对应模板就记 problems(不能保存),绝不退回到别的模板——
宁可导不进去,也不让模型读到「已核对一致」而事实上没核对。核对本身是不可接受的结构类失败(试运行已经
拒收)时,那一段不写、也不记 problems,免得用户被引去改配方(见 _blocking)。
- **不带数字和坐标**:模板里的列名、表名、单位一律写成 {列:名} {表:名} {单位:名} 引用,每张表、每列的模板都过
契约的 prose_problems(known=…)(遮盖引用后不许抽出数字、不许有坐标,引用必须指向真实的名字)。
原表头、坐标、区域外文字全文只进清单和界面,不进说明。
- **模板是常量,拼接只用 .format()**:模板放在 NOTE_TEXT / COLUMN_TEXT 里,{a} {b} 这类槽位由 _tok 填入引用;
这样模板本身经得起文案检查,填进去的名字也不会被当成格式串再解析一次。
"""
from __future__ import annotations
import copy
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from app.data.names import canon
from app.data.recipe_checks import date_columns, plan_checks, split_coord
from app.data.recipe_parsers import UNITS
from app.data.recipe_types import (
Acceptance,
CheckResult,
ColumnOut,
CrosstabBlock,
DerivedSegment,
DimensionSegment,
Extraction,
ListBlock,
MeasuresSegment,
NotComparable,
Placeholder,
Recipe,
SchemaNotes,
SheetRecipe,
SumEq,
TableNote,
derive_tables,
prose_problems,
render_prose,
)
#: 表说明的片段模板。槽位里填的是 {列:名} {表:名} 引用(_tok 生成),片段之间用「。」拼接
NOTE_TEXT = {
"grain_1": "粒度:每个{a}",
"grain_2": "粒度:每个{a}的每个{b}",
"grain_n": "粒度:{a} 与 {b} 的每个组合",
"sum_eq_passed": "{total} 等于 {parts} 之和(导入时已{per}核对):求合计只用 {total},不要把这几列相加",
"sum_eq_mismatch_accepted": "{total} 与 {parts} 之和的关系在本期个别{unit}不成立,已由用户确认接受,详见导入清单:"
"不要据此推算,也不要把这几列相加",
# 试运行回执的预览按「全部未接受」生成(P2-SPEC 7.4),这时还不能写「已由用户确认接受」
"sum_eq_mismatch_pending": "{total} 与 {parts} 之和的关系在本期个别{unit}不成立,尚未确认接受:"
"不要据此推算,也不要把这几列相加",
"sum_eq_nulls": "{total} 与 {parts} 之和的关系在本期部分{unit}因空值未能核对:不要据此推算,也不要把这几列相加",
# 本期一行数据都没有(AU-1):不能写「已核对」,也不是「因空值」
"sum_eq_no_rows": "{total} 与 {parts} 之和的关系在本期没有数据行,未能核对:不要据此推算,也不要把这几列相加",
"not_comparable_unequal": "与 {other} 口径不同:各{dims}之和不等于 {value},不能互相推算或相加",
# 基表除了分组列没有别的粒度列时,「各……之和」无从说起,改说按分组列汇总
"not_comparable_unequal_flat": "与 {other} 口径不同:{a_value} 按 {by} 汇总不等于 {value},不能互相推算或相加",
"not_comparable_equal": "与 {other} 口径不同,不能互相推算或相加",
"hour_range": "{dim} 按规范写法存储:{start}-{end},不带单位",
# 配方没派生起止小时列时,没有列可以引用
"hour_range_plain": "{dim} 按规范写法存储:起始小时与结束小时用连字符相连,不带单位",
"const": "{col} 取自原表的分段标题",
"total_kept": "原表的合计在 {table},不要与本表相加",
"total_not_kept": "原表的合计行未导入本表",
"total_k_passed": "原表写明的合计,已按明细重算核对一致",
"total_k_passed_g_unverifiable": "原表写明的合计,合计值已按明细核对一致,公式引用的格子未能核对",
"total_k_uncached": "原表写明的合计。本期原表未保存计算结果,合计值为空,未能核对",
"total_k_blank": "原表写明的合计。本期原表合计格为空,未能核对",
"total_k_null_detail": "原表写明的合计,按原表保存的值存储。本期明细含无数据占位符,未能核对",
"total_k_tiling": "原表写明的合计,按原表保存的值存储。本期明细未能覆盖合计的全部范围,未能核对",
"total_k_hidden": "原表写明的合计,按原表保存的值存储。本期存在隐藏行或使用了分类汇总函数,合计口径无法确定,未能核对",
"total_k_mixed": "原表写明的合计,按原表保存的值存储。本期合计值未能核对",
"total_overlap": "各{dim}覆盖的时段可能互相重叠:不要彼此相加,也不要与 {base} 相加",
"list_total_passed": "原表合计行的值,已按各列明细求和核对一致:不要与 {base} 相加",
# 合计行里空着的数字格不生成合计格(P2-SPEC 4.4),T 只核对了其余的列:不能说「各列」都一致
"list_total_passed_partial": "原表合计行的值。本期原表 {cols} 的合计格为空,其余各列已按明细求和核对一致:"
"不要与 {base} 相加",
"list_total_uncached": "原表合计行的值。本期原表未保存计算结果,合计值为空,未能核对:不要与 {base} 相加",
# 合计格写的是占位符(转成空值)时走到这里,与交叉表的 total_k_blank 对应
"list_total_blank": "原表合计行的值。本期原表合计格为空,未能核对:不要与 {base} 相加",
"list_total_tiling": "原表合计行的值,按原表保存的值存储。本期合计行之上没有明细,未能核对:不要与 {base} 相加",
"list_total_null_detail": "原表合计行的值,按原表保存的值存储。本期明细含空值,未能核对:不要与 {base} 相加",
"list_total_hidden": "原表合计行的值,按原表保存的值存储。本期存在隐藏行或使用了分类汇总函数,合计口径无法确定,"
"未能核对:不要与 {base} 相加",
"list_total_mixed": "原表合计行的值,按原表保存的值存储。本期未能核对:不要与 {base} 相加",
"hidden_excluded": "本表不含原表中被隐藏的行",
"hidden_included": "本表包含原表中被隐藏的行",
"ignored_columns": "原表另有未导入的列",
"rows_after_stop": "原表在空行之后另有未导入的文字行,内容见证据面板",
# 不写「不一致」:K / T 没通过的表不许出现「一致」二字,按子串判断时「不一致」也会撞上
"period_conflict_accepted": "本期统计期的写法在原表或文件名中有冲突,已由用户确认接受,详见导入清单",
"period_conflict_pending": "本期统计期的写法在原表或文件名中有冲突,尚未确认接受",
"period_human": "本期统计期为人工录入",
"outside_digits": "本期原表附有说明文字(可能涉及口径),内容见证据面板",
}
#: 列说明的片段模板
COLUMN_TEXT = {
"unit": "单位:{unit}",
"null_placeholder": "空值表示原表为{meaning}占位符,不代表零;求和时请同时统计非空个数",
"null_blank": "空值表示原表为空格,不代表零;求和时请同时统计非空个数",
"null_both": "空值表示原表为{meaning}占位符或空格,不代表零;求和时请同时统计非空个数",
"date": "格式 YYYY-MM-DD",
"date_year_from_period": "格式 YYYY-MM-DD,年份按统计期补全",
}
#: 「逐日 / 逐行」「个别日期 / 个别行」:粒度只有一列日期时按日说,否则按行说
_PER = {True: ("逐日", "日期"), False: ("逐行", "行")}
_AND = " 与 "
_LIST = "、"
_MEANING_BOTH = "无数据或不适用"
#: 交叉表合计的单一原因 → 模板键;列表合计同理。列表的 blank:合计格写了占位符(非空格,转成空值,照样生成
#: 合计格);tiling:合计行之上没有明细行。unrecognized 只出现在公式引用(G)上
_K_REASON = {r: f"total_k_{r}" for r in ("uncached", "blank", "null_detail", "tiling", "hidden")}
_T_REASON = {r: f"list_total_{r}" for r in ("uncached", "blank", "null_detail", "tiling", "hidden")}
#: 允许出现「一致」的模板:只在对应的 K / T 通过时才会选用
CONSISTENT_KEYS = frozenset({"total_k_passed", "total_k_passed_g_unverifiable", "list_total_passed",
"list_total_passed_partial"})
def _tok(kind: str, name: str) -> str:
"""结构化引用 {列:名} / {表:名} / {单位:名}。渲染时换成名字,检查时遮盖。"""
return "{" + kind + ":" + name + "}"
def _col(name: str) -> str:
return _tok("列", name)
def _tab(name: str) -> str:
return _tok("表", name)
def _join_and(names: list[str]) -> str:
"""a、b 与 c。"""
if len(names) <= 1:
return "".join(names)
return _LIST.join(names[:-1]) + _AND + names[-1]
# ---------------------------------------------------------------- 配方结构:每张表从哪来
@dataclass
class _Writers:
"""一张表在配方里的来历(哪些工作表、分段、块写进它、它是谁的合计另存表)。"""
sheets: list[SheetRecipe] = field(default_factory=list)
crosstabs: list[CrosstabBlock] = field(default_factory=list)
dims: list[DimensionSegment] = field(default_factory=list)
measures: list[MeasuresSegment] = field(default_factory=list)
#: 以本表为基表核对的 derived 段(本表是明细表)
verified_by: list[DerivedSegment] = field(default_factory=list)
#: 把合计另存进本表的 derived 段(本表是交叉表的表内合计表)
kept_from: list[DerivedSegment] = field(default_factory=list)
lists: list[ListBlock] = field(default_factory=list)
#: 把合计行另存进本表的列表块
list_totals: list[ListBlock] = field(default_factory=list)
def add_sheet(self, sheet: SheetRecipe) -> None:
if all(s is not sheet for s in self.sheets):
self.sheets.append(sheet)
def _writers(recipe: Recipe) -> dict[str, _Writers]:
out: dict[str, _Writers] = {}
def w(table: str, sheet: SheetRecipe) -> _Writers:
entry = out.setdefault(table, _Writers())
entry.add_sheet(sheet)
return entry
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, CrosstabBlock):
for seg in block.segments:
if isinstance(seg, MeasuresSegment):
e = w(seg.table, sheet)
e.measures.append(seg)
if block not in e.crosstabs:
e.crosstabs.append(block)
elif isinstance(seg, DimensionSegment):
e = w(seg.table, sheet)
e.dims.append(seg)
if block not in e.crosstabs:
e.crosstabs.append(block)
elif isinstance(seg, DerivedSegment):
w(seg.verify.against_table, sheet).verified_by.append(seg)
if seg.keep_as is not None:
e = w(seg.keep_as.table, sheet)
e.kept_from.append(seg)
if block not in e.crosstabs:
e.crosstabs.append(block)
else:
w(block.table, sheet).lists.append(block)
total = block.rows.total_row
if total is not None and total.keep_as:
w(total.keep_as, sheet).list_totals.append(block)
return out
def _actual_sheet(sheet: SheetRecipe, extraction: Extraction) -> str:
return extraction.sheets.matched.get(sheet.id) or sheet.match.name
def _lineage_spans(extraction: Extraction, table: str, sheet: str) -> list[tuple[int, int]]:
"""本表的值在这张工作表上占的行区间(来自溯源段)。判断隐藏行、停止后的行属于哪张表用。"""
spans: list[tuple[int, int]] = []
for runs in (extraction.lineage.get(table) or {}).values():
for run in runs:
try:
run_sheet, cell, count, direction = run[1], run[2], int(run[3]), run[4]
except (IndexError, TypeError, ValueError):
continue
parsed = split_coord(cell)
if run_sheet != sheet or parsed is None:
continue
row = parsed[2]
spans.append((row, row + count - 1) if direction == "down" else (row, row))
return spans
# ---------------------------------------------------------------- 表说明的各个片段
@dataclass
class _Ctx:
recipe: Recipe
extraction: Extraction
checks: dict[str, CheckResult]
accepted: set[str]
tables: dict[str, list[ColumnOut]]
grains: dict[str, list[str]]
dates: dict[str, set[str]]
writers: dict[str, _Writers]
problems: list[str]
plan: Any
def check(self, table: str, cid: str | None, what: str) -> CheckResult | None:
c = self.checks.get(cid or "")
if c is None:
self.problems.append(f"表「{table}」:找不到{what}的核对结果,无法生成说明")
return c
def _blocking(c: CheckResult) -> bool:
"""不可接受的结构类失败(K / G / T 不一致、核对 SQL 出错、落库不对):试运行已经因此拒收、也提交不了。
这时不写这一段说明、也不记 problems:problems 会被当成「配方本身不合法 / 说明生成失败」显示,
把用户引去改配方,而真正的问题是那条核对。problems 只留给真正缺模板的情况。
不写比写错安全:缺了这一句,说明里就不会有「一致」。
"""
return c.status == "mismatch" and c.category == "structure" and not c.acceptable
def _per_day(ctx: _Ctx, table: str) -> bool:
grain = ctx.grains.get(table, [])
return len(grain) == 1 and grain[0] in ctx.dates.get(table, set())
def _grain_fragment(grain: list[str]) -> str | None:
cols = [_col(g) for g in grain]
if not cols:
return None
if len(cols) == 1:
return NOTE_TEXT["grain_1"].format(a=cols[0])
if len(cols) == 2:
return NOTE_TEXT["grain_2"].format(a=cols[0], b=cols[1])
return NOTE_TEXT["grain_n"].format(a=_LIST.join(cols[:-1]), b=cols[-1])
def _sum_eq_fragment(ctx: _Ctx, table: str, rel: SumEq) -> str | None:
c = ctx.check(table, rel.id, f"关系「{rel.id}」")
if c is None or _blocking(c):
# 核对 SQL 出错时 status 也是 mismatch:不能当成「关系不成立」去写
return None
per, unit = _PER[_per_day(ctx, table)]
total = _col(rel.total)
if c.status == "passed":
return NOTE_TEXT["sum_eq_passed"].format(total=total, parts=_join_and([_col(p) for p in rel.parts]), per=per)
parts = _LIST.join(_col(p) for p in rel.parts)
if c.status == "mismatch":
key = "sum_eq_mismatch_accepted" if rel.id in ctx.accepted else "sum_eq_mismatch_pending"
return NOTE_TEXT[key].format(total=total, parts=parts, unit=unit)
if c.status == "unverifiable":
key = "sum_eq_no_rows" if not c.checked and not c.unverifiable else "sum_eq_nulls"
return NOTE_TEXT[key].format(total=total, parts=parts, unit=unit)
ctx.problems.append(f"表「{table}」:关系「{rel.id}」的核对状态「{c.status}」没有对应的说明")
return None
def _not_comparable_fragment(ctx: _Ctx, table: str, rel: NotComparable) -> str | None:
c = ctx.check(table, rel.id, f"关系「{rel.id}」")
if c is None or _blocking(c):
return None
if c.status != "info":
ctx.problems.append(f"表「{table}」:关系「{rel.id}」的核对没有完成,无法生成说明")
return None
other = rel.b.table if table == rel.a.table else rel.a.table
if c.failed <= 0:
# 本期全部相等(或没有可比的分组):不写「不等于」
return NOTE_TEXT["not_comparable_equal"].format(other=_tab(other))
dims = [g for g in ctx.grains.get(rel.a.table, []) if g != rel.by]
if dims:
return NOTE_TEXT["not_comparable_unequal"].format(
other=_tab(other), dims=_LIST.join(_col(d) for d in dims), value=_col(rel.b.value))
return NOTE_TEXT["not_comparable_unequal_flat"].format(
other=_tab(other), a_value=_col(rel.a.value), by=_col(rel.by), value=_col(rel.b.value))
def _dim_fragments(w: _Writers) -> list[str]:
out: list[str] = []
for seg in w.dims:
if seg.dim.parser != "hour_range":
continue
roles = {role: name for name, role in seg.dim.derive.items()}
if "start" in roles and "end" in roles:
text = NOTE_TEXT["hour_range"].format(dim=_col(seg.dim.name), start=_col(roles["start"]),
end=_col(roles["end"]))
else:
text = NOTE_TEXT["hour_range_plain"].format(dim=_col(seg.dim.name))
if text not in out:
out.append(text)
for seg in w.dims:
for name in sorted(seg.const):
text = NOTE_TEXT["const"].format(col=_col(name))
if text not in out:
out.append(text)
return out
def _base_total_fragments(w: _Writers) -> list[str]:
"""明细表上的提示:原表合计另存在哪、或者没导入。"""
out: list[str] = []
kept = [s.keep_as.table for s in w.verified_by if s.keep_as is not None]
kept += [b.rows.total_row.keep_as for b in w.lists if b.rows.total_row is not None and b.rows.total_row.keep_as]
for t in kept:
text = NOTE_TEXT["total_kept"].format(table=_tab(t))
if text not in out:
out.append(text)
dropped = any(s.keep_as is None for s in w.verified_by) or any(
b.rows.total_row is not None and not b.rows.total_row.keep_as for b in w.lists)
if dropped:
out.append(NOTE_TEXT["total_not_kept"])
return out
def _total_key(checks: list[CheckResult], by_reason: dict[str, str], mixed: str, *,
g_checks: list[CheckResult] | tuple[CheckResult, ...] = (), passed: str,
passed_g: str | None = None) -> str | None:
"""表内合计表按「状态 × 原因」选模板。返回 None 表示没有对应模板(调用方记 problems)。"""
statuses = {c.status for c in checks} | {c.status for c in g_checks}
if not checks or "mismatch" in statuses or statuses - {"passed", "unverifiable"}:
return None
if all(c.status == "passed" for c in checks):
if any(g.status == "unverifiable" for g in g_checks):
return passed_g
return passed
reasons = {r for c in checks if c.status == "unverifiable" for r, n in c.reasons.items() if n}
some_passed = any(c.status == "passed" for c in checks)
if len(reasons) == 1 and not some_passed:
return by_reason.get(next(iter(reasons)))
# 几种原因,或者一部分核对通过、一部分没有:用最弱的说法,不挑其中一种来写
return mixed if reasons else None
def _kept_total_fragments(ctx: _Ctx, table: str, w: _Writers) -> list[str]:
out: list[str] = []
if w.kept_from:
# 一张合计表可以由几个 derived 段写入(日间、夜间各有合计行):所有段的 K、G 一起选一条模板
ks = [ctx.check(table, ctx.plan.k.get(s.id), f"分段「{s.id}」的合计") for s in w.kept_from]
gs = [ctx.check(table, ctx.plan.g[s.id], f"分段「{s.id}」的公式引用") for s in w.kept_from if s.id in ctx.plan.g]
if None in ks or None in gs:
return out
if not any(_blocking(c) for c in ks + gs):
key = _total_key(ks, _K_REASON, "total_k_mixed", g_checks=gs, passed="total_k_passed",
passed_g="total_k_passed_g_unverifiable")
if key is None:
ctx.problems.append(f"表「{table}」:合计核对 {_ids(ks + gs)} 的结果没有对应的说明")
return out
out.append(NOTE_TEXT[key])
for seg in w.kept_from:
text = NOTE_TEXT["total_overlap"].format(dim=_col(seg.keep_as.dim), base=_tab(seg.verify.against_table))
if text not in out:
out.append(text)
for block in w.list_totals:
t = ctx.check(table, ctx.plan.t.get(block.id), f"块「{block.id}」的合计行")
if t is None or _blocking(t):
continue
key = _total_key([t], _T_REASON, "list_total_mixed", passed="list_total_passed")
if key is None:
ctx.problems.append(f"表「{table}」:合计核对 {t.id} 的结果没有对应的说明")
continue
blank_cols = _uncovered_total_columns(ctx, block) if key == "list_total_passed" else []
if blank_cols:
text = NOTE_TEXT["list_total_passed_partial"].format(
cols=_LIST.join(_col(c) for c in blank_cols), base=_tab(block.table))
else:
text = NOTE_TEXT[key].format(base=_tab(block.table))
if text not in out:
out.append(text)
return out
def _uncovered_total_columns(ctx: _Ctx, block: ListBlock) -> list[str]:
"""列表合计行里没有生成合计格的数字列(原表合计格是空的,P2-SPEC 4.4),按配方列序。
这些列在合计表里存的是空值、T 也没核对过。按合计格算而不按库里的空值数算:4 参数调用(不带 null_counts)
时照样成立。
"""
covered = {it.base_value for it in ctx.plan.items.get(block.id, [])}
return [c.name for c in block.columns if c.type in ("INTEGER", "REAL") and c.name not in covered]
def _ids(checks: list[CheckResult]) -> str:
return "、".join(c.id for c in checks)
def _hidden_fragments(ctx: _Ctx, table: str, w: _Writers) -> list[str]:
excluded = included = False
for sheet in w.sheets:
actual = _actual_sheet(sheet, ctx.extraction)
info = ctx.extraction.hidden.get(actual) or {}
rows = [int(r) for r in info.get("rows") or []]
if not rows:
continue
policy = info.get("policy_rows") or sheet.hidden.rows
spans = _lineage_spans(ctx.extraction, table, actual)
if policy == "exclude":
# 排除的行不在溯源里:落在本表占的行范围之内就算本表排除了它们
lo, hi = (min(a for a, _ in spans), max(b for _, b in spans)) if spans else (None, None)
excluded |= not spans or any(lo <= r <= hi for r in rows)
elif policy == "include":
included |= not spans or any(a <= r <= b for a, b in spans for r in rows)
out = []
if excluded:
out.append(NOTE_TEXT["hidden_excluded"])
if included:
out.append(NOTE_TEXT["hidden_included"])
return out
def _rows_after_stop_tables(ctx: _Ctx) -> set[str]:
"""rows_after_stop 问题落在哪张表上:同一工作表上被空行停下的列表块;不止一个时取紧挨在这些行之上的那个。"""
out: set[str] = set()
for p in ctx.extraction.problems:
if p.code != "rows_after_stop" or not p.cells:
continue
parsed = split_coord(p.cells[0])
if parsed is None or parsed[0] is None:
continue
sheet_name, first_row = parsed[0], parsed[2]
blocks = [b for s in ctx.recipe.sheets if _actual_sheet(s, ctx.extraction) == sheet_name
for b in s.blocks if isinstance(b, ListBlock) and b.rows.blank_rows == "stop"]
if len(blocks) > 1:
above = [(max(b for _, b in spans), blk) for blk in blocks
if (spans := _lineage_spans(ctx.extraction, blk.table, sheet_name))
and max(b for _, b in spans) < first_row]
if above:
blocks = [max(above, key=lambda x: x[0])[1]]
out.update(b.table for b in blocks)
return out
def _period_fragments(ctx: _Ctx, w: _Writers) -> list[str]:
if not any(s.context for s in w.sheets):
return []
out: list[str] = []
# 按传进来的核对结果判(run_checks 把 C1、C2 原样排在最前),与试运行、提交时存下的那份一致
conflicts = [c for c in ctx.checks.values()
if c.kind in ("context_agree", "filename_period") and c.status == "mismatch"]
if conflicts:
accepted = all(c.id in ctx.accepted for c in conflicts)
out.append(NOTE_TEXT["period_conflict_accepted" if accepted else "period_conflict_pending"])
if ctx.extraction.period is not None and ctx.extraction.period.source == "human":
out.append(NOTE_TEXT["period_human"])
return out
def _table_template(ctx: _Ctx, table: str, after_stop: set[str]) -> str:
w = ctx.writers.get(table, _Writers())
spec = next((t for t in ctx.recipe.tables if t.name == table), None)
frags: list[str | None] = [_grain_fragment(ctx.grains.get(table, []))]
for rel in ctx.recipe.relations:
if isinstance(rel, SumEq) and rel.table == table:
frags.append(_sum_eq_fragment(ctx, table, rel))
for rel in ctx.recipe.relations:
if isinstance(rel, NotComparable) and table in (rel.a.table, rel.b.table):
frags.append(_not_comparable_fragment(ctx, table, rel))
frags += _dim_fragments(w)
frags += _base_total_fragments(w)
frags += _kept_total_fragments(ctx, table, w)
frags += _hidden_fragments(ctx, table, w)
if any(b.extra_columns == "ignore" and ctx.extraction.ignored_columns.get(b.id) for b in w.lists):
frags.append(NOTE_TEXT["ignored_columns"])
if table in after_stop:
frags.append(NOTE_TEXT["rows_after_stop"])
frags += _period_fragments(ctx, w)
sheets = {_actual_sheet(s, ctx.extraction) for s in w.sheets}
if any(o.kind == "text_digits" and o.sheet in sheets for o in ctx.extraction.outside_text):
frags.append(NOTE_TEXT["outside_digits"])
if spec is not None and spec.note.strip():
frags.append(spec.note.strip())
return "。".join(f.strip().rstrip("。") for f in frags if f and f.strip().rstrip("。"))
# ---------------------------------------------------------------- 列说明
def _occurred(placeholders: list[Placeholder], extraction: Extraction) -> list[Placeholder]:
"""本期真的出现过的占位符(按 canon 比对原文)。"""
seen = {canon(k) for k, n in extraction.placeholders.items() if n}
return [p for p in placeholders if canon(p.text) in seen]
def _null_fragment(w: _Writers, extraction: Extraction) -> str | None:
blocks: list[Any] = [*w.crosstabs, *w.lists]
marks = [p for b in blocks for p in _occurred(list(b.values.placeholders), extraction)]
blank = any(b.values.blank == "null" for b in blocks)
meanings = {p.meaning for p in marks}
meaning = next(iter(meanings)) if len(meanings) == 1 else _MEANING_BOTH
if marks and blank:
return COLUMN_TEXT["null_both"].format(meaning=meaning)
if marks:
return COLUMN_TEXT["null_placeholder"].format(meaning=meaning)
if blank:
return COLUMN_TEXT["null_blank"]
return None
def _year_from_period(ctx: _Ctx, w: _Writers) -> bool:
"""轴的年份是不是从统计期补的:配了 year_from,而且本期表头不全是自带年份的日期格。"""
for block in w.crosstabs:
if block.axis.year_from is None:
continue
forms = [a.form for a in ctx.extraction.axes if a.block == block.id]
if not forms or any(f != "date" for f in forms):
return True
return False
def _column_templates(ctx: _Ctx, table: str, null_counts: Mapping[str, Mapping[str, int]] | None) -> dict[str, str]:
w = ctx.writers.get(table, _Writers())
grain = set(ctx.grains.get(table, []))
is_total = bool(w.kept_from or w.list_totals)
list_dates = {c.name for b in w.lists for c in b.columns if c.type == "DATE"}
counts = null_counts.get(table) if null_counts is not None else None
out: dict[str, str] = {}
for col in ctx.tables.get(table, []):
frags: list[str] = []
if col.role == "axis":
frags.append(COLUMN_TEXT["date_year_from_period" if _year_from_period(ctx, w) else "date"])
elif col.name in list_dates:
frags.append(COLUMN_TEXT["date"])
if col.unit:
frags.append(COLUMN_TEXT["unit"].format(unit=_tok("单位", col.unit)))
# 值列的空值说明:主键不会是空值;表内合计表的空值已由表说明(未能核对)交代
value_col = (col.role in ("measure", "value") and col.type in ("INTEGER", "REAL"))
has_nulls = counts is None or counts.get(col.name, 0) > 0
if value_col and not is_total and col.name not in grain and has_nulls:
if (text := _null_fragment(w, ctx.extraction)) is not None:
frags.append(text)
if frags:
out[col.name] = "。".join(frags)
return out
# ---------------------------------------------------------------- 入口
def build_notes(recipe: Recipe, extraction: Extraction, checks: list[CheckResult],
acceptances: list[Acceptance], *,
null_counts: Mapping[str, Mapping[str, int]] | None = None) -> SchemaNotes:
"""按核对结果生成全部表说明、列说明。problems 非空就不能保存(试运行判 recipe 类问题,提交拒绝)。
acceptances:写了理由接受的核对。试运行回执的预览传空列表(按「全部未接受」生成),提交时传实际的接受。
null_counts(recipe_checks.column_null_counts 的结果):给了就只对本期真有空值的值列写空值说明。
不给时只能按配方推断——Extraction.placeholders 是整份文件的计数、不分列,于是配了占位符且本期出现过、
或空格存空值的块里,每个值列都写(宁可多写一句「空值表示……」,不能漏写)。
**试运行预览和提交都应传 null_counts**:参考配方(D00)的 全日客流 列说明要是「单位:人次」
(P2-SPEC 7.4、9.7 第 2 步),不传就会多出空值那一句。
"""
tables, shape_problems = derive_tables(recipe)
problems = [f"配方推出的表结构有问题:{p.message}" for p in shape_problems]
ctx = _Ctx(
recipe=recipe, extraction=extraction, checks={c.id: c for c in checks},
accepted={a.check_id for a in acceptances}, tables=tables,
grains={t.name: list(t.grain) for t in recipe.tables}, dates=date_columns(recipe),
writers=_writers(recipe), problems=problems, plan=plan_checks(recipe, extraction),
)
# 只给真正建出来的表写说明(derive_tables 推出的表);声明了却没有分段写入的表不在库里,写了也落不了地
names = list(tables)
known = {"列": {c.name for cols in tables.values() for c in cols},
"表": set(names) | {t.name for t in recipe.tables}, "单位": set(UNITS)}
after_stop = _rows_after_stop_tables(ctx)
notes = SchemaNotes(problems=problems)
notes.kinds = {t.name: t.kind for t in recipe.tables if t.kind == "reported_total" and t.name in tables}
for table in names:
template = _table_template(ctx, table, after_stop)
columns = _column_templates(ctx, table, null_counts)
for p in prose_problems(template, known=known):
problems.append(f"表「{table}」的说明:{p}")
for col, tpl in columns.items():
for p in prose_problems(tpl, known=known):
problems.append(f"表「{table}」列「{col}」的说明:{p}")
if template:
notes.templates[table] = template
notes.templates.update({f"{table}.{col}": tpl for col, tpl in columns.items()})
notes.tables[table] = TableNote(comment=render_prose(template),
columns={col: render_prose(tpl) for col, tpl in columns.items()})
return notes
def apply_notes(schema_cache: dict[str, Any], notes: SchemaNotes) -> dict[str, Any]:
"""把说明写进表结构,返回新 dict(不改入参):表 comment、列 comment,顶层加 import_mode=recipe。
表或列在 schema_cache 里找不到(不该发生:说明和库出自同一份配方)时记进 notes.problems,不悄悄跳过。
表说明为空时保留原来的 comment。
"""
out = copy.deepcopy(schema_cache) if schema_cache else {}
tables = out.get("tables")
if not isinstance(tables, dict):
tables = {}
for name, kind in notes.kinds.items():
# 表的种类记进表结构:说明(不要相加)只有 db_schema 查单表时看得到,工具描述和写作目录靠它点名(AU-5)
if isinstance(tables.get(name), dict):
tables[name]["kind"] = kind
for name, note in notes.tables.items():
meta = tables.get(name)
if not isinstance(meta, dict):
notes.problems.append(f"表「{name}」不在表结构中,说明没有写入")
continue
if note.comment:
meta["comment"] = note.comment
columns = {c.get("name"): c for c in meta.get("columns") or [] if isinstance(c, dict)}
for col, text in note.columns.items():
target = columns.get(col)
if target is None:
notes.problems.append(f"表「{name}」没有列「{col}」,说明没有写入")
continue
target["comment"] = text
out["import_mode"] = "recipe"
return out