Files
2026Technology-Competition/tests/test_writer_snapshot.py
T
lhl d9aa3a3325 fix(writer): 修复概要设计书输出塌缩与章间引用缺失,并补 parser 防灾
- Writer: 表格表头行/Table Grid 边框、列表 List Bullet/Number 样式、行内字符格式不再塌缩(外视 #6 反转)
- Writer: 打通章间引用(WriterState 摘要 → 后章 prompt prior_summaries)
- Writer: 删除 _chunk_source 死代码,章节数据经 DataGate 控 token 预算
- Writer: 章节结果落盘快照,命中即跳过 LLM(录制重拍可续跑,损坏快照自动忽略)
- Parser: 按 body 顺序遍历正文+单元格(含嵌套表、合并单元格去重),修复表格内锚点漏检导致的静默丢章
- Parser/服务层: .xls 显式拒绝(可操作提示),上传即校验扩展名,rules 对齐 docx-only
- 测试: 全量 680 通过,覆盖率 99.37%(红线 99%)
2026-09-15 21:30:55 +08:00

142 lines
5.5 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.
"""录制稳健性:章节结果落盘快照 + 可恢复(P0-b)。
真实 LLM 长耗时串行生成,录制重拍时不应从头重跑。本文件验证:
- 每章生成后写快照(JSON
- 重跑时命中快照的章节跳过 LLM(即使引擎不可用也能完成文档)
- 快照损坏时忽略并从头生成
- WriterState 可 JSON 往返
"""
import json
from types import SimpleNamespace
from docx import Document
from genesis.data_models import ChapterMarker, ParsedTemplate
from genesis.writer.orchestrator import WriteOrchestrator
from genesis.writer.writer_state import WriterState
from genesis.writer.models import ChapterContent, ContentBlock
class RecordingEngine:
def __init__(self):
self.calls = []
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
self.calls.append(variables["title"])
return SimpleNamespace(
data={"title": variables["title"], "blocks": [
{"type": "paragraph", "text": f"{variables['title']}の内容"},
{"type": "table", "headers": ["ID", "名称"], "rows": [["1", "a"]]},
]},
status="ok",
)
class ExplodingEngine:
"""若被调用即失败:用于证明快照命中时不再走 LLM。"""
def chat_structured(self, **kwargs):
raise AssertionError("不应调用 LLM(章节快照应命中)")
def _two_chapter_template(path):
doc = Document()
doc.add_paragraph("第1章 機能一覧", style="Heading 1")
doc.add_paragraph("{{section:function_list}}")
doc.add_paragraph("第2章 画面一覧", style="Heading 1")
doc.add_paragraph("{{section:screen_list}}")
doc.save(path)
def _parsed(tpl):
return ParsedTemplate(
file_name=tpl,
sections=[
ChapterMarker(type="heading", name="第1章 機能一覧", level=1),
ChapterMarker(type="placeholder", name="section:function_list", level=0),
ChapterMarker(type="heading", name="第2章 画面一覧", level=1),
ChapterMarker(type="placeholder", name="section:screen_list", level=0),
],
placeholders={}, styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
)
def _run(tpl, out, engine, snap):
return WriteOrchestrator().generate(
SimpleNamespace(template=_parsed(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=engine,
template_path=str(tpl), snapshot_path=str(snap),
)
def test_snapshot_written_with_all_chapters(tmp_path):
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
_run(tpl, out, RecordingEngine(), snap)
payload = json.loads(snap.read_text(encoding="utf-8"))
assert set(payload["chapters"]) == {"function_list", "screen_list"}
assert payload["chapters"]["function_list"]["blocks"][0]["type"] == "paragraph"
def test_resume_from_snapshot_skips_llm(tmp_path):
tpl, out1, snap = tmp_path / "t.docx", tmp_path / "o1.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
_run(tpl, out1, RecordingEngine(), snap)
# 第二次:引擎会爆炸,但快照命中 → 不调用 LLM,仍产出完整文档
out2 = tmp_path / "o2.docx"
_run(tpl, out2, ExplodingEngine(), snap)
joined = "\n".join(p.text for p in Document(str(out2)).paragraphs)
assert "第1章 機能一覧の内容" in joined
assert "第2章 画面一覧の内容" in joined
def test_corrupt_snapshot_ignored_and_regenerated(tmp_path):
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
snap.write_text("{ this is not json", encoding="utf-8")
engine = RecordingEngine()
_run(tpl, out, engine, snap)
assert len(engine.calls) == 2 # 损坏快照 → 从头生成
assert json.loads(snap.read_text(encoding="utf-8"))["chapters"] # 并重写有效快照
def test_snapshot_with_extra_chapter_ignored(tmp_path):
"""快照含当前模板没有的章节 id → 忽略该章,其余照常生成。"""
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
snap.write_text(json.dumps({
"order": ["function_list", "screen_list", "ghost"],
"chapters": {"ghost": {"chapter_id": "ghost", "version": 1,
"title": "幽灵章", "blocks": []}},
}, ensure_ascii=False), encoding="utf-8")
engine = RecordingEngine()
_run(tpl, out, engine, snap)
assert len(engine.calls) == 2 # 两章均重新生成,幽灵章被忽略
def test_generate_with_explicit_meta_skips_defaults(tmp_path):
"""显式传入 meta → 不再构造默认 meta(覆盖分支)。"""
tpl, out = tmp_path / "t.docx", tmp_path / "o.docx"
_two_chapter_template(str(tpl))
contents = WriteOrchestrator().generate(
SimpleNamespace(template=_parsed(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=RecordingEngine(),
template_path=str(tpl),
meta={"doc_title": "T", "version": "v9", "created_at": "2026-01-01"},
)
assert len(contents) == 2
def test_writer_state_dict_roundtrip():
st = WriterState(["a"])
st.record_success(ChapterContent(chapter_id="a", version=1, title="T", blocks=[
ContentBlock(block_id="1", type="table", headers=["H1", "H2"], rows=[["x", "y"]]),
]))
st2 = WriterState.from_dict(st.to_dict())
assert st2.summary_of("a") == st.summary_of("a")
assert st2.to_dict()["chapters"]["a"]["blocks"][0]["headers"] == ["H1", "H2"]