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
+98 -9
View File
@@ -4,6 +4,37 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from genesis.data_models import ElementType, SheetType
# 单表渲染行数上限(防 token 爆炸;样本量小,通常不触发)
MAX_ROWS_PER_TABLE = 200
# design.md §6.8 ①「DataGate.load(structured_source, selector=该章数据)」的章节级选择器:
# 章节占位符 id → 本章对应的 Excel Sheet 类型
# - introduction 概览章注入全部类型表
# - 未登记的章节 id 缺省为空列表 → 仅 GENERIC 自由記述作背景
_ALL_SHEET_TYPES = list(SheetType)
CHAPTER_SHEET_TYPES: dict[str, list[SheetType]] = {
"introduction": _ALL_SHEET_TYPES,
"function_list": [SheetType.FUNCTION],
"screen_list": [SheetType.SCREEN],
"report_list": [SheetType.REPORT],
"db_design": [SheetType.DATABASE],
"if_definition": [SheetType.INTERFACE],
"batch_list": [SheetType.BATCH],
}
# 章节 id → 影响调查要素类型(None = 展示全部,用于概览章)
CHAPTER_IMPACT_ELEMENT: dict[str, ElementType | None] = {
"function_list": ElementType.FUNCTION,
"screen_list": ElementType.SCREEN,
"report_list": ElementType.REPORT,
"db_design": ElementType.DB,
"if_definition": ElementType.IF,
"batch_list": ElementType.BATCH,
"introduction": None,
}
@dataclass
class ContentBlock:
@@ -90,18 +121,73 @@ class GenerationContext:
"design_rules": "\n".join(self.design_rules),
"template_styles": ", ".join(sorted(self.template_styles)),
"prior_state": str(self.prior_state) if self.prior_state is not None else "",
"source": str(self.structured_source) if self.structured_source is not None else "",
"impact": _format_impact(self.impact_report),
"data": _format_chapter_data(
self.structured_source,
CHAPTER_SHEET_TYPES.get(self.chapter_id, []),
),
"impact": _format_impact(
self.impact_report,
CHAPTER_IMPACT_ELEMENT.get(self.chapter_id, None),
),
}
def _format_impact(report: object | None) -> str:
"""影响调查书格式化为 prompt 可读文本(无报告/无分析时为空串)。"""
def _render_table(tb) -> list[str]:
"""单张 ExcelTable 渲染为可读 Markdown 行(表名行 + 表头 + 分隔 + 数据行)。"""
lines = [f"### 表: {tb.name}{tb.detected_type.value}"]
headers = list(tb.headers)
lines.append("| " + " | ".join(headers) + " |")
lines.append("|" + "|".join([" --- "] * len(headers)) + "|")
for row in tb.rows[:MAX_ROWS_PER_TABLE]:
cells = []
for h in headers:
v = row.get(h)
value = getattr(v, "value", v)
cells.append("" if value is None else str(value))
lines.append("| " + " | ".join(cells) + " |")
return lines
def _format_chapter_data(structured_source: object | None, sheet_types: list[SheetType]) -> str:
"""按章节定向格式化要件定义数据(design.md §6.8 ① selector=该章数据)。
- 命中 sheet_types 的表全部注入(章节主题数据)
- GENERIC(自由記述)作为通用背景始终注入
- structured_source 为 None 或无任何可注入表时返回空串
"""
if structured_source is None:
return ""
tables = getattr(structured_source, "tables", None) or []
matched = [
t for t in tables
if t.detected_type in sheet_types and t.detected_type != SheetType.GENERIC
]
generic = [t for t in tables if t.detected_type == SheetType.GENERIC]
selected = matched + generic
if not selected:
return ""
lines: list[str] = []
for tb in selected:
lines.extend(_render_table(tb))
lines.append("")
return "\n".join(lines).rstrip()
def _format_impact(report: object | None, element_type: ElementType | None = None) -> str:
"""将影响调查书格式化为 prompt 可读文本(无报告/无分析时为空串)。
element_type 非 None 时仅保留该类型要素(章节级定向,design.md §6.8 ①);
警告始终保留(不依赖要素类型)。
"""
if report is None:
return ""
ca = getattr(report, "change_analysis", None)
if ca is None:
return ""
def keep(el) -> bool:
return element_type is None or el.element_type == element_type.value
summary = getattr(report, "summary", {}) or {}
lines = [f"project_type={getattr(ca, 'project_type', '')}"]
lines.append(
@@ -113,13 +199,16 @@ def _format_impact(report: object | None) -> str:
)
)
for el in getattr(ca, "new_elements", []) or []:
lines.append(f"[新規] {el.element_id} {el.element_type} {el.name}")
if keep(el):
lines.append(f"[新規] {el.element_id} {el.element_type} {el.name}")
for el in getattr(ca, "modified_elements", []) or []:
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[変更] {el.element_id} {el.element_type} {el.name} → 受影响: {impacted}")
if keep(el):
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[変更] {el.element_id} {el.element_type} {el.name} → 受影响: {impacted}")
for el in getattr(ca, "deleted_elements", []) or []:
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[削除] {el.element_id} {el.element_type} {el.name} → 受影响: {impacted}")
if keep(el):
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[削除] {el.element_id} {el.element_type} {el.name} → 受影响: {impacted}")
for w in getattr(ca, "warnings", []) or []:
lines.append(f"[警告] {w.element_id}: {w.issue}")
return "\n".join(lines)
+18 -6
View File
@@ -22,8 +22,12 @@ WRITER_PROMPT_TEMPLATE = (
"设计规则:\n{{design_rules}}\n"
"模板样式:\n{{template_styles}}\n"
"影响调查上下文:\n{{impact}}\n"
"参考资料:\n{{source}}\n"
"参考资料(本章对应数据):\n{{data}}\n"
"请输出符合 schema 的章节内容 JSON。\n"
"【主题约束】本章必须且仅围绕标题「{{title}}」所对应的主题撰写,"
"严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;"
"禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。"
"若本章数据为空,则基于规则与影响调查上下文简要撰写,不得虚构数据。\n"
"【语言约束】章节正文(所有 block 的 text 字段)所使用的自然语言,"
"必须与章节标题「{{title}}」所用语言保持一致:标题为日文则用日文撰写,"
"为中文则用中文撰写,依此类推。"
@@ -77,16 +81,24 @@ class WriterAgent:
return [{"index": i, "text": src[i * 1800:(i + 1) * 1800]} for i in range(n)]
def _resolve_prompt(self) -> Prompt:
"""取用/注册 writer.chapter 模板。
"""取用/注册 writer.chapter 模板,并保证返回 Prompt 对象
优先使用 get_or_create(与测试 Fake 兼容);真实 PromptRegistry 无该方法时,
回退为 register + get。
回退为 register + get。注意:真实 get() 返回模板字符串而非 Prompt,
而引擎 _render_prompt 仅对 Prompt 对象做变量渲染、str 原样发送
(否则 {{占位符}} 不被替换直接进 LLM)→ 此处统一包装为 Prompt。
"""
get_or_create = getattr(self.prompt_registry, "get_or_create", None)
if get_or_create is not None:
return get_or_create("writer.chapter", WRITER_PROMPT_TEMPLATE)
self.prompt_registry.register("writer.chapter", "1", WRITER_PROMPT_TEMPLATE)
return self.prompt_registry.get("writer.chapter", "1")
resolved = get_or_create("writer.chapter", WRITER_PROMPT_TEMPLATE)
else:
self.prompt_registry.register("writer.chapter", "1", WRITER_PROMPT_TEMPLATE)
resolved = self.prompt_registry.get("writer.chapter", "1")
if isinstance(resolved, Prompt):
return resolved
template = getattr(resolved, "template", None) or str(resolved)
version = str(getattr(resolved, "version", "1") or "1")
return Prompt(name="writer.chapter", version=version, template=template)
def _call_llm(self, context: GenerationContext) -> dict:
prompt = self._resolve_prompt()