"""录制稳健性:章节结果落盘快照 + 可恢复(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"]