- 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%)
291 lines
12 KiB
Python
291 lines
12 KiB
Python
import pytest
|
||
from types import SimpleNamespace
|
||
from docx import Document
|
||
|
||
from genesis.data_models import (
|
||
ControllerInfo, EntityInfo, ExistingSystemInfo, ImpactReport, ParsedTemplate,
|
||
ChapterMarker, ServiceInfo,
|
||
)
|
||
from genesis.writer.orchestrator import WriteOrchestrator
|
||
from genesis.writer.models import ChapterContent
|
||
|
||
|
||
class FakeEngine:
|
||
def __init__(self):
|
||
self.last_variables = None
|
||
|
||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||
self.last_variables = variables
|
||
# 返回一个固定章节内容(title + 一个段落块)
|
||
return SimpleNamespace(
|
||
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "自动生成的内容"}]},
|
||
status="ok",
|
||
)
|
||
|
||
|
||
def _make_template(path):
|
||
doc = Document()
|
||
doc.add_paragraph("はじめに", style="Heading 1")
|
||
doc.add_paragraph("{{section:introduction}}")
|
||
doc.save(path)
|
||
|
||
|
||
def _ss(template_path):
|
||
parsed = ParsedTemplate(
|
||
file_name=template_path,
|
||
sections=[
|
||
ChapterMarker(type="heading", name="はじめに", level=1),
|
||
ChapterMarker(type="placeholder", name="section:introduction", level=0),
|
||
],
|
||
placeholders={},
|
||
styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
|
||
)
|
||
return SimpleNamespace(template=parsed)
|
||
|
||
|
||
def test_generate_produces_filled_docx(tmp_path):
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
orch = WriteOrchestrator()
|
||
contents = orch.generate(_ss(str(tpl)), str(out), samples_dir="nonexistent_dir_xyz", engine=FakeEngine())
|
||
assert isinstance(contents, list) and len(contents) == 1
|
||
# 输出 docx 含注入文本且无残留异常
|
||
loaded = Document(str(out))
|
||
joined = "\n".join(p.text for p in loaded.paragraphs)
|
||
assert "自动生成的内容" in joined
|
||
|
||
|
||
class AsyncFakeEngine:
|
||
async def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||
return SimpleNamespace(
|
||
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "异步引擎内容"}]},
|
||
status="ok",
|
||
)
|
||
|
||
|
||
def test_generate_with_async_engine_produces_filled_docx(tmp_path):
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
orch = WriteOrchestrator()
|
||
contents = orch.generate(
|
||
_ss(str(tpl)), str(out), samples_dir="nonexistent_dir_xyz", engine=AsyncFakeEngine()
|
||
)
|
||
assert isinstance(contents, list) and len(contents) == 1
|
||
loaded = Document(str(out))
|
||
joined = "\n".join(p.text for p in loaded.paragraphs)
|
||
assert "异步引擎内容" in joined
|
||
|
||
|
||
def test_generate_delegates_to_factory_when_engine_none(monkeypatch, tmp_path):
|
||
# 门禁真实路径:engine=None 时应委托 build_inference_engine() 构造真实引擎
|
||
monkeypatch.setattr("genesis.writer.orchestrator.build_inference_engine", lambda: FakeEngine())
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
contents = WriteOrchestrator().generate(
|
||
_ss(str(tpl)), str(out), samples_dir="nonexistent_dir_xyz", engine=None
|
||
)
|
||
assert len(contents) == 1
|
||
loaded = Document(str(out))
|
||
assert "自动生成的内容" in "\n".join(p.text for p in loaded.paragraphs)
|
||
|
||
|
||
def test_generate_warns_on_unanchored_heading(tmp_path, caplog):
|
||
# 防静默丢章:无 {{section:id}} 锚点的章 → 打 WARNING 并列出章名
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
doc = Document()
|
||
doc.add_paragraph("附録", style="Heading 1")
|
||
doc.save(str(tpl))
|
||
parsed = ParsedTemplate(
|
||
file_name=str(tpl),
|
||
sections=[ChapterMarker(type="heading", name="附録", level=1)],
|
||
placeholders={},
|
||
styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
|
||
)
|
||
orch = WriteOrchestrator()
|
||
with caplog.at_level("WARNING", logger="genesis.writer.orchestrator"):
|
||
orch.generate(
|
||
SimpleNamespace(template=parsed),
|
||
str(out),
|
||
samples_dir="nonexistent_dir_xyz",
|
||
engine=FakeEngine(),
|
||
template_path=str(tpl),
|
||
)
|
||
assert any("附録" in r.message for r in caplog.records)
|
||
|
||
|
||
# ---------- Impact Agent MVP:影响调查结果作为生成主上下文(2026-08-23) ----------
|
||
|
||
def _ss_with_existing(template_path):
|
||
from genesis.data_models import CellValue, ExcelTable, Provenance, SheetType
|
||
parsed = ParsedTemplate(
|
||
file_name=template_path,
|
||
sections=[
|
||
ChapterMarker(type="heading", name="はじめに", level=1),
|
||
ChapterMarker(type="placeholder", name="section:introduction", level=0),
|
||
],
|
||
placeholders={},
|
||
styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
|
||
)
|
||
existing = ExistingSystemInfo(
|
||
controller_layer=[ControllerInfo("OrderController", "OrderController", "OrderController.java",
|
||
"/trade/order", [], "trade-order/OrderController.java")],
|
||
service_layer=[],
|
||
entity_layer=[EntityInfo("OrderDO", "OrderDO", "OrderDO.java", "trade_order", ["id"], "trade-order/OrderDO.java")],
|
||
api_endpoints=[],
|
||
source_path="samples/existing-system",
|
||
)
|
||
headers = ["機能ID", "機能名", "変更区分", "既存対応"]
|
||
rows = [{
|
||
"機能ID": CellValue("F002", Provenance("f.xlsx", "機能一覧", 3, "A", "機能ID")),
|
||
"機能名": CellValue("订单状态查询扩展", Provenance("f.xlsx", "機能一覧", 3, "B", "機能名")),
|
||
"変更区分": CellValue("変更", Provenance("f.xlsx", "機能一覧", 3, "G", "変更区分")),
|
||
"既存対応": CellValue("OrderController", Provenance("f.xlsx", "機能一覧", 3, "H", "既存対応")),
|
||
}]
|
||
tables = [ExcelTable(name="機能一覧", detected_type=SheetType.FUNCTION, extraction_method="openpyxl",
|
||
headers=headers, rows=rows)]
|
||
return SimpleNamespace(template=parsed, existing_system=existing, tables=tables,
|
||
impact_report=None)
|
||
|
||
|
||
def test_generate_auto_runs_impact_when_existing_system(tmp_path):
|
||
"""门控:existing_system 非 None 且未显式传 impact_report → 自动跑影响调查,注入生成上下文。"""
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
engine = FakeEngine()
|
||
WriteOrchestrator().generate(
|
||
_ss_with_existing(str(tpl)), str(out),
|
||
samples_dir="nonexistent_dir_xyz", engine=engine,
|
||
)
|
||
impact = engine.last_variables["impact"]
|
||
assert "project_type=enhancement" in impact
|
||
assert "OrderController" in impact
|
||
|
||
|
||
def test_generate_without_existing_system_impact_empty(tmp_path):
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
engine = FakeEngine()
|
||
WriteOrchestrator().generate(
|
||
_ss(str(tpl)), str(out),
|
||
samples_dir="nonexistent_dir_xyz", engine=engine,
|
||
)
|
||
assert engine.last_variables["impact"] == ""
|
||
|
||
|
||
def test_generate_explicit_impact_report_used(tmp_path):
|
||
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType
|
||
tpl = tmp_path / "tpl.docx"
|
||
out = tmp_path / "out.docx"
|
||
_make_template(str(tpl))
|
||
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
|
||
deleted_elements=[], unchanged_elements=[], warnings=[])
|
||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 0})
|
||
engine = FakeEngine()
|
||
ss = _ss_with_existing(str(tpl))
|
||
WriteOrchestrator().generate(ss, str(out), samples_dir="nonexistent_dir_xyz",
|
||
engine=engine, impact_report=report)
|
||
assert engine.last_variables["impact"] != ""
|
||
# 显式传入时也回填 structured_source,便于 QA/日志读取
|
||
assert ss.impact_report is report
|
||
|
||
|
||
def test_generate_missing_template_path_raises(tmp_path):
|
||
parsed = ParsedTemplate(file_name=None, sections=[], placeholders={}, styles={"used": []})
|
||
with pytest.raises(ValueError, match="template_path"):
|
||
WriteOrchestrator().generate(
|
||
SimpleNamespace(template=parsed), str(tmp_path / "out.docx"),
|
||
samples_dir="nonexistent_dir_xyz", engine=FakeEngine(),
|
||
)
|
||
|
||
|
||
# ---------- 章间引用(design.md §6.9:后章 prompt 注入前章摘要) ----------
|
||
|
||
class RecordingEngine:
|
||
"""记录每次调用的 variables,并返回含表格的章节内容(供摘要提取)。"""
|
||
|
||
def __init__(self):
|
||
self.calls = []
|
||
|
||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||
self.calls.append(variables)
|
||
return SimpleNamespace(
|
||
data={"title": variables["title"], "blocks": [
|
||
{"type": "paragraph", "text": f"{variables['title']}の内容"},
|
||
{"type": "table", "headers": ["ID", "名称"], "rows": [["1", "a"]]},
|
||
]},
|
||
status="ok",
|
||
)
|
||
|
||
|
||
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 test_prior_chapter_summaries_injected_for_later_chapters(tmp_path):
|
||
tpl = tmp_path / "tpl2.docx"
|
||
out = tmp_path / "out2.docx"
|
||
_two_chapter_template(str(tpl))
|
||
parsed = ParsedTemplate(
|
||
file_name=str(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"]},
|
||
)
|
||
engine = RecordingEngine()
|
||
WriteOrchestrator().generate(
|
||
SimpleNamespace(template=parsed), str(out),
|
||
samples_dir="nonexistent_dir_xyz", engine=engine, template_path=str(tpl),
|
||
)
|
||
assert len(engine.calls) == 2
|
||
# 第1章无前章摘要
|
||
assert engine.calls[0]["prior_summaries"] == ""
|
||
# 第2章含第1章摘要(含前章标题与表结构)
|
||
second = engine.calls[1]["prior_summaries"]
|
||
assert "function_list" in second
|
||
assert "機能一覧" in second
|
||
assert "ID" in second and "名称" in second
|
||
|
||
|
||
def test_writer_prompt_template_exposes_prior_summaries():
|
||
from genesis.writer.writer_agent import WRITER_PROMPT_TEMPLATE
|
||
assert "{{prior_summaries}}" in WRITER_PROMPT_TEMPLATE
|
||
|
||
|
||
def test_template_with_cell_anchor_injects_chapter(tmp_path):
|
||
"""P2-1 端到端:锚点位于表格单元格时,章节内容注入该单元格而非被丢弃。"""
|
||
from genesis.parsers.word_template_parser import WordTemplateParser
|
||
|
||
doc = Document()
|
||
doc.add_paragraph("2. 機能一覧", style="Heading 1")
|
||
tbl = doc.add_table(rows=1, cols=1)
|
||
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:function_list}}"
|
||
tpl = tmp_path / "tpl_cell.docx"
|
||
doc.save(str(tpl))
|
||
|
||
parsed = WordTemplateParser().parse(str(tpl))
|
||
out = tmp_path / "out_cell.docx"
|
||
contents = WriteOrchestrator().generate(
|
||
SimpleNamespace(template=parsed), str(out),
|
||
samples_dir="nonexistent_dir_xyz", engine=RecordingEngine(),
|
||
template_path=str(tpl),
|
||
)
|
||
assert len(contents) == 1
|
||
loaded = Document(str(out))
|
||
cell_text = "\n".join(c.text for t in loaded.tables for r in t.rows for c in r.cells)
|
||
assert "機能一覧の内容" in cell_text
|