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:
@@ -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:仅同步 main,29 提交 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 修复(W1,Writer 输出完整性):①表格表头行/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 收窄为 .xlsx,service 接入 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 |
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}} / {{section:id}}(大小写/全角冒号)
|
||||
_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
|
||||
|
||||
@@ -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 中文
|
||||
|
||||
@@ -6,6 +6,7 @@ Impact Agent MVP(2026-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}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""渲染:ChapterContent 的 ContentBlock 序列 → DocxInjector.Block 序列(Phase 5)。
|
||||
|
||||
字段塌缩(外视 #6):table.headers/caption、list.items/style 在渲染时显式丢弃,
|
||||
仅保留 DocxInjector.Block 支持的 (kind, text, level, rows)。
|
||||
完整透传(外视 #6 反转):table 的 headers/caption、list 的 items/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 及未知类型
|
||||
|
||||
@@ -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_rules,RAG 自日文作成说明书/记入规则检索,
|
||||
# 含假名可判日文)。不能用 impact/data(源数据):样本含中文元素名(止损风控等)、
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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-2:allowed_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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] == ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,7 +38,7 @@ def _ctx(cid, title):
|
||||
write_rules=["W1"],
|
||||
design_rules=["D1"],
|
||||
template_styles={"Heading1"},
|
||||
prior_state=None,
|
||||
prior_summaries="",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") == ""
|
||||
|
||||
@@ -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 实际仅支持 .docx(source_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")),
|
||||
|
||||
@@ -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 helper(I8) ----------
|
||||
|
||||
def test_validate_path_rejects_bad_extension(tmp_path):
|
||||
|
||||
@@ -130,3 +130,50 @@ def test_parse_extracts_placeholder_fullwidth_colon(tmp_path):
|
||||
assert result.placeholders == {"section:2": "{{section:2}}"}
|
||||
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
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user