Coverage for src\genesis\writer\models.py: 100%
117 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1"""Writer 子系统数据模型(Phase 5)。"""
2from __future__ import annotations
4from dataclasses import dataclass, field
5from typing import Literal
7from genesis.data_models import ElementType, SheetType
9# 单表渲染行数上限(防 token 爆炸;样本量小,通常不触发)
10MAX_ROWS_PER_TABLE = 200
12# design.md §6.8 ①「DataGate.load(structured_source, selector=该章数据)」的章节级选择器:
13# 章节占位符 id → 本章对应的 Excel Sheet 类型
14# - introduction 概览章注入全部类型表
15# - 未登记的章节 id 缺省为空列表 → 仅 GENERIC 自由記述作背景
16_ALL_SHEET_TYPES = list(SheetType)
17CHAPTER_SHEET_TYPES: dict[str, list[SheetType]] = {
18 "introduction": _ALL_SHEET_TYPES,
19 "function_list": [SheetType.FUNCTION],
20 "screen_list": [SheetType.SCREEN],
21 "report_list": [SheetType.REPORT],
22 "db_design": [SheetType.DATABASE],
23 "if_definition": [SheetType.INTERFACE],
24 "batch_list": [SheetType.BATCH],
25}
27# 章节 id → 影响调查要素类型(None = 展示全部,用于概览章)
28CHAPTER_IMPACT_ELEMENT: dict[str, ElementType | None] = {
29 "function_list": ElementType.FUNCTION,
30 "screen_list": ElementType.SCREEN,
31 "report_list": ElementType.REPORT,
32 "db_design": ElementType.DB,
33 "if_definition": ElementType.IF,
34 "batch_list": ElementType.BATCH,
35 "introduction": None,
36}
39@dataclass
40class ContentBlock:
41 """LLM 生成的内容块。注意:table.headers/caption、list.items/style 在渲染至
42 DocxInjector.Block 时显式丢弃(renderer 中声明并测试)。"""
44 block_id: str
45 type: Literal["paragraph", "heading", "table", "list", "note"]
46 level: int | None = None
47 text: str | None = None
48 caption: str | None = None
49 headers: list[str] | None = None
50 rows: list[list[str]] | None = None
51 items: list[str] | None = None
52 style: str | None = None
53 source_uris: list[str] = field(default_factory=list)
55 @classmethod
56 def from_dict(cls, block_id: str, data: dict) -> "ContentBlock":
57 """从 LLM 输出的 block dict 安全构造内容块。"""
58 return cls(
59 block_id=str(block_id),
60 type=data.get("type", "paragraph"),
61 level=data.get("level"),
62 text=data.get("text"),
63 caption=data.get("caption"),
64 headers=data.get("headers"),
65 rows=data.get("rows"),
66 items=data.get("items"),
67 style=data.get("style"),
68 source_uris=data.get("source_uris", []),
69 )
72@dataclass
73class ChapterContent:
74 chapter_id: str
75 version: int
76 title: str
77 blocks: list[ContentBlock]
79 @classmethod
80 def from_llm(cls, chapter_id: str, title: str, data: dict) -> "ChapterContent":
81 """从 LLM 结构化输出(含 title、blocks 列表)构造章节内容。
83 对每个 block dict 用 ContentBlock.from_dict 安全取值;block_id 缺省为序号字符串。
84 """
85 blocks: list[ContentBlock] = []
86 for i, b in enumerate(data.get("blocks", [])):
87 blocks.append(ContentBlock.from_dict(str(b.get("block_id", i)), b))
88 return cls(chapter_id=chapter_id, version=1, title=title, blocks=blocks)
91@dataclass
92class ChapterSpec:
93 """template_mapper 产出:驱动 WriterAgent 串行顺序。"""
95 chapter_id: str
96 title: str
97 section_placeholder: str | None = None # 如 "{{section:db_design}}",无则 None
98 sub_headings: list[str] = field(default_factory=list) # 本章 H2/H3 子节标题(§6.5 归并)
101@dataclass
102class GenerationContext:
103 chapter_id: str
104 title: str
105 template_marker: ChapterSpec
106 structured_source: object | None
107 write_rules: list[str]
108 design_rules: list[str]
109 template_styles: set[str]
110 prior_state: object | None = None # WriterState,避免循环 import 用 object
111 impact_report: object | None = None # ImpactReport 影响调查书(生成主上下文)
112 output_language: str = "auto" # "auto" | "zh" | "ja"(步骤 1:用户可选输出语言)
114 def _language_instruction(self) -> str:
115 """根据 output_language 生成【语言约束】段的具体指令(步骤 1)。"""
116 if self.output_language == "zh":
117 return "必须使用简体中文撰写(标题、正文与所有说明一律中文)。"
118 if self.output_language == "ja":
119 return "必ず日本語で記述すること(タイトル・本文・すべての説明は日本語)。"
120 # auto:沿用与标题语言一致的旧语义(向后兼容既有日文文档)
121 return (
122 f"必须与章节标题「{self.title}」所用自然语言保持一致:"
123 "标题为日文则用日文撰写,为中文则用中文撰写,依此类推。"
124 )
126 def to_vars(self) -> dict:
127 """返回供 prompt 渲染的变量字典。"""
128 tm = self.template_marker
129 template_marker = f"{tm.chapter_id}:{tm.title}" if tm is not None else ""
130 return {
131 "chapter_id": self.chapter_id,
132 "title": self.title,
133 "template_marker": template_marker,
134 "write_rules": "\n".join(self.write_rules),
135 "design_rules": "\n".join(self.design_rules),
136 "template_styles": ", ".join(sorted(self.template_styles)),
137 "sub_headings": "\n".join(
138 f"- {h}" for h in (getattr(tm, "sub_headings", None) or [])
139 ),
140 "prior_state": str(self.prior_state) if self.prior_state is not None else "",
141 "language_instruction": self._language_instruction(),
142 "data": _format_chapter_data(
143 self.structured_source,
144 CHAPTER_SHEET_TYPES.get(self.chapter_id, []),
145 ),
146 "impact": _format_impact(
147 self.impact_report,
148 CHAPTER_IMPACT_ELEMENT.get(self.chapter_id, None),
149 self.output_language,
150 ),
151 }
154def _render_table(tb) -> list[str]:
155 """将单张 ExcelTable 渲染为可读 Markdown 行(表名行 + 表头 + 分隔 + 数据行)。"""
156 lines = [f"### 表: {tb.name}({tb.detected_type.value})"]
157 headers = list(tb.headers)
158 lines.append("| " + " | ".join(headers) + " |")
159 lines.append("|" + "|".join([" --- "] * len(headers)) + "|")
160 for row in tb.rows[:MAX_ROWS_PER_TABLE]:
161 cells = []
162 for h in headers:
163 v = row.get(h)
164 value = getattr(v, "value", v)
165 cells.append("" if value is None else str(value))
166 lines.append("| " + " | ".join(cells) + " |")
167 return lines
170def _format_chapter_data(structured_source: object | None, sheet_types: list[SheetType]) -> str:
171 """按章节定向格式化要件定义数据(design.md §6.8 ① selector=该章数据)。
173 - 命中 sheet_types 的表全部注入(章节主题数据)
174 - GENERIC(自由記述)作为通用背景始终注入
175 - structured_source 为 None 或无任何可注入表时返回空串
176 """
177 if structured_source is None:
178 return ""
179 tables = getattr(structured_source, "tables", None) or []
180 matched = [
181 t for t in tables
182 if t.detected_type in sheet_types and t.detected_type != SheetType.GENERIC
183 ]
184 generic = [t for t in tables if t.detected_type == SheetType.GENERIC]
185 selected = matched + generic
186 if not selected:
187 return ""
188 lines: list[str] = []
189 for tb in selected:
190 lines.extend(_render_table(tb))
191 lines.append("")
192 return "\n".join(lines).rstrip()
195# 影响调查标签本地化(步骤 B):auto/ja 默认日文,zh 中文
196# 注:方括号标记 [..] 保留(中日通用),仅标签词本地化
197_IMPACT_LABELS: dict[str, dict[str, str]] = {
198 "zh": {
199 "new": "新建", "modified": "变更", "deleted": "删除", "warning": "警告",
200 "affected": "受影响",
201 },
202 "ja": {
203 "new": "新規", "modified": "変更", "deleted": "削除", "warning": "警告",
204 "affected": "受影响",
205 },
206}
209def _format_impact(
210 report: object | None,
211 element_type: ElementType | None = None,
212 output_language: str = "auto",
213) -> str:
214 """将影响调查书格式化为 prompt 可读文本(无报告/无分析时为空串)。
216 element_type 非 None 时仅保留该类型要素(章节级定向,design.md §6.8 ①);
217 警告始终保留(不依赖要素类型)。
218 output_language 控制标签语言(步骤 B);auto 回落到 ja 标签。
219 """
220 if report is None:
221 return ""
222 ca = getattr(report, "change_analysis", None)
223 if ca is None:
224 return ""
226 def keep(el) -> bool:
227 return element_type is None or el.element_type == element_type.value
229 lab = _IMPACT_LABELS.get(output_language, _IMPACT_LABELS["ja"])
230 summary = getattr(report, "summary", {}) or {}
231 lines = [f"project_type={getattr(ca, 'project_type', '')}"]
232 lines.append(
233 "summary: new={new} modified={modified} deleted={deleted} "
234 "unchanged={unchanged} warnings={warnings}".format(
235 new=summary.get("new", 0), modified=summary.get("modified", 0),
236 deleted=summary.get("deleted", 0), unchanged=summary.get("unchanged", 0),
237 warnings=summary.get("warnings", 0),
238 )
239 )
240 for el in getattr(ca, "new_elements", []) or []:
241 if keep(el):
242 lines.append(f"[{lab['new']}] {el.element_id} {el.element_type} {el.name}")
243 for el in getattr(ca, "modified_elements", []) or []:
244 if keep(el):
245 impacted = ", ".join(el.impacted_existing) or "-"
246 lines.append(f"[{lab['modified']}] {el.element_id} {el.element_type} {el.name} → {lab['affected']}: {impacted}")
247 for el in getattr(ca, "deleted_elements", []) or []:
248 if keep(el):
249 impacted = ", ".join(el.impacted_existing) or "-"
250 lines.append(f"[{lab['deleted']}] {el.element_id} {el.element_type} {el.name} → {lab['affected']}: {impacted}")
251 for w in getattr(ca, "warnings", []) or []:
252 lines.append(f"[{lab['warning']}] {w.element_id}: {w.issue}")
253 return "\n".join(lines)