Files
2026Technology-Competition/tests/test_phase5_writer_agent.py
T
lhl 5091125fbc fix(writer): 无子节章剔除 LLM 自造 heading 块
- writer_agent: prompt【小节约束】明确空结构时不得输出 heading 块
- writer_agent: generate_chapter 程序化强制——template_marker.sub_headings
  为空的章,剔除生成结果中全部 type=heading 的内容块(LLM 遵循度不完美,
  以模板结构为准做确定性兜底)
- 真实试运行验证:自造与章同名 H2 清零、H2 总数 14→6(仅模板定义子节)、
  内容引用不受影响
2026-08-24 13:19:11 +08:00

290 lines
11 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.
import pytest
from types import SimpleNamespace
from genesis.writer.writer_agent import (
WriterAgent,
WRITER_PROMPT_TEMPLATE,
CHAPTER_OUTPUT_SCHEMA,
)
from genesis.writer.models import GenerationContext, ChapterSpec
from genesis.writer.writer_state import WriterState
from genesis.writer.exceptions import WriterGenerationError
class FakeEngine:
def __init__(self):
self.calls = 0
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
self.calls += 1
return SimpleNamespace(
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "ok"}]},
status="ok",
)
class FakePromptRegistry:
@staticmethod
def get_or_create(name, template):
return SimpleNamespace(name=name, version="1", template=template)
def _ctx(cid, title):
return GenerationContext(
chapter_id=cid,
title=title,
template_marker=ChapterSpec(chapter_id=cid, title=title, section_placeholder=None),
structured_source="source text",
write_rules=["W1"],
design_rules=["D1"],
template_styles={"Heading1"},
prior_state=None,
)
def test_generate_chapter_success():
agent = WriterAgent(
session_id="s",
engine=FakeEngine(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["db_design"]),
)
content = agent.generate_chapter(_ctx("db_design", "DB 設計"))
assert content.chapter_id == "db_design"
assert content.blocks and content.blocks[0].type == "paragraph"
def test_prompt_template_enforces_title_language():
# 语言约束:正文需与章节标题语言一致(模板为日文时输出日文)
assert "语言约束" in WRITER_PROMPT_TEMPLATE
assert "{{title}}" in WRITER_PROMPT_TEMPLATE
def test_prompt_template_has_impact_context_var():
# 影响调查结果作为生成主上下文:模板必须包含 impact 变量(无影响书时渲染为空串)
assert "影响调查上下文" in WRITER_PROMPT_TEMPLATE
assert "{{impact}}" in WRITER_PROMPT_TEMPLATE
def test_prompt_template_enforces_topic_and_chapter_data():
# 主题约束:正文必须围绕本章标题主题,依据章节数据,禁止套用系统整体架构
assert "主题约束" in WRITER_PROMPT_TEMPLATE
assert "参考资料(本章对应数据)" in WRITER_PROMPT_TEMPLATE
assert "{{data}}" in WRITER_PROMPT_TEMPLATE
# 旧的全量 source 变量不再使用
assert "{{source}}" not in WRITER_PROMPT_TEMPLATE
def test_prompt_template_has_sub_headings_var():
# 子节结构(design.md §6.5:H2/H3 归入本章):模板必须提供小节变量并指导按小节组织
assert "{{sub_headings}}" in WRITER_PROMPT_TEMPLATE
assert "小节" in WRITER_PROMPT_TEMPLATE
def test_prompt_template_forbids_heading_blocks_when_no_sub_headings():
# 无子节的章:LLM 不得自造 heading 块(真实试运行暴露:帳票一覧/バッチ一覧
# 章内出现与章同名的自造 H2
assert "不得输出任何 type=heading 的内容块" in WRITER_PROMPT_TEMPLATE
class HeadingEngine(FakeEngine):
"""返回含 heading 块的输出(模拟 LLM 自造标题行为)。"""
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
return SimpleNamespace(
data={
"title": variables["title"],
"blocks": [
{"type": "heading", "level": 2, "text": variables["title"]},
{"type": "paragraph", "text": "正文内容"},
],
},
status="ok",
)
def test_generate_chapter_drops_headings_when_no_sub_headings():
"""无子节结构的章:LLM 自造的 heading 块被程序化剔除(模板结构为准)。"""
agent = WriterAgent(
session_id="s", engine=HeadingEngine(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["batch_list"]),
)
ctx = _ctx("batch_list", "7. バッチ一覧")
content = agent.generate_chapter(ctx)
assert all(b.type != "heading" for b in content.blocks)
assert [b.text for b in content.blocks] == ["正文内容"]
def test_generate_chapter_keeps_headings_when_sub_headings_exist():
"""有子节结构的章:heading 块保留(按小节组织的内容)。"""
agent = WriterAgent(
session_id="s", engine=HeadingEngine(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["function_list"]),
)
ctx = _ctx("function_list", "2. 機能一覧")
ctx.template_marker.sub_headings = ["2.1 機能一覧表"]
content = agent.generate_chapter(ctx)
assert any(b.type == "heading" for b in content.blocks)
class RecordingEngine(FakeEngine):
"""记录 variables 以断言 prompt 渲染输入。"""
def __init__(self):
super().__init__()
self.last_vars = None
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
self.last_vars = dict(variables)
return super().chat_structured(
session_id=session_id, prompt=prompt,
variables=variables, schema=schema, retry_count=retry_count,
)
class RealStylePromptRegistry:
"""模拟真实 PromptRegistry:无 get_or_createget() 返回模板字符串(非 Prompt)。"""
def __init__(self):
self._tpl = {}
def register(self, name, version, template):
self._tpl[(name, version)] = template
def get(self, name, version=None):
return self._tpl[(name, version)]
class RenderingEngine:
"""模拟真实引擎渲染行为:仅当收到 Prompt 对象时才用 jinja2 渲染(engine._render_prompt 契约)。"""
def __init__(self):
self.last_rendered = None
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
from jinja2 import Template
from genesis.inference.types import Prompt as P
tpl = prompt.template if isinstance(prompt, P) else prompt # str 原样返回 → 不渲染
self.last_rendered = Template(tpl).render(**variables) if isinstance(prompt, P) else tpl
return SimpleNamespace(
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "ok"}]},
status="ok",
)
def test_prompt_is_rendered_with_real_style_registry():
"""回归:真实 PromptRegistry.get 返回模板字符串 → 引擎按 str 原样发送,
{{data}}/{{title}} 等占位符从未被替换(真实试运行暴露)。
WriterAgent 必须保证交给引擎的是可渲染的 Prompt 对象。"""
agent = WriterAgent(
session_id="s",
engine=RenderingEngine(),
prompt_registry=RealStylePromptRegistry(),
state=WriterState(["db_design"]),
)
ctx = _ctx("db_design", "DB 設計")
agent.generate_chapter(ctx)
assert "{{" not in agent.engine.last_rendered # 无残留占位符
assert "DB 設計" in agent.engine.last_rendered # title 已渲染
assert "W1" in agent.engine.last_rendered # write_rules 已渲染
def test_generate_chapter_passes_chapter_scoped_data_var():
from genesis.data_models import (
CellValue, ExcelTable, ParsedTemplate, ChapterMarker,
Provenance, SheetType, StructuredSource,
)
def cell(v):
return CellValue(value=v, provenance=Provenance("f.xlsx", "s", 1, "A", "列"))
src = StructuredSource(
tables=[
ExcelTable("DB定義", SheetType.DATABASE, "openpyxl",
["テーブルID", "テーブル名"],
[{"テーブルID": cell("T001"), "テーブル名": cell("trade_order")}]),
ExcelTable("機能一覧", SheetType.FUNCTION, "openpyxl",
["機能ID", "機能名"],
[{"機能ID": cell("F001"), "機能名": cell("止损风控")}]),
],
template=ParsedTemplate("t.docx",
[ChapterMarker(type="heading", name="x", level=1)],
{}, {"used": []}),
rule_docs=[], image_analyses=[], existing_system=None, comments=[],
)
ctx = GenerationContext(
chapter_id="db_design", title="DB 設計",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 設計", section_placeholder=None),
structured_source=src, write_rules=["W1"], design_rules=["D1"],
template_styles={"Heading1"},
)
engine = RecordingEngine()
agent = WriterAgent(session_id="s", engine=engine,
prompt_registry=FakePromptRegistry(),
state=WriterState(["db_design"]))
agent.generate_chapter(ctx)
data = engine.last_vars["data"]
assert "T001" in data and "trade_order" in data # 本章对应数据
assert "止损风控" not in data # 其他章数据被排除
def test_generate_chapter_retries():
class Boom(FakeEngine):
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
raise RuntimeError("boom")
agent = WriterAgent(
session_id="s",
engine=Boom(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["db_design"]),
max_retries=2,
)
with pytest.raises(WriterGenerationError):
agent.generate_chapter(_ctx("db_design", "DB 設計"))
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": "ok"}]},
status="ok",
)
def test_generate_chapter_with_async_engine():
agent = WriterAgent(
session_id="s",
engine=AsyncFakeEngine(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["db_design"]),
)
content = agent.generate_chapter(_ctx("db_design", "DB 設計"))
assert content.chapter_id == "db_design"
assert content.blocks and content.blocks[0].type == "paragraph"
def test_generate_chapter_propagates_engine_error_detail():
class FailingEngine:
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
return SimpleNamespace(
data={},
status="failed",
error="LLM HTTP 401: invalid key",
error_code="LLM_NETWORK_ERROR",
)
agent = WriterAgent(
session_id="s",
engine=FailingEngine(),
prompt_registry=FakePromptRegistry(),
state=WriterState(["db_design"]),
max_retries=1,
)
with pytest.raises(WriterGenerationError) as exc:
agent.generate_chapter(_ctx("db_design", "DB 設計"))
assert "LLM HTTP 401" in str(exc.value)
assert "LLM_NETWORK_ERROR" in str(exc.value)