fix(writer): 章节级数据注入 + 修复 prompt 从未渲染的关键缺陷

- models: CHAPTER_SHEET_TYPES/CHAPTER_IMPACT_ELEMENT 章节→数据映射(design §6.8 ①)
- models: _format_chapter_data 按章定向渲染 ExcelTable 为 Markdown;GENERIC 自由記述作通用背景
- models: _format_impact 支持按 ElementType 过滤(章节级影响上下文)
- writer_agent: {{source}}(全量repr) → {{data}}(章节数据);新增【主题约束】
- writer_agent: 关键修复——真实 PromptRegistry.get 返回模板字符串,
  引擎对 str 不做变量渲染,LLM 实际收到的是 {{占位符}} 原文;
  _resolve_prompt 统一包装为 Prompt 对象保证渲染
- 真实试运行验证:13 章主题全部正确、引用影响调查数据、语言漂移消除
This commit is contained in:
lhl
2026-08-24 11:50:59 +08:00
parent 6372bb17b9
commit 80ccc324fc
6 changed files with 410 additions and 27 deletions
+40
View File
@@ -45,3 +45,43 @@ def test_build_contexts_carries_impact_report():
ctxs = build_contexts(ss, samples_dir="nonexistent_dir_xyz")
assert ctxs[0].impact_report is report
assert "project_type" in ctxs[0].to_vars()["impact"] or ctxs[0].to_vars()["impact"] != ""
def test_build_contexts_vars_data_scoped_per_chapter():
"""集成:多章模板 + FUNCTION/DB 表 → 各章 to_vars()['data'] 按章节定向。"""
from genesis.data_models import (
CellValue, ExcelTable, Provenance, SheetType, StructuredSource,
)
template = ParsedTemplate(
file_name="t.docx",
sections=[
ChapterMarker(type="heading", name="機能一覧", level=1),
ChapterMarker(type="placeholder", name="section:function_list", level=0),
ChapterMarker(type="heading", name="DB設計", level=1),
ChapterMarker(type="placeholder", name="section:db_design", level=0),
],
placeholders={},
styles={"used": ["Heading1"]},
)
def cell(v):
return CellValue(value=v, provenance=Provenance("f.xlsx", "s", 1, "A", ""))
ss = StructuredSource(
tables=[
ExcelTable("機能一覧", SheetType.FUNCTION, "openpyxl",
["機能ID", "機能名"],
[{"機能ID": cell("F001"), "機能名": cell("止损风控")}]),
ExcelTable("DB定義", SheetType.DATABASE, "openpyxl",
["テーブルID", "テーブル名"],
[{"テーブルID": cell("T001"), "テーブル名": cell("trade_order")}]),
],
template=template, rule_docs=[], image_analyses=[],
existing_system=None, comments=[],
)
ctxs = build_contexts(ss, samples_dir="nonexistent_dir_xyz")
by_id = {c.chapter_id: c for c in ctxs}
fn = by_id["function_list"].to_vars()["data"]
db = by_id["db_design"].to_vars()["data"]
assert "F001" in fn and "trade_order" not in fn
assert "T001" in db and "止损风控" not in db
+143 -12
View File
@@ -1,6 +1,66 @@
from genesis.data_models import (
CellValue,
ExcelTable,
ParsedTemplate,
ChapterMarker,
Provenance,
SheetType,
StructuredSource,
)
from genesis.writer.models import ContentBlock, ChapterContent, GenerationContext, ChapterSpec
def _cell(v):
return CellValue(value=v, provenance=Provenance("要件.xlsx", "s1", 1, "A", ""))
def _table(name, sheet_type, headers, rows):
return ExcelTable(
name=name,
detected_type=sheet_type,
extraction_method="openpyxl",
headers=headers,
rows=[{h: _cell(v) for h, v in zip(headers, row)} for row in rows],
)
def _mk_source():
"""含 FUNCTION / DATABASE / GENERIC 三张表的 StructuredSource(模板仅占位)。"""
template = ParsedTemplate(
file_name="t.docx",
sections=[ChapterMarker(type="heading", name="x", level=1)],
placeholders={},
styles={"used": []},
)
return StructuredSource(
tables=[
_table("機能一覧", SheetType.FUNCTION, ["機能ID", "機能名"], [["F001", "止损风控"], ["F002", "订单登录"]]),
_table("DB定義", SheetType.DATABASE, ["テーブルID", "テーブル名"], [["T001", "trade_order"]]),
_table("自由記述", SheetType.GENERIC, ["text"], [["系统采用前后端分离架构"]]),
],
template=template,
rule_docs=[],
image_analyses=[],
existing_system=None,
comments=[],
)
_UNSET = object()
def _ctx(cid, title, source=_UNSET, report=None):
return GenerationContext(
chapter_id=cid, title=title,
template_marker=ChapterSpec(chapter_id=cid, title=title, section_placeholder=f"section:{cid}"),
structured_source=None if source is None else (
_mk_source() if source is _UNSET else source
),
write_rules=[], design_rules=[], template_styles=set(),
impact_report=report,
)
def test_content_block_defaults():
b = ContentBlock(block_id="b1", type="paragraph", text="你好")
assert b.level is None
@@ -37,24 +97,16 @@ def test_generation_context_impact_var_formats_report():
warnings=[],
)
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 1})
ctx = GenerationContext(
chapter_id="db_design", title="DB 设计",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
structured_source=None, write_rules=["规则1"], design_rules=["规则2"],
template_styles={"Heading 1"}, impact_report=report,
)
# function_list 章节对应 ElementType=機能 → 该要素应保留
ctx = _ctx("function_list", "機能一覧", source=None, report=report)
impact = ctx.to_vars()["impact"]
assert "止损风控" in impact
assert "F001" in impact
def _impact_ctx(report):
return GenerationContext(
chapter_id="db_design", title="DB 设计",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
structured_source=None, write_rules=[], design_rules=[],
template_styles=set(), impact_report=report,
)
# function_list 章节对应 ElementType=機能 → [削除] 機能要素可保留
return _ctx("function_list", "機能一覧", source=None, report=report)
def test_impact_var_empty_when_report_without_analysis():
@@ -82,3 +134,82 @@ def test_impact_var_formats_deleted_and_warnings():
def test_chapter_spec_placeholder_optional():
s = ChapterSpec(chapter_id="x", title="X", section_placeholder=None)
assert s.section_placeholder is None
# ---------- 章节级数据注入(design.md §6.8 ①:selector=该章数据) ----------
def test_to_vars_data_scoped_by_chapter():
"""db_design 章的 data 只含 DATABASE 表 + GENERIC 自由記述,不含 FUNCTION 表。"""
data = _ctx("db_design", "DB設計").to_vars()["data"]
assert "テーブルID" in data and "trade_order" in data # DB 表内容
assert "前后端分离" in data # GENERIC 通用背景
assert "止损风控" not in data and "機能ID" not in data # FUNCTION 表被排除
def test_to_vars_data_function_chapter_gets_function_table():
data = _ctx("function_list", "機能一覧").to_vars()["data"]
assert "F001" in data and "止损风控" in data
assert "trade_order" not in data # DB 表被排除
def test_to_vars_data_introduction_gets_all_tables():
"""introduction 概览章注入全部类型表。"""
data = _ctx("introduction", "はじめに").to_vars()["data"]
assert "F001" in data and "trade_order" in data
def test_to_vars_data_empty_when_no_matching_table():
"""无对应类型表(如 report_list 无 REPORT 表)→ 仅 GENERIC 背景,不报错。"""
data = _ctx("report_list", "帳票一覧").to_vars()["data"]
assert "帳票ID" not in data
assert "前后端分离" in data # GENERIC 始终保留
def test_to_vars_data_none_safe_and_source_key_removed():
"""structured_source=None → data 为空串;旧的全量 source 键不再提供。"""
vars_ = _ctx("db_design", "DB設計", source=None).to_vars()
assert vars_["data"] == ""
assert "source" not in vars_
def test_to_vars_data_renders_markdown_table_shape():
"""表渲染为可读 Markdown 形态(表名行 + 表头行 + 分隔行)。"""
data = _ctx("db_design", "DB設計").to_vars()["data"]
assert "DB定義" in data
assert "| テーブルID | テーブル名 |" in data
assert "---" in data
assert "| T001 | trade_order |" in data
def test_impact_filtered_by_chapter_element_type():
"""db_design 章 impact 只显示 DB 要素;機能要素被过滤。"""
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
ca = ChangeAnalysis(
project_type="enhancement",
new_elements=[
ChangeElement("T001", "DB", "订单表", ChangeType.NEW),
ChangeElement("F001", "機能", "止损风控", ChangeType.NEW),
],
modified_elements=[],
deleted_elements=[],
unchanged_elements=[],
warnings=[],
)
report = ImpactReport(metadata={}, change_analysis=ca, summary={"new": 2})
impact = _ctx("db_design", "DB設計", source=None, report=report).to_vars()["impact"]
assert "T001" in impact and "订单表" in impact
assert "F001" not in impact and "止损风控" not in impact
def test_impact_introduction_shows_all_elements():
"""introduction 章(None 过滤)展示全部要素。"""
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
ca = ChangeAnalysis(
project_type="enhancement",
new_elements=[ChangeElement("T001", "DB", "订单表", ChangeType.NEW),
ChangeElement("F001", "機能", "止损风控", ChangeType.NEW)],
modified_elements=[], deleted_elements=[], unchanged_elements=[], warnings=[],
)
report = ImpactReport(metadata={}, change_analysis=ca, summary={"new": 2})
impact = _ctx("introduction", "はじめに", source=None, report=report).to_vars()["impact"]
assert "T001" in impact and "F001" in impact
+110
View File
@@ -66,6 +66,116 @@ def test_prompt_template_has_impact_context_var():
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
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):