fix(backend): r3-47-01 DOCX 包裹层递归遍历 + r3-42-01 内置模板 key 回退语义

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Debug-2 Backend
2026-09-10 18:03:45 +08:00
co-authored by Claude Fable 5
parent 772c7cd688
commit ad248cf6a8
5 changed files with 253 additions and 60 deletions
+27 -2
View File
@@ -84,7 +84,7 @@ from ..services.docgen.editor import apply_edit
# is_docgen_intent:路由模块导出(测试 monkeypatch 锚点;护栏用同步 route 判型)
from ..services.docgen.intent import is_docgen_intent, route
from ..services.docgen.pipeline import generate_document, name_for_prompt
from ..services.docgen.templating import render_html
from ..services.docgen.templating import get_template_asset, list_templates, render_html
from ..services.docgen.templating_bioflow import (
BIOFLOW_TEMPLATE_NAME,
render_bioflow_report,
@@ -371,8 +371,29 @@ async def _intent_guard(text: str, *, has_active_artifact: bool) -> JSONResponse
# ---------------------------------------------------------------------------
def _pick_template(templates: list[dict[str, Any]], template_id: str | None) -> dict[str, Any] | None:
"""按 template_id 选模版;未指定时返回 vstore 推荐的第一条。"""
"""按 template_id 选模版(r3-42-01,2026-09-10)。
- template_id 是内置 key(official/report/application/contract/speech/
plan/document)→ 直接解析为内置模版(与 templating.py 注册表一致),
不依赖 vstore 检索命中——前端选「通用公文(红头)」传 official 时,
即使库内无该条目也必中 official,不再回落自动推荐(旧缺陷:产物
template_key 与所选不符,实测「方案」+official → plan);
- 非内置 key → vstore 检索结果里按 id 命中;
- 未指定 template_id → vstore 推荐第一条;
- 指定了但哪都未命中 → None(走自动推荐,语义不变)。
"""
if template_id:
tid = str(template_id).strip()
for entry in list_templates():
if entry.get("key") == tid and get_template_asset(tid) is not None:
# 内置模版条目形态与 pipeline 期望一致:id 放 key,
# title 放 name(pipeline 读 name 作 template_name 回退)
return {
"id": tid,
"name": entry.get("title") or tid,
"description": entry.get("description") or "",
"source": "builtin",
}
for t in templates:
if str(t.get("id")) == template_id:
return t
@@ -527,6 +548,10 @@ async def _run_docgen_task(
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
template_key = str(result.get("template_key") or "")
if not template_key and template is not None and template.get("source") == "builtin":
# 兜底(r3-42-01):pipeline 理论上已定稿内置 key,此处防
# pipeline 结果遗漏——保证 artifact.template_key 与所选一致
template_key = str(template.get("id") or "")
template_css = str(result.get("css") or "")
tpl_display = str(result.get("template_name") or "") or (
(template or {}).get("name", "") or (template or {}).get("title", "")
+49
View File
@@ -800,3 +800,52 @@ def test_startup_cleanup_marks_running_tasks_error(db, tmp_path):
_check()
_run(database.close())
# ---------------------------------------------------------------------------
# r3-42-01 内置模板回退语义(2026-09-10)
# ---------------------------------------------------------------------------
def test_pick_template_builtin_key_resolves_without_vstore_hit():
"""template_id 是内置 key → 直接解析为内置模版,不依赖 vstore 检索命中。"""
from app.services.docgen.templating import get_template_asset, list_templates
keys = [e["key"] for e in list_templates()]
assert "official" in keys and "plan" in keys and "document" in keys
# vstore 检索结果为空集(库内无条目)
tpl = docgen_mod._pick_template([], "official")
assert tpl is not None
assert tpl["id"] == "official"
assert tpl["source"] == "builtin"
assert tpl["name"] == "通用公文(红头)"
# 内置模版必须真实存在(get_template_asset 非空才解析)
assert get_template_asset("official") is not None
def test_pick_template_non_builtin_id_still_vstore():
"""非内置 key → vstore 检索按 id 命中;未命中 → None(自动推荐)。"""
tpl = docgen_mod._pick_template([{"id": "lib-9", "name": "库内模板"}], "lib-9")
assert tpl is not None and tpl["name"] == "库内模板"
assert docgen_mod._pick_template([], "lib-9") is None
# 未指定 template_id → vstore 推荐第一条
assert docgen_mod._pick_template([{"id": "lib-1"}], None)["id"] == "lib-1"
def test_generate_builtin_official_uses_key(client, db, monkeypatch):
"""r3-42-01 复现:vstore 检索空 + template_id=official → 产物 key=official。"""
async def search_empty(query, *, as_template=None, top_k=20):
return []
monkeypatch.setattr(docgen_mod, "search_documents", search_empty)
resp = client.post("/api/docgen/generate", json={
"description": "撰写一份水稻病虫害防治方案", "session_id": "r3-42",
"template_id": "official",
})
assert resp.status_code == 202, resp.text
_drain_pending()
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
assert task["status"] == "done", task["error"]
artifact = task["artifact"]
assert artifact["template_key"] == "official", artifact
assert artifact["template_name"] == "通用公文(红头)", artifact
+86 -48
View File
@@ -5,6 +5,12 @@
HTML(与 PDF 导出/预览同一份产物)用 BeautifulSoup 遍历成 python-docx 文档,
保证 DOCX 与 PDF 预览版式一致(红头/标题层级/表格/图片同源)。
结构适配(r3-47-01,2026-09-10):真实 render_html 产物是
<main><div class="doc-body">…包裹层…</div></main>(模板 wrapper 有 doc-cover/
doc-body 多层包裹)。遍历必须递归下钻包裹容器(保文档序),只把
「叶块」(标题/段落/表格/列表/引用/代码块/图片/图注)转为 docx 元素——
修复前只遍历 main 直接子元素,整个 doc-body 被塌成 1 个段落。
样式约定(与模板 css 视觉对齐):
- .doc-letterhead:红字(RGBColor(255,0,0))居中、22pt 黑体(红头文件要件)
- h1/h2/h3:黑体分级字号 22/16/14pt 加粗(>h3 折叠到 h3)
@@ -44,14 +50,15 @@ _FIGURE_PROXY = "/api/figures/proxy"
_IMAGE_HTTP_TIMEOUT = 8.0
_IMAGE_MAX_BYTES = 20 * 1024 * 1024
# 已支持元素外的顶层块元素一律按段落处理(模板 main 内预期就是这些)
_BLOCK_TAGS = {
# 叶块:直接转 docx 元素、不再下钻的标签
_LEAF_BLOCKS = {
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "div", "li", "blockquote", "pre", "table", "figure", "img",
"ol", "ul", "section", "article", "header", "footer", "main",
"p", "table", "ul", "ol", "li", "blockquote", "pre",
"figure", "figcaption", "img", "hr", "br",
}
_VOID_TAGS = {"img", "br", "hr", "meta", "link"}
# 遍历时整体跳过的标签
_SKIP_TAGS = {"script", "style", "meta", "link", "title", "head"}
def _set_run_font(run, font_name: str, size_pt: float | None = None,
@@ -101,7 +108,7 @@ def _abs_image_url(src: str, base_url: str = "") -> str:
def _collect_direct_text(node: Tag) -> str:
"""节点的直接文本(不含后代元素),用于 alt 兜底。"""
"""节点的直接文本(不含后代元素),用于判断容器是否有自己的文本。"""
return "".join(
str(child) for child in node.children if isinstance(child, NavigableString)
).strip()
@@ -112,9 +119,39 @@ def _heading_level(tag_name: str) -> int:
return min(int(m.group(1)), 3) if m else 0 # >h3 折叠到 h3
def _walk_blocks(container: Tag):
"""递归遍历块级内容(保文档序)。
规则:
- 叶块(_LEAF_BLOCKS)直接产出;
- 容器类(div/section/article/header/footer/main/body…)无直接文本 → 下钻;
- 容器有直接文本 → 按段落产出(防丢内容);
- script/style 等跳过。
r3-47-01:真实模板是 <main><div class="doc-body">…</div></main>,
必须下钻包裹层——只遍历直接子元素会把整个 doc-body 塌成单段。
"""
for child in container.children:
if not isinstance(child, Tag):
continue
name = (child.name or "").lower()
if name in _SKIP_TAGS:
continue
if name in _LEAF_BLOCKS:
yield child
continue
if _collect_direct_text(child):
yield child # 容器自身有文本:按段落
else:
yield from _walk_blocks(child)
def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
bold: bool | None = None) -> None:
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。"""
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。
嵌套块级(ul/ol/img)不在行内渲染——由调用方按块处理。
"""
if isinstance(node, NavigableString):
text = str(node)
if text:
@@ -123,7 +160,7 @@ def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
if not isinstance(node, Tag):
return
name = node.name.lower() if node.name else ""
if name in ("script", "style"):
if name in ("script", "style", "ul", "ol", "img"):
return
if name in ("br",):
paragraph.add_run().add_break()
@@ -140,17 +177,13 @@ def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
run = paragraph.add_run(node.get_text())
_set_run_font(run, "Courier New", max(size_pt - 1.0, 8.0), bold=bold)
return
if name in ("span", "a", "small", "sub", "sup"):
for child in node.children:
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
return
# 未知行内元素:递归子节点(避免把 <b>/<code> 后代塌成纯文本)
for child in node.children:
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。返回是否产出了内容。"""
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。"""
name = (node.name or "").lower()
text = _collect_direct_text(node)
@@ -161,7 +194,7 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
run = para.add_run(node.get_text(" ", strip=True))
_set_run_font(run, _FONT_HEADING, _HEADING_SIZES[level], bold=True)
para.paragraph_format.space_before = Pt(10)
return True
return
if name in ("ul", "ol"):
ordered = name == "ol"
@@ -172,51 +205,63 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
para = doc.add_paragraph(style=style)
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
_add_inline_runs(para, child, _FONT_BODY, 12.0)
return True
# 嵌套列表(li 内的 ul/ol)按块下钻
for nested in child.find_all(["ul", "ol"], recursive=False):
_add_rich_block(doc, nested, list_level + 1)
return
if name == "li": # 游离 li(无 ul/ol 父)
para = doc.add_paragraph(style="List Bullet")
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
_add_inline_runs(para, node, _FONT_BODY, 12.0)
return True
for nested in node.find_all(["ul", "ol"], recursive=False):
_add_rich_block(doc, nested, list_level + 1)
return
if name == "table":
_add_table(doc, node)
return True
return
if name in ("figure", "figcaption") or (name == "div" and node.find("img")):
for img in node.find_all("img", recursive=False) or node.find_all("img"):
_add_image(doc, img)
if name == "figure" and not node.find("img"):
_add_image(doc, node.img) # type: ignore[arg-type]
# figcaption 文本
caption = node.find("figcaption")
if caption is not None:
if name in ("figure", "figcaption"):
if name == "figure":
imgs = node.find_all("img", recursive=False) or node.find_all("img")
if imgs:
for img in imgs:
_add_image(doc, img)
caption = node.find("figcaption")
if caption is not None:
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run(caption.get_text(" ", strip=True))
_set_run_font(run, _FONT_BODY, 10.5)
else:
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run(caption.get_text(" ", strip=True))
run = para.add_run(node.get_text(" ", strip=True))
_set_run_font(run, _FONT_BODY, 10.5)
return True
return
if name == "img":
_add_image(doc, node)
return True
return
if name == "pre":
para = doc.add_paragraph()
run = para.add_run(node.get_text())
_set_run_font(run, "Courier New", 10.5)
return True
return
if name == "blockquote":
para = doc.add_paragraph(style="Quote")
_add_inline_runs(para, node, _FONT_BODY, 12.0)
return True
return
if name in ("hr", "br", "script", "style", "meta", "link"):
return False
if name in ("hr", "br"):
return
# 默认段落(p/div/section 等):直接文本优先,无直接文本则全文(避免丢内容)
# 默认段落(p/带直接文本的 div/section 等):图片后代先按块内嵌,再落文本
for img in node.find_all("img", recursive=False):
_add_image(doc, img)
para = doc.add_paragraph()
if text:
_add_inline_runs(para, node, _FONT_BODY, 12.0)
@@ -225,7 +270,6 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
if body_text:
run = para.add_run(body_text)
_set_run_font(run, _FONT_BODY, 12.0)
return True
def _add_table(doc: Document, table_el: Tag) -> None:
@@ -308,9 +352,9 @@ def docx_from_html_document(html: str, title: str = "", out_path: str | Path | N
doc.core_properties.title = (title or "document")[:200]
body = soup.body or soup
# 红头(.doc-letterhead):红字居中 22pt 黑体;空串不产出段落
# 红头(.doc-letterhead):红字居中 22pt 黑体;处理后从树中摘除
# (红头常包在 doc-cover 内、main 外,避免遍历时重复落段)
letterhead_el = body.find(class_="doc-letterhead")
letterhead = ""
if letterhead_el is not None:
letterhead = letterhead_el.get_text(" ", strip=True)
if letterhead:
@@ -319,19 +363,13 @@ def docx_from_html_document(html: str, title: str = "", out_path: str | Path | N
run = para.add_run(letterhead)
_set_run_font(run, _FONT_HEADING, _LETTERHEAD_SIZE, bold=True,
color=RGBColor(255, 0, 0))
letterhead_el.decompose()
# 主内容容器(render_html 用 <main> 包 body;bioflow 模板同理)
main = body.find("main") or body
# 遍历 main 的直接子元素(跳过已处理的红头与 meta)
for child in main.children:
if not isinstance(child, Tag):
continue
name = (child.name or "").lower()
if name in ("script", "style", "meta", "link", "title"):
continue
if "doc-letterhead" in (child.get("class") or []):
continue # 红头已处理
_add_rich_block(doc, child)
# 主内容:main 优先;递归下钻包裹容器(doc-body 等),保文档序
main = body.find("main") if body is not None else None
root = main if main is not None else body
for block in _walk_blocks(root):
_add_rich_block(doc, block)
out = Path(out_path) if out_path is not None else Path(f"{title or 'document'}.docx")
out.parent.mkdir(parents=True, exist_ok=True)
+22 -10
View File
@@ -312,19 +312,31 @@ async def generate_document(
outline_source = "llm"
if template:
tpl_id = str(template.get("id") or "")
try:
content = await client.get_document(tpl_id)
except client.DocgenUnavailable:
content = None
if content:
template_outline = _template_outline_from_content(content)
if template_outline:
outline_source = "library"
template_name = str(template.get("name") or "库内模版")
# 内置模版(r3-42-01,2026-09-10):_pick_template 已解析为
# {id: key, name: title, source: "builtin"}——无库内大纲,直接定稿
# template_key/template_name,不再走 vstore 拉取(拉「official」这个
# id 会 404);库内模版(id 为 vstore document_id)才拉明文大纲。
if template.get("source") == "builtin":
template_key = tpl_id
template_name = str(template.get("name") or tpl_id)
else:
try:
content = await client.get_document(tpl_id)
except client.DocgenUnavailable:
content = None
if content:
template_outline = _template_outline_from_content(content)
if template_outline:
outline_source = "library"
template_name = str(template.get("name") or "库内模版")
# 3. 库内未命中:内置模版分类 + 联网结构参考(best-effort,默认启用)
if not template_outline:
template_key = templating.classify_doc_type(description)
# r3-42-01:内置模版已定稿 template_key(step 2 builtin 分支),
# 这里不得用分类器覆盖(修复前「方案」+official → classify 覆盖成
# plan,产物 key 与所选不符)
if not template_key:
template_key = templating.classify_doc_type(description)
if web_search:
try:
web_outline = await websearch.search_template_outline(description)
+69
View File
@@ -125,3 +125,72 @@ def test_bold_italic_code_runs(tmp_path):
code_found = any(r.font.name == "Courier New"
for p in doc.paragraphs for r in p.runs)
assert code_found
# r3-47-01 复现样例:真实 render_html 产物形态——
# <main><div class="doc-body">…</div></main> 包裹层 + 嵌套 section
HTML_WRAPPED = """<!DOCTYPE html><html><head><meta charset="utf-8">
<title>水稻病虫害防治方案</title></head><body>
<div class="doc-cover"><div class="doc-letterhead">某某单位文件</div></div>
<main><div class="doc-body">
<h1 class="doc-title">水稻病虫害防治方案</h1>
<section>
<h2>一、病害防治</h2>
<p>第一节正文,共 60 段落之一。</p>
<p>另一个段落。</p>
<h3>1.1 稻瘟病</h3>
<ul><li>防治要点一</li><li>防治要点二</li></ul>
<table class="doc-table"><tr><th>药剂</th><th>用量</th></tr>
<tr><td>三环唑</td><td>20g/亩</td></tr></table>
<figure><img src="http://127.0.0.1:1/down.png" alt="稻瘟病症状"/><figcaption>图1 症状</figcaption></figure>
</section>
<section>
<h2>二、虫害防治</h2>
<p>第二节正文。</p>
</section>
</div></main>
</body></html>"""
def test_wrapped_docbody_structure_preserved(tmp_path):
"""r3-47-01 回归:doc-body 包裹层下钻——标题/段落/表格/列表/图注全保留,
不得塌缩成单段落。"""
out = docx_from_html_document(HTML_WRAPPED, title="水稻病虫害防治方案",
out_path=tmp_path / "w.docx")
doc = Document(str(out))
styles = [p.style.name for p in doc.paragraphs]
assert "Heading 1" in styles
assert styles.count("Heading 2") == 2 # 两个 section 的 h2
assert "Heading 3" in styles
assert "List Bullet" in styles
# 表格完整(2 行 2 列,表头灰底)
assert len(doc.tables) == 1
table = doc.tables[0]
assert len(table.rows) == 2 and table.cell(1, 1).text == "20g/亩"
assert _cell_shade(table.cell(0, 0)) == "D9D9D9"
# 段落文本保留(不是全塌成一段)
texts = "\n".join(p.text for p in doc.paragraphs)
assert "第一节正文" in texts
assert "第二节正文" in texts
# 红头仍正确
assert doc.paragraphs[0].text == "某某单位文件"
def test_wrapped_structure_counts_match_html(tmp_path):
"""r3-47-01:docx 元素计数与 HTML 结构对应(段落数 >= p 数,标题/表格计数一致)。"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(HTML_WRAPPED, "html.parser")
n_p = len(soup.find_all("p"))
n_h2 = len(soup.find_all("h2"))
n_h3 = len(soup.find_all("h3"))
n_table = len(soup.find_all("table"))
out = docx_from_html_document(HTML_WRAPPED, title="水稻病虫害防治方案",
out_path=tmp_path / "c.docx")
doc = Document(str(out))
styles = [p.style.name for p in doc.paragraphs]
assert styles.count("Heading 2") == n_h2
assert styles.count("Heading 3") == n_h3
assert len(doc.tables) == n_table
# 段落:p 落 Normal(含图注段),计数不塌缩(>= p 数,图注/图片段会额外加)
assert styles.count("Normal") >= n_p