Files
AgentLab/backend/tests/fixtures/xlsx/mutate.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

142 lines
5.8 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.
"""9.4 的数据变异:在 XML 层改 D00 的字节(flow_workbook(2026-08-01, 31)),保留缓存值。
做法同 xlsx-lab 评审时的 mutate.py,但输入是合成夹具。openpyxl 写不出来的形态(去掉公式留缓存、陈旧的
<dimension>、右侧多出的格子不进 <dimension>)只能这么造。每种变异只改预定的格子,其余字节级不动
(test_fixtures_xlsx 逐格比对)。
"""
from __future__ import annotations
import re
from . import patch_parts, sheet_part
from .flow import SHEET
#: 代号 → (改法, 期望的结果, 期望出现的问题 code 或核对 id)。照 P2-SPEC 9.4 抄录,WP-7 验收时逐项比
MUTATIONS: dict[str, tuple[str, str, tuple[str, ...]]] = {
"P1": ("第 28–30 行去掉公式、保留缓存值", "passed", ()),
"P1b": ("第 28–30 行保留公式、去掉缓存值", "needs_decision", ("K1",)),
"P2": ("D15 改成空", "rejected", ("value_blank",)),
"P6": ("R4 改成与 Q4 相同的日期", "rejected", ("axis_duplicate",)),
"P9": ("C28 改成 =SUM(C22:C24),缓存值自洽", "rejected", ("K1", "G1")),
"P20": ("第 32 行加「备用分区(人次)」和两个数,<dimension> 不更新", "rejected", ("row_unclaimed",)),
"P23": ("<dimension> 截到第 27 行", "passed", ()),
"P24": ("<dimension> 截到倒数第二个日期列", "passed", ()),
"P25": ("右侧多一列:表头「合计」、各数据行 999,<dimension> 不更新", "rejected", ("axis_extra_cells",)),
"P26": ("删掉最后一个日期列的所有格,<dimension> 跟着改,B2 仍写到月末", "rejected", ("axis_coverage",)),
# 9.4「外加」:同一分段里一行写 8-9、另一行写 8-9(全角横线)
"PKDUP": ("B12(9-10)改成「8-9」,与 B11 的「8-9」规范写法相同", "rejected", ("pk_duplicate", "label_duplicate")),
}
#: D00 的数据行(指标、日间、夜间、合计)
DATA_ROWS = (5, 6, 7, *range(10, 21), *range(22, 31))
_TOTAL_ROWS = "(?:28|29|30)"
def _cell_span(xml: str, ref: str) -> re.Match[str]:
m = re.search(rf'<c r="{ref}"(?:[^>]*/>|[^>]*>.*?</c>)', xml)
if m is None:
raise AssertionError(f"没有格子 {ref}")
return m
def _replace_cell(xml: str, ref: str, new: str) -> str:
m = _cell_span(xml, ref)
return xml[:m.start()] + new + xml[m.end():]
def _number(xml: str, ref: str) -> float:
m = re.search(r"<v>([^<]*)</v>", _cell_span(xml, ref).group(0))
if m is None:
raise AssertionError(f"格子 {ref} 没有值")
return float(m.group(1))
def _text(xml: str, ref: str) -> str:
m = re.search(r"<t[^>]*>([^<]*)</t>", _cell_span(xml, ref).group(0))
if m is None:
raise AssertionError(f"格子 {ref} 不是文字")
return m.group(1)
def _inline(ref: str, text: str) -> str:
return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'
def _num(ref: str, v: float | int) -> str:
return f'<c r="{ref}" t="n"><v>{v}</v></c>'
def _append_to_row(xml: str, row: int, cell: str) -> str:
xml, n = re.subn(rf'(<row r="{row}"[^>]*>.*?)(</row>)', lambda m: m.group(1) + cell + m.group(2), xml,
count=1, flags=re.S)
if n != 1:
raise AssertionError(f"没有第 {row} 行")
return xml
def _dimension(xml: str) -> tuple[str, int, str, int]:
m = re.search(r'<dimension ref="([A-Z]+)(\d+):([A-Z]+)(\d+)"/>', xml)
if m is None:
raise AssertionError("没有 <dimension>")
return m.group(1), int(m.group(2)), m.group(3), int(m.group(4))
def _set_dimension(xml: str, ref: str) -> str:
return re.sub(r'<dimension ref="[^"]*"/>', f'<dimension ref="{ref}"/>', xml, count=1)
def _col_shift(letters: str, delta: int) -> str:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
n += delta
out = ""
while n:
n, rem = divmod(n - 1, 26)
out = chr(65 + rem) + out
return out
def _mutate_sheet(xml: str, case: str) -> str:
c1, r1, last, r2 = _dimension(xml)
if (c1, r1, r2) != ("B", 2, 30):
raise ValueError(f"mutate 只接受 D00 的版式(<dimension> 是 B2:…30),这份是 {c1}{r1}:{last}{r2}")
if case == "P1":
return re.sub(rf'(<c r="[A-Z]+{_TOTAL_ROWS}"[^>]*>)<f>[^<]*</f>', r"\1", xml)
if case == "P1b":
return re.sub(rf'(<c r="[A-Z]+{_TOTAL_ROWS}"[^>]*><f>[^<]*</f>)(?:<v>[^<]*</v>|<v\s*/>)', r"\1", xml)
if case == "P2":
return _replace_cell(xml, "D15", "")
if case == "P6":
return _replace_cell(xml, "R4", _inline("R4", _text(xml, "Q4")))
if case == "P9":
v = sum(_number(xml, f"C{r}") for r in (22, 23, 24))
return _replace_cell(xml, "C28", f'<c r="C28"><f>SUM(C22:C24)</f><v>{int(v)}</v></c>')
if case == "P20":
row = ('<row r="32">' + _inline("B32", "备用分区(人次)") + _num("C32", 12) + _num("D32", 15) + "</row>")
return xml.replace("</sheetData>", row + "</sheetData>", 1)
if case == "P23":
return _set_dimension(xml, f"B2:{last}27")
if case == "P24":
return _set_dimension(xml, f"B2:{_col_shift(last, -1)}30")
if case == "P25":
extra = _col_shift(last, 1)
xml = _append_to_row(xml, 4, _inline(f"{extra}4", "合计"))
for r in DATA_ROWS:
xml = _append_to_row(xml, r, _num(f"{extra}{r}", 999))
return xml
if case == "P26":
xml = re.sub(rf'<c r="{last}\d+"(?:[^>]*/>|[^>]*>.*?</c>)', "", xml)
return _set_dimension(xml, f"B2:{_col_shift(last, -1)}30")
if case == "PKDUP":
return _replace_cell(xml, "B12", _inline("B12", "8-9"))
raise ValueError(f"不认识的变异:{case}")
def mutate(xlsx: bytes, case: str) -> bytes:
"""在 XML 层改 D00 的字节。case 见 MUTATIONS。"""
if case not in MUTATIONS:
raise ValueError(f"不认识的变异:{case}")
part = sheet_part(xlsx, SHEET)
return patch_parts(xlsx, {part: lambda t: _mutate_sheet(t, case)})