数式が入っていればExcelは正しい。そう見えがちだけど、納品直前に見るべきなのは数式の有無より、計算済みの値が一緒に入っているかだと思っている。
8月10日に公開されたOpenAIのModel MLの事例でも、調査・分析の結果を編集可能で追跡できるExcelに落とす流れが扱われていた。編集できることは助かる。ただ、.xlsxを成果物として渡すなら、数式だけ残して「開いた人のExcelが計算してくれる」状態はちょっと危ない。プレビュー、PDF変換、別の処理系は、保存済みの値を読むことがある。
今回は外部ライブラリを使わず、.xlsxの中身を読んで二つの兆候をCIで落とすスクリプトを書いた。
- ブックが手動計算モードになっている
- 数式セルに計算結果のキャッシュがない
.xlsxの数式と表示値は別に入っている
さっき検証用のxlsxをZIPとして開いて、ここを改めて確認した。B3に残るのは次のようなXMLだ。
<c r="B3"><f>B2*2</f></c>
<f> が数式で、計算済みの値は <v> に入る。値があればこうなる。
<c r="B2"><f>SUM(A1:A2)</f><v>3</v></c>
数式と表示値は、同じ場所に保存されているわけではない。
さらに xl/workbook.xml の <calcPr calcMode="manual"/> は、手動計算モードを表す。手動計算そのものを禁止する意図はない。重いモデルの試算ではむしろ使う。ただし、これと空の <v> が同居したファイルを、検査なしで配る理由はあまりない。
Pythonで再計算漏れを検出する
以下を check_xlsx_recalc.py として保存する。.xlsxはZIPなので、zipfileとxml.etree.ElementTreeだけで読める。シート名とセル番地まで出すようにした。
import argparse
import json
import posixpath
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
REL_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL_PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {"m": MAIN, "r": REL_DOC}
def scan_xlsx(path: Path) -> dict:
issues = []
with zipfile.ZipFile(path) as zf:
workbook = ET.fromstring(zf.read("xl/workbook.xml"))
calc = workbook.find("m:calcPr", NS)
calc_mode = calc.get("calcMode", "auto") if calc is not None else "auto"
if calc_mode == "manual":
issues.append({"kind": "manual_calc_mode"})
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
targets = {
rel.get("Id"): posixpath.normpath(
posixpath.join("xl", rel.get("Target"))
)
for rel in rels.findall(f"{{{REL_PKG}}}Relationship")
}
for sheet in workbook.findall("m:sheets/m:sheet", NS):
target = targets[sheet.get(f"{{{REL_DOC}}}id")]
root = ET.fromstring(zf.read(target))
for cell in root.findall(".//m:c", NS):
if cell.find("m:f", NS) is None:
continue
cached = cell.find("m:v", NS)
if cached is None or cached.text in (None, ""):
issues.append({
"kind": "formula_without_cached_value",
"sheet": sheet.get("name"),
"cell": cell.get("r"),
})
return {"file": str(path), "issues": issues}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("xlsx", type=Path)
args = parser.parse_args()
report = scan_xlsx(args.xlsx)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if report["issues"] else 0
if __name__ == "__main__":
raise SystemExit(main())
実行はこれだけ。
python check_xlsx_recalc.py deliverable.xlsx
検証用に、B2だけにキャッシュ値を持たせ、B3は数式だけにしたxlsxで実行した。出力は次のとおりだった。終了コードも 1 になるので、GitHub Actionsなどではそのまま失敗扱いにできる。
{
"file": "/tmp/forecast.xlsx",
"issues": [
{"kind": "manual_calc_mode"},
{
"kind": "formula_without_cached_value",
"sheet": "試算",
"cell": "B3"
}
]
}
これは正しさの証明ではない
この検査で分かるのは「再計算されていないかもしれない」というファイル構造上の兆候までだ。<v> があっても、古い入力値で計算したキャッシュかは区別できない。外部リンク、Power Query、マクロ、動的配列まで含めるなら、生成に使ったアプリで開き直して計算し、値のスナップショットを別途比較する工程が要る。
逆に、空文字を返す数式では空の <v> が仕様どおりのこともある。このスクリプトを即座にブロックにせず、最初は警告としてCIに入れ、対象ブックの例外を集める方が現実的だった。
おわりに
AIがExcelを作る場面で、数式が入っていることは途中経過にすぎない。渡すファイルの利用者が見るのは計算式より表示値だ。
だから成果物チェックでは、数式のレビューに加えて、再計算の状態を検査する。ZIPの中を数十行で見れば、手動計算とキャッシュ欠落は拾える。生成側のモデルを替える前に、この検査を1本置く方が、壊れた試算表を渡す確率は確実に下がる。