From ad248cf6a8510e149baf310f4e3a13b23fe03871 Mon Sep 17 00:00:00 2001 From: Debug-2 Backend Date: Thu, 10 Sep 2026 18:03:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20r3-47-01=20DOCX=20=E5=8C=85?= =?UTF-8?q?=E8=A3=B9=E5=B1=82=E9=80=92=E5=BD=92=E9=81=8D=E5=8E=86=20+=20r3?= =?UTF-8?q?-42-01=20=E5=86=85=E7=BD=AE=E6=A8=A1=E6=9D=BF=20key=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- sdk/app/routers/docgen.py | 29 ++++- sdk/app/routers/test_docgen_api.py | 49 ++++++++ sdk/app/services/docgen/html2docx.py | 134 ++++++++++++++-------- sdk/app/services/docgen/pipeline.py | 32 ++++-- sdk/app/services/test_docgen_html2docx.py | 69 +++++++++++ 5 files changed, 253 insertions(+), 60 deletions(-) diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py index efae9e1..40405d1 100644 --- a/sdk/app/routers/docgen.py +++ b/sdk/app/routers/docgen.py @@ -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", "") diff --git a/sdk/app/routers/test_docgen_api.py b/sdk/app/routers/test_docgen_api.py index 274af2e..eebbf85 100644 --- a/sdk/app/routers/test_docgen_api.py +++ b/sdk/app/routers/test_docgen_api.py @@ -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 diff --git a/sdk/app/services/docgen/html2docx.py b/sdk/app/services/docgen/html2docx.py index 84ed273..211d704 100644 --- a/sdk/app/services/docgen/html2docx.py +++ b/sdk/app/services/docgen/html2docx.py @@ -5,6 +5,12 @@ HTML(与 PDF 导出/预览同一份产物)用 BeautifulSoup 遍历成 python-docx 文档, 保证 DOCX 与 PDF 预览版式一致(红头/标题层级/表格/图片同源)。 +结构适配(r3-47-01,2026-09-10):真实 render_html 产物是 +
…包裹层…
(模板 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:真实模板是
…
, + 必须下钻包裹层——只遍历直接子元素会把整个 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://// 与裸文本。""" + """递归组装行内 run://// 与裸文本。 + + 嵌套块级(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 # 未知行内元素:递归子节点(避免把 / 后代塌成纯文本) 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 用
包 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) diff --git a/sdk/app/services/docgen/pipeline.py b/sdk/app/services/docgen/pipeline.py index f35e31d..4f2e32e 100644 --- a/sdk/app/services/docgen/pipeline.py +++ b/sdk/app/services/docgen/pipeline.py @@ -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) diff --git a/sdk/app/services/test_docgen_html2docx.py b/sdk/app/services/test_docgen_html2docx.py index a574bca..4e3fd53 100644 --- a/sdk/app/services/test_docgen_html2docx.py +++ b/sdk/app/services/test_docgen_html2docx.py @@ -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 产物形态—— +#
…
包裹层 + 嵌套 section +HTML_WRAPPED = """ +水稻病虫害防治方案 +
某某单位文件
+
+

水稻病虫害防治方案

+
+

一、病害防治

+

第一节正文,共 60 段落之一。

+

另一个段落。

+

1.1 稻瘟病

+
  • 防治要点一
  • 防治要点二
+ +
药剂用量
三环唑20g/亩
+
稻瘟病症状
图1 症状
+
+
+

二、虫害防治

+

第二节正文。

+
+
+""" + + +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