mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:32:04 +00:00
2d5e50fa88
配方语言(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 起草的同意对话框
107 lines
5.1 KiB
Python
107 lines
5.1 KiB
Python
"""合成 xlsx 夹具(期 2,WP-1):全部在测试里用 openpyxl 现造,标签一律假名,数字随机或手写假数。
|
||
|
||
- flow.py:「结构仿照客流表」的交叉表(P2-SPEC 9.1),带 variant 参数造 9.2 的漂移用例;
|
||
- drift.py:9.2 的 31 例(30 例 + D28b)及各例的期望;
|
||
- lab.py:9.3 的实验室难缠用例(结构照 xlsx-lab/case*.py 重写,名字全换成假名);
|
||
- mutate.py:9.4 的数据变异(在 XML 层改 D00 的字节,保留缓存值)。
|
||
|
||
**用户的真实 Excel 文件不得以任何形式进入这里。** openpyxl 写公式不带缓存值、默认写 fullCalcOnLoad,
|
||
「在 Excel 里打开并保存过」的文件由这里的小工具在压缩包层面补上。这些函数不写磁盘,只返回字节。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import re
|
||
import zipfile
|
||
from typing import Callable
|
||
from xml.etree import ElementTree as ET
|
||
|
||
_NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||
_NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||
_NS_PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||
|
||
|
||
#: 压缩包条目和文档属性里的时间一律写成这个:同样的参数造出同样的字节(原件哈希、构建 id 才可复现)
|
||
_FIXED_TIME = (2026, 1, 1, 0, 0, 0)
|
||
_STAMP = re.compile(r"(<dcterms:(created|modified)\b[^>]*>)[^<]*(</dcterms:\2>)")
|
||
|
||
|
||
def save(book) -> bytes:
|
||
"""openpyxl 工作簿 → 字节(不落盘)。openpyxl 存盘时把「修改时间」写成当前时刻,这里改成固定值。"""
|
||
buf = io.BytesIO()
|
||
book.save(buf)
|
||
src = zipfile.ZipFile(io.BytesIO(buf.getvalue()))
|
||
out = io.BytesIO()
|
||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
|
||
for info in src.infolist():
|
||
data = src.read(info.filename)
|
||
if info.filename == "docProps/core.xml":
|
||
data = _STAMP.sub(r"\g<1>2026-01-01T00:00:00Z\3", data.decode("utf-8")).encode("utf-8")
|
||
entry = zipfile.ZipInfo(info.filename, date_time=_FIXED_TIME)
|
||
entry.compress_type = zipfile.ZIP_DEFLATED
|
||
entry.external_attr = info.external_attr
|
||
dst.writestr(entry, data)
|
||
return out.getvalue()
|
||
|
||
|
||
def patch_parts(raw: bytes, edits: dict[str, Callable[[str], str]]) -> bytes:
|
||
"""改压缩包里的部件:{部件路径: 文本 → 文本}。其余部件原样拷贝(含压缩参数)。"""
|
||
src = zipfile.ZipFile(io.BytesIO(raw))
|
||
missing = set(edits) - set(src.namelist())
|
||
if missing:
|
||
raise KeyError(f"压缩包里没有这些部件:{sorted(missing)}")
|
||
out = io.BytesIO()
|
||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
|
||
for info in src.infolist():
|
||
data = src.read(info.filename)
|
||
if info.filename in edits:
|
||
data = edits[info.filename](data.decode("utf-8")).encode("utf-8")
|
||
dst.writestr(info, data)
|
||
return out.getvalue()
|
||
|
||
|
||
def read_part(raw: bytes, name: str) -> str:
|
||
return zipfile.ZipFile(io.BytesIO(raw)).read(name).decode("utf-8")
|
||
|
||
|
||
def sheet_part(raw: bytes, sheet: str) -> str:
|
||
"""工作表名 → 它在压缩包里的 XML 路径(按 workbook.xml 和关系文件配对,不靠 sheetN 的序号猜)。"""
|
||
z = zipfile.ZipFile(io.BytesIO(raw))
|
||
book = ET.fromstring(z.read("xl/workbook.xml"))
|
||
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
|
||
targets = {r.get("Id"): r.get("Target") for r in rels.iter(f"{{{_NS_PKG}}}Relationship")}
|
||
for el in book.iter(f"{{{_NS_MAIN}}}sheet"):
|
||
if el.get("name") == sheet:
|
||
target = targets[el.get(f"{{{_NS_REL}}}id")]
|
||
return target.lstrip("/") if target.startswith("/") else f"xl/{target}"
|
||
raise KeyError(f"没有工作表「{sheet}」")
|
||
|
||
|
||
def _cell_re(ref: str) -> re.Pattern[str]:
|
||
# openpyxl 写出的格:<c r="C28"><f>SUM(C22:C25)</f><v></v></c>(可能带 s=、t= 属性)
|
||
return re.compile(rf'<c r="{ref}"(?P<attrs>[^>]*)>(?P<f><f>[^<]*</f>)(?P<v><v\s*/>|<v>[^<]*</v>)?</c>')
|
||
|
||
|
||
def fill_cached(xml: str, values: dict[str, object]) -> str:
|
||
"""给公式格补上 <v> 缓存值(模拟 Excel 保存过)。每个坐标必须恰好命中一个公式格。"""
|
||
for ref, v in values.items():
|
||
text = repr(v) if isinstance(v, float) else str(v)
|
||
xml, n = _cell_re(ref).subn(lambda m: f'<c r="{ref}"{m.group("attrs")}>{m.group("f")}<v>{text}</v></c>', xml)
|
||
if n != 1:
|
||
raise AssertionError(f"公式格 {ref} 命中 {n} 次")
|
||
return xml
|
||
|
||
|
||
def excel_saved(raw: bytes, sheet: str, values: dict[str, object], *, full_calc: bool = False) -> bytes:
|
||
"""补缓存值,并按 full_calc 决定是否保留 workbook.xml 的 fullCalcOnLoad(openpyxl 默认写它)。"""
|
||
edits: dict[str, Callable[[str], str]] = {}
|
||
if values:
|
||
edits[sheet_part(raw, sheet)] = lambda t: fill_cached(t, values)
|
||
if not full_calc:
|
||
edits["xl/workbook.xml"] = lambda t: t.replace(' fullCalcOnLoad="1"', "")
|
||
return patch_parts(raw, edits) if edits else raw
|
||
|
||
|
||
def has_full_calc(raw: bytes) -> bool:
|
||
return 'fullCalcOnLoad="1"' in read_part(raw, "xl/workbook.xml")
|