fix(writer): 修复概要设计书输出塌缩与章间引用缺失,并补 parser 防灾

- 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%)
This commit is contained in:
lhl
2026-09-15 21:30:55 +08:00
parent 389d1963a4
commit d9aa3a3325
28 changed files with 1086 additions and 83 deletions
+2
View File
@@ -171,3 +171,5 @@
| 2026-09-13 00:47 | 反馈迭代 | 要件定义书改为待发附件随消息发送:前端 pendingAttachment chip→send 先上传再发消息(body.attachment);消息绑定 attachment(store 增列+旧库迁移, agent 透传);新增 GET /api/sessions/{sid}/files/{file_type}service.file_path;前端加 xlsx(SheetJS) 在线查看多 sheet 弹层 | src/genesis/server/store.py; src/genesis/server/service.py; src/genesis/server/app.py; src/genesis/chat/agent.py; frontend/src/main.ts; frontend/src/types.ts; frontend/src/chat.css; frontend/package.json; tests/test_server_store.py; tests/test_server_api.py | deepseek-chat |
| 2026-09-13 01:00 | 反馈迭代 | 附件展示调整:用户消息改为 .msg-stack 列布局,文字在气泡内、附件 chip 在气泡下方独立一行右对齐;另重启后端使 /api/sessions/{sid}/files/{file_type} 新路由生效(原 404 系旧进程未含新路由) | frontend/src/main.ts; frontend/src/chat.css; src/genesis/server/static/chat.js; src/genesis/server/static/chat.css | deepseek-chat |
| 2026-09-13 21:43 | 反馈迭代 | 推送代码到 Gitea(方案1:仅同步 main29 提交 b14c221→afd8080,远程仅保留 main);修复推送认证 403/pre-receive declined:根因为全局 ~/.gitconfig 中 url.<token>@gittea.dev/.insteadOf 重写规则回填只读令牌,已删除该规则、清空失效 .git-credentials、清理 Windows 凭据缓存,改用 GCM 输入有 write 权限账号后推送成功 | .git/config; ~/.gitconfig; ~/.git-credentials | deepseek-flash |
| 2026-09-15 11:30 | Agent 实现 | P0 修复(W1Writer 输出完整性):①表格表头行/Table Grid 边框不再塌缩(renderer 透传 headers、docx_injector 渲染表头行+边框)②列表逐项套用 List Bullet/Number 样式 ③行内占位符替换保留首个 run 字符格式 ④章间引用打通(WriterState.summary_of/summaries_before → 后章 prompt {{prior_summaries}})⑤删除 writer_agent._chunk_source 死代码,章节数据经 DataGate 控 token 预算(超预算按最大表优先省略+标记,不阻断生成)⑥章节结果落盘快照 JSON + 命中快照跳过 LLM(录制重拍可续跑,损坏快照自动忽略)⑦生成阶段 per-chapter 日志可观测。全量 664 通过、覆盖率 99.36%(红线 99% | src/genesis/writer/renderer.py; src/genesis/writer/docx_injector.py; src/genesis/writer/models.py; src/genesis/writer/writer_state.py; src/genesis/writer/writer_agent.py; src/genesis/writer/orchestrator.py; src/genesis/writer/context_builder.py; tests/test_phase5_renderer.py; tests/test_docx_injector.py; tests/test_phase5_models.py; tests/test_phase5_writer_state.py; tests/test_phase5_writer_orchestrator.py; tests/test_phase5_writer_agent.py; tests/test_writer_data_budget.py; tests/test_writer_snapshot.py; tests/test_writer_agent_branches.py | deepseek-flash |
| 2026-09-15 21:03 | Agent 实现 | P2-1/P2-2 廉价防灾:①P2-1 Word 占位符漏表格单元格——新增 _word_common.iter_all_paragraphs(按 body 顺序遍历正文+单元格段落含嵌套表,按 w:tc 去重合并单元格),word_template_parser 与 docx_injector 的 _inject_sections/_inject_inline/_has_residue/_collect_residue 全部改用它,消除「表格内锚点漏检→静默丢章/漏检残留」②P2-2 .xls 扩展名不一致——excel_reader 定为唯一事实来源(LEGACY_EXCEL_EXTS/HINT 可操作提示)source_aggregator XLSX_EXTS 收窄为 .xlsxservice 接入 EXPECTED_SUFFIX 上传即校验(rules 修正为 docx-only,对齐解析层)、config.allowed_extensions 移除 .xls。全量 680 通过、覆盖率 99.37%(红线 99% | src/genesis/parsers/_word_common.py; src/genesis/parsers/word_template_parser.py; src/genesis/parsers/excel_reader.py; src/genesis/parsers/source_aggregator.py; src/genesis/writer/docx_injector.py; src/genesis/server/service.py; src/genesis/config.py; tests/test_word_template_parser.py; tests/test_docx_injector.py; tests/test_excel_reader.py; tests/test_source_aggregator.py; tests/test_server_service.py; tests/test_config.py; tests/test_phase5_writer_orchestrator.py | deepseek-flash |
+1 -1
View File
@@ -17,7 +17,7 @@ ENV_PREFIX = "GENESIS_"
class ServerConfig(BaseModel):
max_upload_mb: int = 100
allowed_extensions: list[str] = Field(
default_factory=lambda: [".xlsx", ".xls", ".docx", ".pptx", ".java", ".xml", ".yml",
default_factory=lambda: [".xlsx", ".docx", ".pptx", ".java", ".xml", ".yml",
".py", ".ts", ".go", ".cs"]
)
+31 -1
View File
@@ -1,7 +1,11 @@
"""Word 解析共享小工具(WordTemplateParser / RuleDocParser 复用)。"""
"""Word 解析共享小工具(WordTemplateParser / RuleDocParser / DocxInjector 复用)。"""
from __future__ import annotations
from docx.oxml.ns import qn
from docx.table import Table
from docx.text.paragraph import Paragraph
def heading_level(style_name: str) -> int:
"""从 Heading N 样式名解析大纲级别;非数字/无后缀兜底 1。"""
@@ -9,3 +13,29 @@ def heading_level(style_name: str) -> int:
return int(style_name.split()[-1])
except (ValueError, IndexError):
return 1
def iter_all_paragraphs(doc):
"""按文档顺序遍历「正文段落 + 表格单元格段落(含嵌套表)」。
- 保持 body 顺序正文段落与表格交错使模板解析的 锚点绑定
不错位`doc.paragraphs` 会把正文段落与表格分成两批破坏顺序
- 合并单元格的 `row.cells` 会重复指向同一 `w:tc` tc 去重
避免重复收集占位符 / 重复注入
"""
seen_tc: set = set()
def walk(element):
for child in element.iterchildren():
if child.tag == qn("w:p"):
yield Paragraph(child, doc)
elif child.tag == qn("w:tbl"):
for row in Table(child, doc).rows:
for cell in row.cells:
tc = cell._tc
if id(tc) in seen_tc:
continue
seen_tc.add(id(tc))
yield from walk(tc)
yield from walk(doc.element.body)
+10 -2
View File
@@ -6,11 +6,19 @@ from typing import Any
from openpyxl import load_workbook
from openpyxl.worksheet.worksheet import Worksheet
# P2-2:旧版 .xls(BIFF)不受支持。此处为扩展名校验的单一事实来源,
# 其他层(source_aggregator / server service)均从此处引用,避免多处声明互相矛盾。
LEGACY_EXCEL_EXTS = (".xls",)
LEGACY_EXCEL_HINT = "不支持旧版 Excel 格式 {ext}:请用 Excel 另存为 .xlsx 后重试"
def open_workbook(path: str | Path):
"""普通模式打开 .xlsx(保留公式/样式/批注).xls 报错"""
"""普通模式打开 .xlsx(保留公式/样式/批注).xls 给出可操作提示"""
path = Path(path)
if path.suffix.lower() != ".xlsx":
suffix = path.suffix.lower()
if suffix in LEGACY_EXCEL_EXTS:
raise ValueError(LEGACY_EXCEL_HINT.format(ext=path.suffix))
if suffix != ".xlsx":
raise ValueError(f"不支持的 Excel 格式: {path.suffix}")
return load_workbook(path)
+10 -3
View File
@@ -6,22 +6,29 @@ from genesis.data_models import StructuredSource
from genesis.impact.code_parser import CodeParser
from genesis.impact.existing_system_explorer import ExistingSystemExplorer
from genesis.parsers.excel_parser import ExcelParser
from genesis.parsers.excel_reader import LEGACY_EXCEL_EXTS, LEGACY_EXCEL_HINT
from genesis.parsers.rule_doc_parser import RuleDocParser
from genesis.parsers.word_template_parser import WordTemplateParser
XLSX_EXTS = (".xlsx", ".xls")
# .xlsx 为唯一受支持的 Excel 格式(旧版 .xls 显式拒绝,见 excel_reader)。
XLSX_EXTS = (".xlsx",)
DOCX_EXT = ".docx"
__all__ = ["SourceParser", "XLSX_EXTS", "DOCX_EXT", "LEGACY_EXCEL_EXTS", "LEGACY_EXCEL_HINT"]
def _validate_path(path: str | Path, allowed_exts: tuple[str, ...]) -> Path:
"""校验文件扩展名合法且文件存在(T7 DRY:消除三处重复校验)。
Raises:
ValueError: 扩展名不在 allowed_exts含无扩展名
ValueError: 旧版 .xls / 扩展名不在 allowed_exts含无扩展名
FileNotFoundError: 文件不存在
"""
p = Path(path)
if p.suffix.lower() not in allowed_exts:
suffix = p.suffix.lower()
if suffix in LEGACY_EXCEL_EXTS:
raise ValueError(LEGACY_EXCEL_HINT.format(ext=p.suffix))
if suffix not in allowed_exts:
raise ValueError(f"不支持的文件类型: {p.suffix or '(无扩展名)'}")
if not p.exists():
raise FileNotFoundError(str(path))
+3 -2
View File
@@ -7,7 +7,7 @@ from docx import Document
from docx.oxml.ns import qn
from genesis.data_models import ChapterMarker, ParsedTemplate
from genesis.parsers._word_common import heading_level
from genesis.parsers._word_common import heading_level, iter_all_paragraphs
# 统一占位符正则:{{键名}} 或 {{键名:章节名}}spec §3.2)。
# 宽容:键名大小写不敏感、分隔符支持半角(:)/全角(:)冒号;解析时归一为小写键名 + 半角冒号。
@@ -29,7 +29,8 @@ class WordTemplateParser:
# 文档命名样式(定义集合)
defined = {s.name for s in doc.styles if s.name}
for para in doc.paragraphs:
# P2-1:含表格单元格(按 body 顺序),避免表格内锚点漏检 / 错绑
for para in iter_all_paragraphs(doc):
style_name = para.style.name if para.style else "Normal"
used_styles.add(style_name)
text = para.text
+25 -3
View File
@@ -21,7 +21,11 @@ from pathlib import Path
from docx import Document
from genesis.parsers.source_aggregator import SourceParser
from genesis.parsers.source_aggregator import (
LEGACY_EXCEL_EXTS,
LEGACY_EXCEL_HINT,
SourceParser,
)
from genesis.server.store import SessionStore, SessionRecord, ProjectsStore, ProjectConfigError
_LOGGER = logging.getLogger(__name__)
@@ -30,12 +34,12 @@ ALLOWED_FILE_TYPES = {
"requirements", "template", "write_instruction", "rules", "existing_system",
}
# 各类型建议扩展名(宽松校验:仅拒绝明显非法的空文件
# 各类型允许的扩展名(上传即校验,与解析层能力对齐;.xls 见 LEGACY_EXCEL_HINT
EXPECTED_SUFFIX = {
"requirements": (".xlsx",),
"template": (".docx",),
"write_instruction": (".docx",),
"rules": (".docx", ".xlsx"),
"rules": (".docx",), # 解析层(source_aggregator)仅支持 docx 规则
"existing_system": (".zip",),
}
@@ -150,9 +154,27 @@ class GenesisService:
"chunks": chunks,
}
@staticmethod
def _validate_upload_suffix(file_type: str, filename: str) -> None:
"""上传即校验扩展名(失败快于解析期)。
Raises:
FileTypeError: 旧版 .xls或扩展名不在该 file_type 的允许集合内
"""
suffix = Path(filename).suffix.lower()
if suffix in LEGACY_EXCEL_EXTS:
raise FileTypeError(LEGACY_EXCEL_HINT.format(ext=Path(filename).suffix))
expected = EXPECTED_SUFFIX.get(file_type)
if expected and suffix not in expected:
raise FileTypeError(
f"不支持的扩展名 {suffix or '(无扩展名)'}"
f"{file_type} 允许: {', '.join(expected)}"
)
def upload_file(self, session_id: str, file_type: str, filename: str, content: bytes) -> dict:
if file_type not in ALLOWED_FILE_TYPES:
raise FileTypeError(f"不支持的 file_type: {file_type}")
self._validate_upload_suffix(file_type, filename)
sdir = self.data_root / session_id / "uploads"
sdir.mkdir(parents=True, exist_ok=True)
if file_type == "existing_system":
-1
View File
@@ -29,7 +29,6 @@ def build_contexts(
write_rules=write_rules,
design_rules=design_rules,
template_styles=set(used),
prior_state=None,
impact_report=getattr(structured_source, "impact_report", None),
output_language=output_language,
)
+70 -23
View File
@@ -21,6 +21,8 @@ from docx.document import Document as DocxDocument
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
from genesis.parsers._word_common import iter_all_paragraphs
# 宽容:docx 正文锚点可能写为 {{Section:id}} / {{sectionid}}(大小写/全角冒号)
_SECTION_RE = re.compile(r"\{\{\s*section\s*[:]\s*([^}]+?)\s*\}\}", re.IGNORECASE)
_INLINE_RE = re.compile(r"\{\{([^}]+)\}\}")
@@ -32,12 +34,19 @@ class DocxInjectError(Exception):
@dataclass
class Block:
"""简化的内容块(ContentBlock 原型的子集)。"""
"""简化的内容块(ContentBlock 原型的子集)。
kind: str # "paragraph" | "heading" | "table"
- table: text=标题(caption)headers=表头行rows=数据行
- list: items=列表项style="bullet"|"numbered"
"""
kind: str # "paragraph" | "heading" | "table" | "list" | "note"
text: str = ""
level: int = 1 # heading 层级
rows: list[list[str]] = field(default_factory=list) # table 行
headers: list[str] = field(default_factory=list) # table 表头
rows: list[list[str]] = field(default_factory=list) # table 数据行
items: list[str] = field(default_factory=list) # list 列表项
style: str = "" # list 样式: "bullet" | "numbered"
class DocxInjector:
@@ -130,7 +139,8 @@ class DocxInjector:
# ---------- 内部 ----------
def _inject_sections(self, doc: DocxDocument, sections: dict[str, list[Block]]) -> None:
for para in list(doc.paragraphs):
# P2-1:含表格单元格(按 body 顺序);先物化再注入,避免边注入边遍历
for para in list(iter_all_paragraphs(doc)):
m = _SECTION_RE.search(para.text)
if not m:
continue
@@ -142,7 +152,7 @@ class DocxInjector:
self._replace_paragraph_with_blocks(doc, para, blocks)
def _inject_inline(self, doc: DocxDocument, meta: dict[str, str]) -> None:
for para in doc.paragraphs:
for para in iter_all_paragraphs(doc):
if _INLINE_RE.search(para.text):
# 仅替换行内占位符,保留模板其余文本
new_text = _INLINE_RE.sub(lambda mm: meta.get(mm.group(1), mm.group(0)), para.text)
@@ -163,42 +173,79 @@ class DocxInjector:
for el in reversed(self._block_element(doc, block)):
parent.insert(para_idx, el)
_NUMBERED_STYLES = {"numbered", "number", "ordered"}
@staticmethod
def _apply_style_if_present(doc: DocxDocument, obj, name: str) -> None:
"""模板中定义了该样式才套用(自定义模板可能缺标准样式名)。"""
if name in doc.styles: # pragma: no cover - 取决于模板是否定义标准样式
obj.style = name
def _block_element(self, doc: DocxDocument, block: Block) -> list:
if block.kind == "heading":
p = doc.add_paragraph(block.text, style=f"Heading {block.level}")
return [p._p]
if block.kind == "table":
elements: list = []
if block.text:
style = "Caption" if "Caption" in doc.styles else None
cap = doc.add_paragraph(block.text, style=style)
elements.append(cap._p)
tbl = doc.add_table(rows=0, cols=len(block.rows[0]) if block.rows else 1)
for r in block.rows:
cells = tbl.add_row().cells
for i, val in enumerate(r):
cells[i].text = str(val)
elements.append(tbl._tbl) # type: ignore[attr-defined]
return elements
return self._table_element(doc, block)
if block.kind == "list":
return self._list_elements(doc, block)
# 默认 paragraph
p = doc.add_paragraph(block.text)
return [p._p]
def _table_element(self, doc: DocxDocument, block: Block) -> list:
"""表格:标题(Caption) + 表头行 + 数据行;套用 Table Grid 保证边框。"""
elements: list = []
if block.text:
style = "Caption" if "Caption" in doc.styles else None
cap = doc.add_paragraph(block.text, style=style)
elements.append(cap._p)
widths = [len(block.headers)] + [len(r) for r in block.rows]
ncols = max(max(widths, default=0), 1)
tbl = doc.add_table(rows=0, cols=ncols)
self._apply_style_if_present(doc, tbl, "Table Grid")
if block.headers:
header_cells = tbl.add_row().cells
for i, val in enumerate(block.headers[:ncols]):
header_cells[i].text = str(val)
for r in block.rows:
cells = tbl.add_row().cells
for i, val in enumerate(r[:ncols]):
cells[i].text = str(val)
elements.append(tbl._tbl) # type: ignore[attr-defined]
return elements
def _list_elements(self, doc: DocxDocument, block: Block) -> list:
"""列表:逐项成段,套用 List Bullet / List Number 样式。"""
items = block.items or ([block.text] if block.text else [])
numbered = (block.style or "").lower() in self._NUMBERED_STYLES
style = "List Number" if numbered else "List Bullet"
paras = [doc.add_paragraph(str(it)) for it in items]
for p in paras:
self._apply_style_if_present(doc, p, style)
return [p._p for p in paras]
def _set_paragraph_text(self, para: Paragraph, text: str) -> None:
# 清空 run,写入单 run(原型简化;保留段落样式)
for run in list(para.runs):
run._r.getparent().remove(run._r)
para.add_run(text)
# 保留首个 run 的字符格式(字体/字号/加粗),替换其文本,其余 run 清空。
# 旧实现清空全部 run 后新建,会丢失模板定义的字符格式(外视 #6 反转)。
runs = list(para.runs)
if not runs:
para.add_run(text)
return
runs[0].text = text
for run in runs[1:]:
run.text = ""
def _has_residue(self, doc: DocxDocument) -> bool:
for para in doc.paragraphs:
# P2-1:单元格内的未替换占位符也必须检出(否则静默丢内容)
for para in iter_all_paragraphs(doc):
if _INLINE_RE.search(para.text):
return True
return False
def _collect_residue(self, doc: DocxDocument) -> list[str]:
found: list[str] = []
for para in doc.paragraphs:
for para in iter_all_paragraphs(doc):
for m in _INLINE_RE.finditer(para.text):
found.append(m.group(0))
return found
+55 -7
View File
@@ -5,10 +5,14 @@ from dataclasses import dataclass, field
from typing import Literal
from genesis.data_models import ElementType, SheetType
from genesis.orchestrator.datagate import DataGate, DataGateError, DataSelector
# 单表渲染行数上限(防 token 爆炸;样本量小,通常不触发)
MAX_ROWS_PER_TABLE = 200
# 章节数据 token 预算(OV5 DataGate 默认上限;可由 GenerationContext 注入覆盖)
MAX_CHAPTER_DATA_TOKENS = 8_000
# design.md §6.8 ①「DataGate.load(structured_source, selector=该章数据)」的章节级选择器:
# 章节占位符 id → 本章对应的 Excel Sheet 类型
# - introduction 概览章注入全部类型表
@@ -38,8 +42,8 @@ CHAPTER_IMPACT_ELEMENT: dict[str, ElementType | None] = {
@dataclass
class ContentBlock:
"""LLM 生成的内容块。注意:table.headers/caption、list.items/style 在渲染
DocxInjector.Block 时显式丢弃renderer 中声明并测试"""
"""LLM 生成的内容块。table.headers/caption、list.items/style 会完整透传
DocxInjector.Block渲染器保证表头/边框/列表样式 renderer"""
block_id: str
type: Literal["paragraph", "heading", "table", "list", "note"]
@@ -107,9 +111,10 @@ class GenerationContext:
write_rules: list[str]
design_rules: list[str]
template_styles: set[str]
prior_state: object | None = None # WriterState,避免循环 import 用 object
prior_summaries: str = "" # 前章摘要(design.md §6.9 章间引用,由编排层注入)
impact_report: object | None = None # ImpactReport 影响调查书(生成主上下文)
output_language: str = "auto" # "auto" | "zh" | "ja"(步骤 1:用户可选输出语言)
data_token_budget: int = MAX_CHAPTER_DATA_TOKENS # 本章数据 token 预算(OV5 DataGate
def _language_instruction(self) -> str:
"""根据 output_language 生成【语言约束】段的具体指令(步骤 1)。"""
@@ -137,11 +142,12 @@ class GenerationContext:
"sub_headings": "\n".join(
f"- {h}" for h in (getattr(tm, "sub_headings", None) or [])
),
"prior_state": str(self.prior_state) if self.prior_state is not None else "",
"prior_summaries": self.prior_summaries,
"language_instruction": self._language_instruction(),
"data": _format_chapter_data(
self.structured_source,
CHAPTER_SHEET_TYPES.get(self.chapter_id, []),
token_budget=self.data_token_budget,
),
"impact": _format_impact(
self.impact_report,
@@ -167,12 +173,47 @@ def _render_table(tb) -> list[str]:
return lines
def _format_chapter_data(structured_source: object | None, sheet_types: list[SheetType]) -> str:
_TRUNCATION_NOTE = "(注:以下表因超出上下文 token 预算被省略:{names}"
def _table_tokens(gate: DataGate, source: object, table) -> float:
"""单表 token 估算;单表即超预算时视为无穷大(优先被省略)。"""
try:
return gate.load(source, DataSelector(table_ids=[table.name])).token_estimate
except DataGateError:
return float("inf")
def _fits_budget(gate: DataGate, source: object, tables: list) -> bool:
try:
gate.load(source, DataSelector(table_ids=[t.name for t in tables]))
return True
except DataGateError:
return False
def _shrink_to_budget(tables: list, source: object, gate: DataGate) -> tuple[list, list[str]]:
"""按「最大表优先」省略,直到整体落入预算;返回 (保留表, 被省略表名)。"""
kept = list(tables)
omitted: list[str] = []
while kept and not _fits_budget(gate, source, kept):
biggest = max(kept, key=lambda t: _table_tokens(gate, source, t))
kept.remove(biggest)
omitted.append(biggest.name)
return kept, omitted
def _format_chapter_data(
structured_source: object | None,
sheet_types: list[SheetType],
token_budget: int = MAX_CHAPTER_DATA_TOKENS,
) -> str:
"""按章节定向格式化要件定义数据(design.md §6.8 ① selector=该章数据)。
- 命中 sheet_types 的表全部注入章节主题数据
- GENERIC自由記述作为通用背景始终注入
- structured_source None 或无任何可注入表时返回空串
- DataGate token 预算OV5超预算时按最大表优先省略并留省略标记不抛错阻断生成
"""
if structured_source is None:
return ""
@@ -185,11 +226,18 @@ def _format_chapter_data(structured_source: object | None, sheet_types: list[She
selected = matched + generic
if not selected:
return ""
gate = DataGate(max_total_tokens=token_budget)
kept, omitted = _shrink_to_budget(selected, structured_source, gate)
if not kept:
return _TRUNCATION_NOTE.format(names=", ".join(omitted))
lines: list[str] = []
for tb in selected:
for tb in kept:
lines.extend(_render_table(tb))
lines.append("")
return "\n".join(lines).rstrip()
text = "\n".join(lines).rstrip()
if omitted:
text += "\n\n" + _TRUNCATION_NOTE.format(names=", ".join(omitted))
return text
# 影响调查标签本地化(步骤 B):auto/ja 默认日文,zh 中文
+61 -15
View File
@@ -6,6 +6,7 @@ Impact Agent MVP2026-08-23):门控 = 用户是否提供既有系统(exi
"""
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
@@ -31,6 +32,34 @@ def _section_id_of(placeholder: str | None) -> str | None:
return placeholder[len("section:"):]
def _load_snapshot_contents(snapshot_path: str | None) -> dict:
"""读取章节快照的已生成内容;缺失/损坏 → 空 dict(从头生成,不阻断)。"""
if not snapshot_path:
return {}
path = Path(snapshot_path)
if not path.exists():
return {}
try:
loaded = WriterState.from_dict(json.loads(path.read_text(encoding="utf-8")))
except Exception: # 快照损坏不应阻断录制/生成
_LOGGER.warning("章节快照损坏,忽略并从头生成:%s", snapshot_path)
return {}
return {cid: c for cid, c in loaded.contents.items() if c is not None}
def _write_snapshot(state: WriterState, snapshot_path: str | None) -> None:
"""原子写章节快照(临时文件 + 替换),供重拍/续跑复用。"""
if not snapshot_path:
return
path = Path(snapshot_path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(state.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
)
tmp.replace(path)
def _warn_unanchored(ctxs) -> None:
"""防静默丢章:对缺少 {{section:<id>}} 锚点的章节打显式告警。
@@ -59,6 +88,7 @@ class WriteOrchestrator:
meta: dict | None = None,
output_language: str = "auto",
chapter_attempts: int = 3,
snapshot_path: str | None = None,
) -> list[ChapterContent]:
engine = engine or build_inference_engine()
prompt_registry = prompt_registry or PromptRegistry()
@@ -72,26 +102,25 @@ class WriteOrchestrator:
ctxs = build_contexts(structured_source, samples_dir, output_language=output_language)
_warn_unanchored(ctxs)
state = WriterState([c.chapter_id for c in ctxs])
# 快照命中:复用已生成章节,跳过 LLM(录制重拍不从头重跑)
for cid, cached in _load_snapshot_contents(snapshot_path).items():
if cid in state.contents:
state.contents[cid] = cached
state.versions[cid] = 1
agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state)
contents: list[ChapterContent] = []
sections: dict[str, list[Block]] = {}
for ctx in ctxs:
# 章级管道重试(#1/#2):真实 LLM 输出有随机方差,单章硬失败不连坐整次运行。
# 每轮管道尝试内部已含 WriterAgent.max_retries 次 LLM 调用;chapter_attempts 为
# 管道层兜底轮数(默认 3)。耗尽后仍抛错(不吞错)。
content: ChapterContent | None = None
last_err: Exception | None = None
for attempt in range(max(1, chapter_attempts)):
try:
content = agent.generate_chapter(ctx)
break
except WriterGenerationError as e:
last_err = e
_LOGGER.warning("章节 %s 生成失败(第 %d/%d 轮管道重试): %s",
ctx.chapter_id, attempt + 1, chapter_attempts, e)
if content is None:
raise WriterGenerationError(f"章节 {ctx.chapter_id} 管道重试耗尽: {last_err}")
# 章间引用(design.md §6.9):注入此前已生成章节的摘要(串行生成保证前章已就绪)
ctx.prior_summaries = state.summaries_before(ctx.chapter_id)
content = state.contents.get(ctx.chapter_id)
if content is not None:
_LOGGER.info("章节 %s 命中快照,跳过 LLM 生成", ctx.chapter_id)
else:
content = self._generate_with_retries(agent, ctx, chapter_attempts)
_write_snapshot(state, snapshot_path)
_LOGGER.info("章节 %s 生成完成(%d 块)", ctx.chapter_id, len(content.blocks))
contents.append(content)
blocks = render_chapter_blocks(content)
sec_id = _section_id_of(ctx.template_marker.section_placeholder)
@@ -111,3 +140,20 @@ class WriteOrchestrator:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc.save(output_path)
return contents
@staticmethod
def _generate_with_retries(agent: WriterAgent, ctx, chapter_attempts: int) -> ChapterContent:
"""章级管道重试(#1/#2):真实 LLM 输出有随机方差,单章硬失败不连坐整次运行。
每轮管道尝试内部已含 WriterAgent.max_retries LLM 调用chapter_attempts
管道层兜底轮数默认 3耗尽后仍抛错不吞错
"""
last_err: Exception | None = None
for attempt in range(max(1, chapter_attempts)):
try:
return agent.generate_chapter(ctx)
except WriterGenerationError as e:
last_err = e
_LOGGER.warning("章节 %s 生成失败(第 %d/%d 轮管道重试): %s",
ctx.chapter_id, attempt + 1, chapter_attempts, e)
raise WriterGenerationError(f"章节 {ctx.chapter_id} 管道重试耗尽: {last_err}")
+10 -4
View File
@@ -1,7 +1,7 @@
"""渲染:ChapterContent 的 ContentBlock 序列 → DocxInjector.Block 序列(Phase 5)。
字段塌缩外视 #6):table.headers/caption、list.items/style 在渲染时显式丢弃
仅保留 DocxInjector.Block 支持的 (kind, text, level, rows)
完整透传外视 #6 反转):tableheaders/caption、listitems/style 全部保留
使生成的 Word 表格带表头与边框列表带项目符号不再塌缩为纯文本
"""
from __future__ import annotations
@@ -15,9 +15,15 @@ def render_chapter_blocks(content: ChapterContent) -> list[Block]:
if b.type == "heading":
out.append(Block(kind="heading", text=b.text or b.caption or "", level=b.level or 1))
elif b.type == "table":
out.append(Block(kind="table", text=b.caption or "", rows=b.rows or []))
out.append(Block(
kind="table", text=b.caption or "",
headers=b.headers or [], rows=b.rows or [],
))
elif b.type == "list":
out.append(Block(kind="list", text="\n".join(b.items or [])))
out.append(Block(
kind="list", text="\n".join(b.items or []),
items=b.items or [], style=b.style or "bullet",
))
elif b.type == "note":
out.append(Block(kind="note", text=b.text or b.caption or ""))
else: # paragraph 及未知类型
+1 -9
View File
@@ -27,6 +27,7 @@ WRITER_PROMPT_TEMPLATE = (
"设计规则:\n{{design_rules}}\n"
"模板样式:\n{{template_styles}}\n"
"影响调查上下文:\n{{impact}}\n"
"前章摘要(已生成章节的要点,供本章跨章引用;勿重复展开):\n{{prior_summaries}}\n"
"参考资料(本章对应数据):\n{{data}}\n"
"请输出符合 schema 的章节内容 JSON。\n"
"【小节约束】若上方「本章小节结构」非空,必须按该小节顺序组织内容,"
@@ -81,13 +82,6 @@ class WriterAgent:
self.state = state
self.max_retries = max_retries
def _chunk_source(self, source) -> list[dict]:
if source is None:
return [{"index": 0, "text": ""}]
src = source if isinstance(source, str) else str(source)
n = max(1, len(src) // 1800 + 1)
return [{"index": i, "text": src[i * 1800:(i + 1) * 1800]} for i in range(n)]
def _resolve_prompt(self) -> Prompt:
"""取用/注册 writer.chapter 模板,并保证返回 Prompt 对象。
@@ -134,8 +128,6 @@ class WriterAgent:
return result.data
def generate_chapter(self, context: GenerationContext) -> ChapterContent:
self._chunk_source(context.structured_source) # 分块可用性验证(真实拼回留待后续)
# 步骤 A:推导本章期望输出语言(仅依赖 context,循环前置,稳定)
# fallback 用「规则文档」(write_rules/design_rulesRAG 自日文作成说明书/记入规则检索,
# 含假名可判日文)。不能用 impact/data(源数据):样本含中文元素名(止损风控等)、
+58 -1
View File
@@ -1,12 +1,16 @@
"""Writer 跨章状态(Phase 5)。追踪各章版本/内容/最近评估结果。"""
from __future__ import annotations
from genesis.writer.models import ChapterContent
import dataclasses
from genesis.writer.models import ChapterContent, ContentBlock
from genesis.eval.scorer import EvalReport
class WriterState:
def __init__(self, chapter_order: list[str]) -> None:
# 保留模板章节顺序,供章间引用(design.md §6.9)判断「前章」
self.order: list[str] = list(chapter_order)
self.versions: dict[str, int] = {cid: 0 for cid in chapter_order}
self.contents: dict[str, ChapterContent | None] = {cid: None for cid in chapter_order}
self.last_eval: dict[str, EvalReport | None] = {cid: None for cid in chapter_order}
@@ -18,6 +22,59 @@ class WriterState:
def record_eval(self, cid: str, report: EvalReport) -> None:
self.last_eval[cid] = report
# ---------- 章间引用摘要(design.md §6.9 ----------
def summary_of(self, chapter_id: str) -> str | None:
"""单章摘要:章 id + 标题 + 各表结构(表头 + 行数)。未生成 → None。"""
content = self.contents.get(chapter_id)
if content is None:
return None
parts = [f"[{chapter_id}] {content.title}"]
for b in content.blocks:
if b.type == "table" and b.headers:
parts.append(f" 表: {' | '.join(b.headers)}{len(b.rows or [])} 行)")
return "\n".join(parts)
def summaries_before(self, chapter_id: str) -> str:
"""本章之前已生成章节的摘要集合(token 友好,供后章 prompt 引用)。"""
if chapter_id not in self.order:
return ""
idx = self.order.index(chapter_id)
return "\n".join(
s for cid in self.order[:idx] if (s := self.summary_of(cid))
)
# ---------- 落盘快照(P0-b:录制重拍不从头重跑) ----------
def to_dict(self) -> dict:
"""序列化为 JSON 友好 dict(仅含已生成章节)。"""
return {
"order": list(self.order),
"chapters": {
cid: {
"chapter_id": c.chapter_id,
"version": c.version,
"title": c.title,
"blocks": [dataclasses.asdict(b) for b in c.blocks],
}
for cid, c in self.contents.items() if c is not None
},
}
@classmethod
def from_dict(cls, data: dict) -> "WriterState":
state = cls(list(data.get("order", [])))
for cid, payload in (data.get("chapters") or {}).items():
content = ChapterContent(
chapter_id=payload.get("chapter_id", cid),
version=payload.get("version", 1),
title=payload.get("title", ""),
blocks=[ContentBlock(**b) for b in payload.get("blocks", [])],
)
state.contents[cid] = content
state.versions[cid] = state.versions.get(cid, 0) + 1
return state
def needs_regeneration(self) -> list[str]:
out: list[str] = []
for cid, content in self.contents.items():
+9 -1
View File
@@ -105,4 +105,12 @@ def test_rerank_env_override(monkeypatch):
monkeypatch.setenv("GENESIS_RAG__RERANK__ENABLED", "false")
s = Settings.from_dir(FIXTURES)
assert s.rag.rerank.enabled is False
assert s.rag.rerank.model == "BAAI/bge-reranker-v2-m3"
assert s.rag.rerank.model == "BAAI/bge-reranker-v2-m3"
# ---------- P2-2allowed_extensions 不残留旧版 .xls(与解析层对齐) ----------
def test_default_allowed_extensions_excludes_legacy_xls():
s = Settings()
assert ".xls" not in s.app.server.allowed_extensions
assert ".xlsx" in s.app.server.allowed_extensions
+170 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import pytest
from docx import Document
from docx.shared import Pt
from genesis.writer.docx_injector import (
Block,
@@ -31,7 +32,8 @@ def _section_blocks() -> list[Block]:
return [
Block(kind="heading", text="3.1 テーブル一覧", level=2),
Block(kind="paragraph", text="以下がDB表定义です。"),
Block(kind="table", rows=[["テーブル", "説明"], ["TB001", "社員"]]),
Block(kind="table", text="テーブル一覧", headers=["テーブル", "説明"],
rows=[["TB001", "社員"]]),
]
@@ -183,3 +185,170 @@ def test_duplicate_subheading_with_content_not_removed(tmp_path):
texts = [p.text for p in out.paragraphs]
# 模板子节有内容 → 不删(保守策略:仅删除完全空的重复标题)
assert "テーブル定義は別紙参照。" in texts
# ---------- 表格:表头行 + 边框 + 标题(外视 #6 反转) ----------
def test_table_header_row_rendered(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
tbl = out.tables[0]
assert [c.text for c in tbl.rows[0].cells] == ["テーブル", "説明"]
assert [c.text for c in tbl.rows[1].cells] == ["TB001", "社員"]
def test_table_has_grid_borders(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
assert out.tables[0].style is not None
assert out.tables[0].style.name == "Table Grid"
def test_table_caption_rendered(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
assert any("テーブル一覧" in p.text for p in out.paragraphs)
# ---------- 列表:逐项 + 样式(外视 #6 反转) ----------
def test_list_block_uses_bullet_style(tmp_path):
tpl = _make_template(tmp_path, "{{section:x}}")
blocks = [Block(kind="list", items=["項目一", "項目二"], style="bullet")]
out = DocxInjector(tpl).inject({"x": blocks}, {"doc_title": "T"})
bullets = [p for p in out.paragraphs if p.style.name == "List Bullet"]
assert [p.text for p in bullets] == ["項目一", "項目二"]
def test_list_block_uses_numbered_style(tmp_path):
tpl = _make_template(tmp_path, "{{section:x}}")
blocks = [Block(kind="list", items=["A", "B"], style="numbered")]
out = DocxInjector(tpl).inject({"x": blocks}, {"doc_title": "T"})
numbered = [p for p in out.paragraphs if p.style.name == "List Number"]
assert [p.text for p in numbered] == ["A", "B"]
# ---------- 行内替换:保留字符格式 ----------
def test_inline_replacement_preserves_run_format(tmp_path):
doc = Document()
para = doc.add_paragraph()
run = para.add_run("標題:{{doc_title}}")
run.font.size = Pt(20)
run.bold = True
path = tmp_path / "tpl_fmt.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({}, {"doc_title": "概要設計書"})
out_para = out.paragraphs[0]
assert out_para.text == "標題:概要設計書"
assert out_para.runs[0].font.size == Pt(20)
assert out_para.runs[0].bold is True
# ---------- 防御分支:非数字 Heading / 无 run / 多 run 跨段 ----------
def test_heading_level_non_numeric_tail_returns_none():
assert DocxInjector._heading_level("Heading X") is None
assert DocxInjector._heading_level(None) is None
def test_bare_subheading_followed_by_table_is_kept(tmp_path):
"""裸子节后紧跟表格 → 非「纯空」(表格为其内容),保留不删。"""
doc = Document()
doc.add_paragraph("{{section:x}}")
doc.add_paragraph("補足", style="Heading 2")
doc.add_paragraph("") # 空段落 → 扫描继续
doc.add_table(rows=1, cols=1) # 表格 → 终止「裸」判定
path = tmp_path / "tpl_tbl.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({"x": []}, {"doc_title": "T"})
assert any(p.text.strip() == "補足" for p in out.paragraphs)
def test_set_paragraph_text_adds_run_when_none(tmp_path):
"""无 run 的段落:走 add_run 分支且不报错。"""
target = Document().add_paragraph()
DocxInjector(str(tmp_path / "unused.docx"))._set_paragraph_text(target, "新文本")
assert target.text == "新文本"
def test_inline_replacement_across_multiple_runs(tmp_path):
"""行内占位符跨多个 run → 保留首个 run 格式并清空其余 run。"""
doc = Document()
para = doc.add_paragraph()
r1 = para.add_run("{{")
r1.bold = True
para.add_run("doc_title}}")
path = tmp_path / "tpl_multi.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({}, {"doc_title": "表題"})
p = out.paragraphs[0]
assert p.text == "表題"
assert p.runs[0].bold is True
# ---------- P2-1:表格单元格内的占位符注入(此前只扫正文段落 → 漏注入/漏检残留) ----------
def _all_text(doc) -> str:
"""正文段落 + 表格单元格段落的全部文本(用于单元格注入断言)。
合并单元格的 row.cells 会重复指向同一 tc tc 去重避免重复计数
"""
parts = [p.text for p in doc.paragraphs]
seen_tc: set = set()
for tbl in doc.tables:
for row in tbl.rows:
for cell in row.cells:
if id(cell._tc) in seen_tc:
continue
seen_tc.add(id(cell._tc))
parts.extend(p.text for p in cell.paragraphs)
return "\n".join(parts)
def _cell_anchor_template(tmp_path, inner="{{section:db_design}}", name="tpl_cell.docx") -> str:
doc = Document()
doc.add_paragraph("{{doc_title}}")
tbl = doc.add_table(rows=1, cols=1)
tbl.rows[0].cells[0].paragraphs[0].text = inner
doc.add_paragraph("尾部固定内容")
path = tmp_path / name
doc.save(str(path))
return str(path)
def test_section_placeholder_inside_table_cell_replaced(tmp_path):
tpl = _cell_anchor_template(tmp_path)
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
text = _all_text(out)
assert "3.1 テーブル一覧" in text # 标题块注入进单元格
assert "以下がDB表定义です。" in text
assert "{{section:db_design}}" not in text # 占位符已被替换
def test_inline_meta_inside_table_cell_replaced(tmp_path):
tpl = _cell_anchor_template(tmp_path, inner="{{doc_title}}", name="tpl_cell_meta.docx")
out = DocxInjector(tpl).inject({}, {"doc_title": "表題"})
text = _all_text(out)
assert "表題" in text and "{{doc_title}}" not in text
def test_residue_inside_table_cell_detected(tmp_path):
"""单元格内未替换的占位符必须被残留检查捕获(否则静默丢内容)。"""
tpl = _cell_anchor_template(tmp_path, inner="{{section:unknown}}", name="tpl_cell_res.docx")
with pytest.raises(DocxInjectError, match="残留"):
DocxInjector(tpl).inject({}, {"doc_title": "T"})
def test_merged_cell_placeholder_injected_once(tmp_path):
doc = Document()
tbl = doc.add_table(rows=1, cols=2)
tbl.rows[0].cells[0].merge(tbl.rows[0].cells[1])
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:x}}"
path = tmp_path / "tpl_merge.docx"
doc.save(str(path))
blocks = [Block(kind="paragraph", text="注入内容")]
out = DocxInjector(str(path)).inject({"x": blocks}, {})
assert _all_text(out).count("注入内容") == 1
+9 -2
View File
@@ -17,8 +17,15 @@ def test_open_workbook_and_sheet_matrix(tmp_path):
assert sheet_matrix(ws) == [["機能ID", "機能名"], ["F001", "社員登録"]]
def test_open_workbook_rejects_xls(tmp_path):
def test_open_workbook_rejects_legacy_xls_actionably(tmp_path):
bad = tmp_path / "old.xls"
bad.write_bytes(b"not really xls")
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="另存为 .xlsx"):
open_workbook(bad)
def test_open_workbook_rejects_other_extension(tmp_path):
bad = tmp_path / "note.csv"
bad.write_text("a,b", encoding="utf-8")
with pytest.raises(ValueError, match="不支持的 Excel 格式"):
open_workbook(bad)
+12
View File
@@ -242,3 +242,15 @@ def test_to_vars_exposes_sub_headings():
def test_to_vars_sub_headings_empty_when_none():
vars_ = _ctx("db_design", "DB設計").to_vars()
assert vars_["sub_headings"] == ""
# ---------- 章间引用摘要注入(design.md §6.9 ----------
def test_to_vars_exposes_prior_summaries():
ctx = _ctx("db_design", "DB設計", source=None)
ctx.prior_summaries = "前章摘要"
assert ctx.to_vars()["prior_summaries"] == "前章摘要"
def test_to_vars_prior_summaries_defaults_empty():
assert _ctx("db_design", "DB設計", source=None).to_vars()["prior_summaries"] == ""
+18 -5
View File
@@ -6,8 +6,9 @@ def _content():
blocks = [
ContentBlock(block_id="1", type="heading", level=2, text="小節"),
ContentBlock(block_id="2", type="paragraph", text="正文"),
ContentBlock(block_id="3", type="table", caption="表1", rows=[["a", "b"], ["1", "2"]]),
ContentBlock(block_id="4", type="list", items=["項目一", "項目二"]),
ContentBlock(block_id="3", type="table", caption="表1",
headers=["見出しA", "見出しB"], rows=[["a", "b"], ["1", "2"]]),
ContentBlock(block_id="4", type="list", items=["項目一", "項目二"], style="bullet"),
ContentBlock(block_id="5", type="note", text="注意"),
]
return ChapterContent(chapter_id="db_design", version=1, title="DB 設計", blocks=blocks)
@@ -17,9 +18,21 @@ def test_render_maps_all_block_types():
out = render_chapter_blocks(_content())
kinds = [b.kind for b in out]
assert kinds == ["heading", "paragraph", "table", "list", "note"]
# 表:rows 透传
def test_render_preserves_table_headers_and_caption():
# 外视 #6 反转:表头/标题不再塌缩,须透传至注入器
out = render_chapter_blocks(_content())
table = out[2]
assert table.kind == "table"
assert table.headers == ["見出しA", "見出しB"]
assert table.text == "表1" # caption
assert table.rows == [["a", "b"], ["1", "2"]]
# 列表:items 拼接进 text
assert "項目一" in out[3].text and "項目二" in out[3].text
def test_render_preserves_list_items_and_style():
out = render_chapter_blocks(_content())
lst = out[3]
assert lst.kind == "list"
assert lst.items == ["項目一", "項目二"]
assert lst.style == "bullet"
+1 -1
View File
@@ -38,7 +38,7 @@ def _ctx(cid, title):
write_rules=["W1"],
design_rules=["D1"],
template_styles={"Heading1"},
prior_state=None,
prior_summaries="",
)
+86
View File
@@ -202,3 +202,89 @@ def test_generate_missing_template_path_raises(tmp_path):
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
+38 -1
View File
@@ -1,6 +1,6 @@
"""WriterState 测试(P5-T5)。"""
from genesis.writer.writer_state import WriterState
from genesis.writer.models import ChapterContent
from genesis.writer.models import ChapterContent, ContentBlock
from genesis.eval.scorer import EvalReport
@@ -30,3 +30,40 @@ def test_failed_eval_marks_regeneration():
st.record_success(_content("a"))
st.record_eval("a", _report(["a"]))
assert st.needs_regeneration() == ["a"]
# ---------- 章间引用摘要(design.md §6.9 ----------
def _content_with_table(cid, title, headers, nrows):
rows = [[str(i) for _ in headers] for i in range(nrows)]
return ChapterContent(chapter_id=cid, version=1, title=title, blocks=[
ContentBlock(block_id="b1", type="table", headers=headers, rows=rows),
])
def test_summary_of_includes_title_and_table_structure():
st = WriterState(["a"])
st.record_success(_content_with_table("a", "機能一覧", ["機能ID", "機能名"], 3))
s = st.summary_of("a")
assert "a" in s and "機能一覧" in s
assert "機能ID" in s and "機能名" in s
assert "3" in s
def test_summary_of_none_when_not_generated():
st = WriterState(["a"])
assert st.summary_of("a") is None
def test_summaries_before_respects_order():
st = WriterState(["a", "b", "c"])
st.record_success(_content_with_table("a", "第一章", ["ID"], 1))
st.record_success(_content_with_table("b", "第二章", ["ID"], 2))
before_c = st.summaries_before("c")
assert "第一章" in before_c and "第二章" in before_c
assert st.summaries_before("a") == ""
def test_summaries_before_unknown_chapter_empty():
st = WriterState(["a"])
assert st.summaries_before("zzz") == ""
+27
View File
@@ -75,6 +75,33 @@ def test_create_session_name_and_project(tmp_path):
assert got.project == "projA"
# ---------- P2-2:上传即校验扩展名(EXPECTED_SUFFIX 生效;.xls 显式拒绝) ----------
def test_upload_rejects_legacy_xls_requirements(svc):
s = svc.create_session("u1")
with pytest.raises(FileTypeError, match="另存为 .xlsx"):
svc.upload_file(s.session_id, "requirements", "old.xls", b"legacy-biff")
def test_upload_rejects_xlsx_rules_aligning_with_parser(svc):
"""rules 实际仅支持 .docxsource_aggregator 侧)→ 上传即拒绝 .xlsx。"""
s = svc.create_session("u1")
with pytest.raises(FileTypeError, match="不支持的扩展名"):
svc.upload_file(s.session_id, "rules", "rules.xlsx", b"x")
def test_upload_rejects_wrong_suffix_for_template(svc):
s = svc.create_session("u1")
with pytest.raises(FileTypeError, match="不支持的扩展名"):
svc.upload_file(s.session_id, "template", "tpl.xlsx", b"x")
def test_upload_accepts_docx_rules(svc):
s = svc.create_session("u1")
svc.upload_file(s.session_id, "rules", "rules_entry_ja.docx", b"PK\x03\x04")
assert svc.get_session(s.session_id).files["rules"]["name"] == "rules_entry_ja.docx"
def test_upload_requirements_auto_names_session(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
+15
View File
@@ -108,6 +108,21 @@ def test_parse_extensionless_rule(tmp_path):
SourceParser().parse(rule_paths=[bad])
# ---------- P2-2:旧版 .xls 显式拒绝(可操作消息,非含糊格式错误) ----------
def test_parse_legacy_xls_requirement_rejected_actionably(tmp_path):
bad = tmp_path / "old.xls"
bad.write_bytes(b"legacy-biff")
with pytest.raises(ValueError, match="另存为 .xlsx"):
SourceParser().parse(requirement_paths=[bad])
def test_xlsx_exts_excludes_legacy_xls():
from genesis.parsers.source_aggregator import LEGACY_EXCEL_EXTS, XLSX_EXTS
assert XLSX_EXTS == (".xlsx",)
assert ".xls" in LEGACY_EXCEL_EXTS
# ---------- T7: DRY _validate_path helperI8 ----------
def test_validate_path_rejects_bad_extension(tmp_path):
+47
View File
@@ -130,3 +130,50 @@ def test_parse_extracts_placeholder_fullwidth_colon(tmp_path):
assert result.placeholders == {"section:2": "{{section2}}"}
ph = [s for s in result.sections if s.type == "placeholder"]
assert [s.name for s in ph] == ["section:2"]
# ---------- P2-1:表格单元格内的占位符(此前漏检 → 静默丢章) ----------
def _doc_with_cell_placeholder():
"""H1 → 表格(单元格内含锚点) → H1。锚点必须归属其前的 H1。"""
doc = new_document()
doc.add_heading("1. はじめに", level=1)
tbl = doc.add_table(rows=1, cols=1)
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:introduction}}"
doc.add_heading("2. 機能一覧", level=1)
doc.add_paragraph("{{section:function_list}}")
return doc
def test_parse_placeholder_inside_table_cell(tmp_path):
path = save_document(tmp_path, _doc_with_cell_placeholder())
result = WordTemplateParser().parse(path)
assert "section:introduction" in result.placeholders
ph = [s.name for s in result.sections if s.type == "placeholder"]
assert "section:introduction" in ph
def test_parse_table_placeholder_keeps_document_order(tmp_path):
"""单元格锚点须按文档顺序落位(在其前的 H1 与在其后的 H1 之间),不得错绑。"""
path = save_document(tmp_path, _doc_with_cell_placeholder())
result = WordTemplateParser().parse(path)
names = [(s.type, s.name) for s in result.sections]
i_intro = names.index(("heading", "1. はじめに"))
i_ph = names.index(("placeholder", "section:introduction"))
i_func = names.index(("heading", "2. 機能一覧"))
assert i_intro < i_ph < i_func
def test_parse_merged_cell_placeholder_not_duplicated(tmp_path):
"""合并单元格的 row.cells 会重复指向同一 tc → 不得重复收集。"""
doc = new_document()
doc.add_heading("1. 章", level=1)
tbl = doc.add_table(rows=1, cols=2)
tbl.rows[0].cells[0].merge(tbl.rows[0].cells[1])
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:db_design}}"
path = save_document(tmp_path, doc)
result = WordTemplateParser().parse(path)
ph = [s.name for s in result.sections if s.type == "placeholder"]
assert ph.count("section:db_design") == 1
+57
View File
@@ -0,0 +1,57 @@
"""WriterAgent 防御分支覆盖(P0 补测)。
覆盖_resolve_prompt 直返回 Prompt引擎异常状态无详情LLM 返回非法 data 触发重试
"""
import pytest
from types import SimpleNamespace
from genesis.inference.types import Prompt
from genesis.writer.writer_agent import WriterAgent, WriterGenerationError
from genesis.writer.writer_state import WriterState
from genesis.writer.models import ChapterSpec, GenerationContext
class _Registry:
def get_or_create(self, name, template):
return Prompt(name=name, version="1", template=template)
class _FailedEngine:
def chat_structured(self, **kw):
return SimpleNamespace(status="failed", data=None) # 无 error / error_code
class _BadDataEngine:
def chat_structured(self, **kw):
return SimpleNamespace(status="ok", data={"title": "x", "blocks": None})
def _ctx():
return GenerationContext(
chapter_id="db_design", title="DB 設計",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 設計",
section_placeholder=None),
structured_source="src", write_rules=["W"], design_rules=["D"],
template_styles={"Heading 1"}, output_language="ja",
)
def _agent(engine, max_retries=1):
return WriterAgent(
session_id="s", engine=engine, prompt_registry=_Registry(),
state=WriterState(["db_design"]), max_retries=max_retries,
)
def test_resolve_prompt_passthrough_when_registry_returns_prompt():
assert isinstance(_agent(_FailedEngine())._resolve_prompt(), Prompt)
def test_engine_failed_status_without_details_raises():
with pytest.raises(WriterGenerationError, match="failed"):
_agent(_FailedEngine()).generate_chapter(_ctx())
def test_invalid_llm_data_triggers_retry_and_raises():
with pytest.raises(WriterGenerationError, match="重试耗尽"):
_agent(_BadDataEngine()).generate_chapter(_ctx())
+119
View File
@@ -0,0 +1,119 @@
"""Writer 章节数据 token 预算(OV5 DataGate 接入)。
背景design.md §6.8 要求 `DataGate.load(structured_source, selector=该章数据)`
但此前 `_format_chapter_data` 直接渲染未过 DataGate writer_agent
`_chunk_source` 只做可用性验证不生效死代码本文件验证
- 章节数据超出 token 预算时按最大表优先省略并留下省略标记不抛错不阻断生成
- 预算可由 GenerationContext.data_token_budget 注入
- 死代码 _chunk_source 已移除
"""
from genesis.data_models import (
CellValue,
ExcelTable,
ParsedTemplate,
ChapterMarker,
Provenance,
SheetType,
StructuredSource,
)
from genesis.orchestrator.datagate import DataGate, DataSelector
from genesis.writer.models import ChapterSpec, GenerationContext
from genesis.writer.writer_agent import WriterAgent
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 _source(tables):
template = ParsedTemplate(
file_name="t.docx",
sections=[ChapterMarker(type="heading", name="x", level=1)],
placeholders={},
styles={"used": []},
)
return StructuredSource(
tables=tables, template=template, rule_docs=[], image_analyses=[],
existing_system=None, comments=[],
)
def _ctx(chapter_id, source, budget=None):
kwargs = {} if budget is None else {"data_token_budget": budget}
return GenerationContext(
chapter_id=chapter_id, title="機能一覧",
template_marker=ChapterSpec(
chapter_id=chapter_id, title="機能一覧",
section_placeholder=f"section:{chapter_id}",
),
structured_source=source,
write_rules=[], design_rules=[], template_styles=set(),
**kwargs,
)
def _small_table():
return _table("機能一覧", SheetType.FUNCTION, ["機能ID", "機能名"], [["F001", "止損"]])
def _big_table():
rows = [[f"F{i:03d}", "非常に長い説明文" * 20] for i in range(200)]
return _table("大量機能", SheetType.FUNCTION, ["機能ID", "説明"], rows)
# ---------- 预算常量 ----------
def test_default_data_token_budget_exposed():
from genesis.writer.models import MAX_CHAPTER_DATA_TOKENS
assert MAX_CHAPTER_DATA_TOKENS == 8_000
# ---------- 超预算:省略 + 标记(不抛错) ----------
def test_tiny_budget_omits_all_tables_with_marker():
src = _source([_small_table()])
data = _ctx("function_list", src, budget=1).to_vars()["data"]
# 预算=1 → 无表可留 → 仅省略标记,且不抛 DataGateError
assert data == "" or "省略" in data
assert "F001" not in data
def test_budget_drops_largest_table_and_keeps_smaller():
small, big = _small_table(), _big_table()
src = _source([small, big])
# 测量用 gate 需足够大(大表单独即超默认 8000 预算,否则测量本身会抛错)
gate = DataGate(max_total_tokens=10 ** 9)
small_tokens = gate.load(src, DataSelector(table_ids=["機能一覧"])).token_estimate
big_tokens = gate.load(src, DataSelector(table_ids=["大量機能"])).token_estimate
assert big_tokens > small_tokens
# 预算仅够小表 → 大表被省略,小表内容保留,标记点名被省略的表
data = _ctx("function_list", src, budget=small_tokens + 1).to_vars()["data"]
assert "F001" in data and "止損" in data
assert "大量機能" not in data or "省略" in data
assert "省略" in data and "大量機能" in data
# ---------- 预算内:原样渲染 ----------
def test_within_budget_renders_without_marker():
src = _source([_small_table()])
data = _ctx("function_list", src).to_vars()["data"]
assert "F001" in data and "止損" in data
assert "省略" not in data
# ---------- 死代码已移除 ----------
def test_writer_agent_has_no_dead_chunk_source():
assert not hasattr(WriterAgent, "_chunk_source")
+141
View File
@@ -0,0 +1,141 @@
"""录制稳健性:章节结果落盘快照 + 可恢复(P0-b)。
真实 LLM 长耗时串行生成录制重拍时不应从头重跑本文件验证
- 每章生成后写快照JSON
- 重跑时命中快照的章节跳过 LLM即使引擎不可用也能完成文档
- 快照损坏时忽略并从头生成
- WriterState JSON 往返
"""
import json
from types import SimpleNamespace
from docx import Document
from genesis.data_models import ChapterMarker, ParsedTemplate
from genesis.writer.orchestrator import WriteOrchestrator
from genesis.writer.writer_state import WriterState
from genesis.writer.models import ChapterContent, ContentBlock
class RecordingEngine:
def __init__(self):
self.calls = []
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
self.calls.append(variables["title"])
return SimpleNamespace(
data={"title": variables["title"], "blocks": [
{"type": "paragraph", "text": f"{variables['title']}の内容"},
{"type": "table", "headers": ["ID", "名称"], "rows": [["1", "a"]]},
]},
status="ok",
)
class ExplodingEngine:
"""若被调用即失败:用于证明快照命中时不再走 LLM。"""
def chat_structured(self, **kwargs):
raise AssertionError("不应调用 LLM(章节快照应命中)")
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 _parsed(tpl):
return ParsedTemplate(
file_name=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"]},
)
def _run(tpl, out, engine, snap):
return WriteOrchestrator().generate(
SimpleNamespace(template=_parsed(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=engine,
template_path=str(tpl), snapshot_path=str(snap),
)
def test_snapshot_written_with_all_chapters(tmp_path):
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
_run(tpl, out, RecordingEngine(), snap)
payload = json.loads(snap.read_text(encoding="utf-8"))
assert set(payload["chapters"]) == {"function_list", "screen_list"}
assert payload["chapters"]["function_list"]["blocks"][0]["type"] == "paragraph"
def test_resume_from_snapshot_skips_llm(tmp_path):
tpl, out1, snap = tmp_path / "t.docx", tmp_path / "o1.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
_run(tpl, out1, RecordingEngine(), snap)
# 第二次:引擎会爆炸,但快照命中 → 不调用 LLM,仍产出完整文档
out2 = tmp_path / "o2.docx"
_run(tpl, out2, ExplodingEngine(), snap)
joined = "\n".join(p.text for p in Document(str(out2)).paragraphs)
assert "第1章 機能一覧の内容" in joined
assert "第2章 画面一覧の内容" in joined
def test_corrupt_snapshot_ignored_and_regenerated(tmp_path):
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
snap.write_text("{ this is not json", encoding="utf-8")
engine = RecordingEngine()
_run(tpl, out, engine, snap)
assert len(engine.calls) == 2 # 损坏快照 → 从头生成
assert json.loads(snap.read_text(encoding="utf-8"))["chapters"] # 并重写有效快照
def test_snapshot_with_extra_chapter_ignored(tmp_path):
"""快照含当前模板没有的章节 id → 忽略该章,其余照常生成。"""
tpl, out, snap = tmp_path / "t.docx", tmp_path / "o.docx", tmp_path / "snap.json"
_two_chapter_template(str(tpl))
snap.write_text(json.dumps({
"order": ["function_list", "screen_list", "ghost"],
"chapters": {"ghost": {"chapter_id": "ghost", "version": 1,
"title": "幽灵章", "blocks": []}},
}, ensure_ascii=False), encoding="utf-8")
engine = RecordingEngine()
_run(tpl, out, engine, snap)
assert len(engine.calls) == 2 # 两章均重新生成,幽灵章被忽略
def test_generate_with_explicit_meta_skips_defaults(tmp_path):
"""显式传入 meta → 不再构造默认 meta(覆盖分支)。"""
tpl, out = tmp_path / "t.docx", tmp_path / "o.docx"
_two_chapter_template(str(tpl))
contents = WriteOrchestrator().generate(
SimpleNamespace(template=_parsed(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=RecordingEngine(),
template_path=str(tpl),
meta={"doc_title": "T", "version": "v9", "created_at": "2026-01-01"},
)
assert len(contents) == 2
def test_writer_state_dict_roundtrip():
st = WriterState(["a"])
st.record_success(ChapterContent(chapter_id="a", version=1, title="T", blocks=[
ContentBlock(block_id="1", type="table", headers=["H1", "H2"], rows=[["x", "y"]]),
]))
st2 = WriterState.from_dict(st.to_dict())
assert st2.summary_of("a") == st.summary_of("a")
assert st2.to_dict()["chapters"]["a"]["blocks"][0]["headers"] == ["H1", "H2"]