diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index a2a3ed7..9f973db 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -80,4 +80,5 @@ | 2026-08-11 | Agent 实现 | T6+T11(架构审查整改,Lane B):v1 rerank 精排 + bge-m3 多语言切换(Issue6 + OV2)。config.py 新增 RerankConfig(enabled=True/model=BAAI/bge-reranker-v2-m3/device=cpu)挂入 RagConfig;EmbeddingConfig.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-m3;config-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.py:parse_source_uri(解析 file.xlsx#Sheet!CellRef → SourceRef,格式非法 raise URIError)、provenance_to_uri(Provenance 还原,与 build 互逆)、resolve_source_uri(StructuredSource 内定位真实 CellValue)、validate_source_uris(批量强验证 → ValidationResult(resolved/unresolved),格式错误或源中不存在一律 unresolved,防 QA#8 编造 URI 作弊);统一 URI 唯一生成入口 build_source_uri(provenance.py),formatting_detector 经其生成,无散落不一致;新建 tests/test_resolver.py(13 用例:解析/往返/定位/批量验证/防御分支);同步 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.py(GoldenCase/GoldenSet,YAML 加载,samples/ 真实脱敏样本作 input_ref 基线)、scorer.py(ChapterScorer 按 §7.2 指标体系打分);确定性维度 traceability(resolver 验证 source_uri 可解析率)/placeholder_residue(无 {{...}} 残留)/chapter_completeness(章节覆盖期望集合);LLM 语义维度经 llm_evaluators 钩子注入(默认中性分,待 Phase5);新增 tests/test_eval_scorer.py(9 用例:各维度/聚合/钩子/空输入);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.py(DocxInjector 用原生 python-docx 实现 §6.6 占位符注入);Block 模型(paragraph/heading/table);章节级 {{section:id}} → 替换为内容块 docx 元素序列,行内 {{meta}} → 元信息填充;残留检查:未替换 {{...}} 抛 DocxInjectError(与 §6.6 规范一致);格式精度:注入 heading 继承模板 Heading 样式、原有内容样式不被破坏;新增 tests/test_docx_injector.py(5 用例:章节/行内替换/残留报错/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.py(DataGate.load(source, selector):子集加载 + 规模保护 max_total_rows=500 无 selector 拒绝全量 + token 预算 max_total_tokens=8000 复用 CJK 保守估算 + 未知表容错)+ task_queue.py(TaskQueue ABC + PersistentTaskQueue SQLite 落盘:enqueue/poll/update_status/get/cancel/recover/close + 幂等去重 §5.3 + recover 将 running→failed、pending 保留);新建 tests/test_datagate.py(8 用例:子集/规模保护/1000 行 Excel selector 放行/token 预算/未知表/空 selector)+ tests/test_task_queue.py(11 用例:CRUD/幂等缓存/重开不丢/recover 语义/防御分支);同步 agent-runtime-design.md(§4.2 原则→机制 + §3.5/3.6 任务级恢复已实现)、api-design.md §5.2/5.3、design.md §8.4.1;TDD 验证 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 | diff --git a/docs/design.md b/docs/design.md index 7c065e4..1f58fcc 100644 --- a/docs/design.md +++ b/docs/design.md @@ -852,6 +852,15 @@ ContentBlock(JSON,LLM 输出) 注入: 按 §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 diff --git a/src/genesis/writer/__init__.py b/src/genesis/writer/__init__.py new file mode 100644 index 0000000..be306de --- /dev/null +++ b/src/genesis/writer/__init__.py @@ -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"] diff --git a/src/genesis/writer/docx_injector.py b/src/genesis/writer/docx_injector.py new file mode 100644 index 0000000..3c385c8 --- /dev/null +++ b/src/genesis/writer/docx_injector.py @@ -0,0 +1,127 @@ +"""docx 注入原型(T17,OV8)。 + +背景: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 diff --git a/tests/test_docx_injector.py b/tests/test_docx_injector.py new file mode 100644 index 0000000..3b17471 --- /dev/null +++ b/tests/test_docx_injector.py @@ -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 block(level=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)