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