feat(writer): docx 注入原型提前(T17 架构审查整改,OV8)

- T17 (OV8, P1): 新建 src/genesis/writer/ 包
  - docx_injector.py: DocxInjector 用原生 python-docx 实现 §6.6 占位符注入
    - 章节级 {{section:id}} → 内容块 docx 元素序列(heading/paragraph/table)
    - 行内 {{meta}} → 元信息填充
    - 残留检查: 未替换 {{...}} 抛 DocxInjectError
    - 格式精度: 注入 heading 继承模板 Heading 样式,原内容不被破坏
  - 新增 test_docx_injector.py(5 用例)
  - 同步 design.md §6.7 渲染链路 T17 原型说明
- TDD: RED(模块缺失)→ GREEN(聚焦 5 passed)→ 全量 245 passed / 100.00%(1361 stmts/340 br)
This commit is contained in:
lhl
2026-08-12 22:47:43 +08:00
parent d2e651bad0
commit e3c714d5d9
5 changed files with 236 additions and 0 deletions
+1
View File
@@ -80,4 +80,5 @@
| 2026-08-11 | Agent 实现 | T6+T11(架构审查整改,Lane B):v1 rerank 精排 + bge-m3 多语言切换(Issue6 + OV2)。config.py 新增 RerankConfigenabled=True/model=BAAI/bge-reranker-v2-m3/device=cpu)挂入 RagConfigEmbeddingConfig.model 默认 bge-small-zh-v1.5 → BAAI/bge-m3(实际语料日文);rag-layer-design.md 新增 §2.3 多语言与日文样本验证、§6.3 Rerank 精排(窗口=RRF top-10、候选≤top_k 跳过、故障降级 RRF 原序),原 §6.3-6.6 顺延 6.4-6.7;选型表/依赖表/manifest/流程图 bge-small-zh → bge-m3config-design.md embedding 默认 + 新增 rerank 段;design.md §5.5 与 implementation-plan 4.3 同步;新增 tests/test_rag_design_consistency.py 一致性门禁(6 用例:代码默认/fixture 同步/4 文档用 bge-m3+reranker/无 legacy 引用);TDD 验证 RED(默认模型仍旧+rerank 字段不存在)→ GREEN(聚焦 13 passed)→ 全量 198 passed 覆盖 100.00%996 stmts/252 br),fail_under=99 达标 | src/genesis/config.py, tests/test_config.py, tests/test_rag_design_consistency.py, tests/fixtures/rag.yaml, docs/rag-layer-design.md, docs/config-design.md, docs/design.md, docs/implementation-plan.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T12(架构审查整改,P1):URI 统一 + resolver + 强验证(OV3)。新建 src/genesis/parsers/resolver.pyparse_source_uri(解析 file.xlsx#Sheet!CellRef → SourceRef,格式非法 raise URIError)、provenance_to_uriProvenance 还原,与 build 互逆)、resolve_source_uriStructuredSource 内定位真实 CellValue)、validate_source_uris(批量强验证 → ValidationResult(resolved/unresolved),格式错误或源中不存在一律 unresolved,防 QA#8 编造 URI 作弊);统一 URI 唯一生成入口 build_source_uriprovenance.py),formatting_detector 经其生成,无散落不一致;新建 tests/test_resolver.py13 用例:解析/往返/定位/批量验证/防御分支);同步 design.md §9.2 机制化说明 + §6.8 第五步存在性校验引用;TDD 验证 RED(模块缺失)→ GREEN(聚焦 10 passed)→ 全量 231 passed 覆盖 100.00%1191 stmts/298 br),fail_under=99 达标 | src/genesis/parsers/resolver.py, tests/test_resolver.py, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T13(架构审查整改,P1):黄金集 + 评分器(OV4)。新建 src/genesis/eval/ 包:golden_set.pyGoldenCase/GoldenSetYAML 加载,samples/ 真实脱敏样本作 input_ref 基线)、scorer.pyChapterScorer 按 §7.2 指标体系打分);确定性维度 traceabilityresolver 验证 source_uri 可解析率)/placeholder_residue(无 {{...}} 残留)/chapter_completeness(章节覆盖期望集合);LLM 语义维度经 llm_evaluators 钩子注入(默认中性分,待 Phase5);新增 tests/test_eval_scorer.py9 用例:各维度/聚合/钩子/空输入);tests/fixtures/eval/golden_set.yaml 示例黄金集(2 case);同步 design.md §7.5 黄金集与评分器机制化说明(定位为 CI 质量门禁);TDD 验证 RED(模块缺失 + NameError)→ GREEN(聚焦 8 passed)→ 全量 240 passed 覆盖 100.00%1279 stmts/308 br),fail_under=99 达标 | src/genesis/eval/__init__.py, src/genesis/eval/golden_set.py, src/genesis/eval/scorer.py, tests/test_eval_scorer.py, tests/fixtures/eval/golden_set.yaml, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T17(架构审查整改,P1):docx 注入原型提前(OV8)。新建 src/genesis/writer/ 包:docx_injector.pyDocxInjector 用原生 python-docx 实现 §6.6 占位符注入);Block 模型(paragraph/heading/table);章节级 {{section:id}} → 替换为内容块 docx 元素序列,行内 {{meta}} → 元信息填充;残留检查:未替换 {{...}} 抛 DocxInjectError(与 §6.6 规范一致);格式精度:注入 heading 继承模板 Heading 样式、原有内容样式不被破坏;新增 tests/test_docx_injector.py5 用例:章节/行内替换/残留报错/heading 样式继承/原内容保留);同步 design.md §6.7 渲染链路 T17 原型说明;TDD 验证 RED(模块缺失)→ GREEN(聚焦 5 passed)→ 全量 245 passed 覆盖 100.00%1361 stmts/340 br),fail_under=99 达标 | src/genesis/writer/__init__.py, src/genesis/writer/docx_injector.py, tests/test_docx_injector.py, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T14+T16(架构审查整改,Lane A):DataGate 机制化 + 任务级持久化(OV5 + OV7)。新建 src/genesis/orchestrator/ 包:datagate.pyDataGate.load(source, selector):子集加载 + 规模保护 max_total_rows=500 无 selector 拒绝全量 + token 预算 max_total_tokens=8000 复用 CJK 保守估算 + 未知表容错)+ task_queue.pyTaskQueue ABC + PersistentTaskQueue SQLite 落盘:enqueue/poll/update_status/get/cancel/recover/close + 幂等去重 §5.3 + recover 将 running→failed、pending 保留);新建 tests/test_datagate.py8 用例:子集/规模保护/1000 行 Excel selector 放行/token 预算/未知表/空 selector+ tests/test_task_queue.py11 用例:CRUD/幂等缓存/重开不丢/recover 语义/防御分支);同步 agent-runtime-design.md(§4.2 原则→机制 + §3.5/3.6 任务级恢复已实现)、api-design.md §5.2/5.3、design.md §8.4.1TDD 验证 RED(模块缺失/错误消息不匹配)→ GREEN(聚焦 8+8 passed)→ 全量 218 passed 覆盖 100.00%1140 stmts/278 br),fail_under=99 达标 | src/genesis/orchestrator/__init__.py, src/genesis/orchestrator/datagate.py, src/genesis/orchestrator/task_queue.py, tests/test_datagate.py, tests/test_task_queue.py, docs/agent-runtime-design.md, docs/api-design.md, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
+9
View File
@@ -852,6 +852,15 @@ ContentBlockJSONLLM 输出)
注入: 按 §6.6 占位符 / §6.5 Heading 定位写入模板
```
> **T17 docx 注入原型(OV8,已落地 `src/genesis/writer/docx_injector.py`**
> 将最难的「格式精度」成功标准提前验证。原型用原生 python-docx 实现 §6.6 占位符注入:
> - 章节级 `{{section:id}}` → 替换为内容块渲染的 docx 元素序列(heading/paragraph/table
> - 行内 `{{meta}}` → 元信息填充
> - **残留检查**:未替换 `{{...}}` 视为渲染失败(抛 `DocxInjectError`),与 §6.6 规范约束一致
> - **格式精度**:注入 heading 继承模板对应 Heading 样式(如 `Heading 2`),原有模板内容样式不被破坏
>
> 该原型在 Writer 完整实现前即可独立验证 docx 注入关键路径,规避「格式精度排末尾导致返工」的风险。
**模板样式映射表**(渲染器配置):
```yaml
+10
View File
@@ -0,0 +1,10 @@
"""writer 包:docx 渲染(T17 原型,OV8)。"""
from __future__ import annotations
from dataclasses import dataclass, field
from docx import Document
from docx.document import Document as DocxDocument
__all__ = ["Block", "DocxInjector", "DocxInjectError"]
+127
View File
@@ -0,0 +1,127 @@
"""docx 注入原型(T17OV8)。
背景:design.md §6.6/6.7 定义 docxtpl 占位符注入 + 格式精度要求,但完整 Writer
未实现。OV8 裁定将最难成功标准(格式精度)提前验证 → 本原型用原生 python-docx
实现占位符替换,验证关键路径:
- 章节级占位符 `{{section:id}}` → 替换为内容块渲染的 docx 元素序列
- 行内占位符 `{{meta}}` → 元信息填充
- 残留检查:未替换 `{{...}}` 视为渲染失败(design §6.6 规范约束)
- 格式精度:注入 heading 继承模板对应 Heading 样式(不破坏模板样式)
注:原型不引入 docxtpl 依赖,验证 python-docx 原生注入即可满足格式精度关键路径。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from docx import Document
from docx.document import Document as DocxDocument
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
_SECTION_RE = re.compile(r"\{\{section:([^}]+)\}\}")
_INLINE_RE = re.compile(r"\{\{([^}]+)\}\}")
class DocxInjectError(Exception):
"""docx 注入失败(占位符残留 / 非法模板)。"""
@dataclass
class Block:
"""简化的内容块(ContentBlock 原型的子集)。"""
kind: str # "paragraph" | "heading" | "table"
text: str = ""
level: int = 1 # heading 层级
rows: list[list[str]] = field(default_factory=list) # table 行
class DocxInjector:
"""模板占位符注入器(原型)。"""
def __init__(self, template_path: str) -> None:
self._template_path = template_path
def inject(self, sections: dict[str, list[Block]], meta: dict[str, str]) -> DocxDocument:
doc = Document(self._template_path)
self._inject_sections(doc, sections)
self._inject_inline(doc, meta)
# 残留检查(design §6.6 规范约束)
if self._has_residue(doc):
residue = self._collect_residue(doc)
raise DocxInjectError(f"占位符残留未替换:{residue}")
return doc
# ---------- 内部 ----------
def _inject_sections(self, doc: DocxDocument, sections: dict[str, list[Block]]) -> None:
for para in list(doc.paragraphs):
m = _SECTION_RE.search(para.text)
if not m:
continue
section_id = m.group(1)
blocks = sections.get(section_id)
if blocks is None:
# 未提供该章节内容 → 保留占位符段落,交由残留检查报错
continue
self._replace_paragraph_with_blocks(doc, para, blocks)
def _inject_inline(self, doc: DocxDocument, meta: dict[str, str]) -> None:
for para in doc.paragraphs:
if _INLINE_RE.search(para.text):
# 仅替换行内占位符,保留模板其余文本
new_text = _INLINE_RE.sub(lambda mm: meta.get(mm.group(1), mm.group(0)), para.text)
self._set_paragraph_text(para, new_text)
def _replace_paragraph_with_blocks(
self, doc: DocxDocument, para: Paragraph, blocks: list[Block]
) -> None:
"""将含 {{section:id}} 的段落替换为 blocks 渲染的元素序列。"""
parent = para._p.getparent()
para_idx = list(parent).index(para._p)
# 先移除原占位符段落
parent.remove(para._p)
# 逆序插入,使最终顺序正确
for block in reversed(blocks):
el = self._block_element(doc, block)
parent.insert(para_idx, el)
def _block_element(self, doc: DocxDocument, block: Block):
if block.kind == "heading":
p = doc.add_paragraph(block.text, style=f"Heading {block.level}")
return p._p
if block.kind == "table":
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)
return tbl._tbl # type: ignore[attr-defined]
# 默认 paragraph
p = doc.add_paragraph(block.text)
return p._p
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)
def _has_residue(self, doc: DocxDocument) -> bool:
for para in doc.paragraphs:
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 m in _INLINE_RE.finditer(para.text):
found.append(m.group(0))
return found
+89
View File
@@ -0,0 +1,89 @@
"""docx 注入原型测试(T17,OV8)。
OV8 裁定:最难成功标准(格式精度)排关键路径末尾 → docx 注入原型提前验证。
本文件测试 DocxInjector:模板占位符替换(章节级/行内)、残留检查、格式精度
(注入 heading 继承模板 Heading 样式,原有内容样式不被破坏)。
"""
from __future__ import annotations
import pytest
from docx import Document
from genesis.writer.docx_injector import (
Block,
DocxInjectError,
DocxInjector,
)
def _make_template(tmp_path, body: str) -> str:
doc = Document()
doc.add_paragraph("{{doc_title}}") # 行内占位符
doc.add_paragraph(body) # 章节占位符所在段落
doc.add_paragraph("尾部固定内容")
path = tmp_path / "template.docx"
doc.save(str(path))
return str(path)
def _section_blocks() -> list[Block]:
return [
Block(kind="heading", text="3.1 テーブル一覧", level=2),
Block(kind="paragraph", text="以下がDB表定义です。"),
Block(kind="table", rows=[["テーブル", "説明"], ["TB001", "社員"]]),
]
# ---------- 章节级占位符替换 ----------
def test_section_placeholder_replaced(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
inj = DocxInjector(tpl)
out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"})
full_text = "\n".join(p.text for p in out.paragraphs)
assert "{{section:db_design}}" not in full_text
assert "3.1 テーブル一覧" in full_text
assert "以下がDB表定义です。" in full_text
# ---------- 行内占位符替换 ----------
def test_inline_meta_replaced(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
inj = DocxInjector(tpl)
out = inj.inject({"db_design": []}, {"doc_title": "概要設計書"})
assert "{{doc_title}}" not in "\n".join(p.text for p in out.paragraphs)
assert "概要設計書" in "\n".join(p.text for p in out.paragraphs)
# ---------- 占位符残留检查 ----------
def test_residue_detection_raises(tmp_path):
tpl = _make_template(tmp_path, "{{section:unknown_chapter}}")
inj = DocxInjector(tpl)
with pytest.raises(DocxInjectError, match="残留"):
inj.inject({}, {"doc_title": "X"})
# ---------- 格式精度:heading 继承模板样式 ----------
def test_heading_inherits_template_style(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
inj = DocxInjector(tpl)
out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "T"})
# 注入的 heading blocklevel=2)应渲染为模板中存在的 Heading 2 样式段落
heading_paras = [p for p in out.paragraphs if p.style.name == "Heading 2"]
assert any("3.1 テーブル一覧" in p.text for p in heading_paras)
def test_original_content_style_preserved(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
inj = DocxInjector(tpl)
out = inj.inject({"db_design": []}, {"doc_title": "T"})
# 模板原有段落(尾部固定内容)在注入后仍存在且未被破坏
assert any("尾部固定内容" in p.text for p in out.paragraphs)