fix(backend): r3-47-01 DOCX 包裹层递归遍历 + r3-42-01 内置模板 key 回退语义
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
772c7cd688
commit
ad248cf6a8
@@ -84,7 +84,7 @@ from ..services.docgen.editor import apply_edit
|
|||||||
# is_docgen_intent:路由模块导出(测试 monkeypatch 锚点;护栏用同步 route 判型)
|
# is_docgen_intent:路由模块导出(测试 monkeypatch 锚点;护栏用同步 route 判型)
|
||||||
from ..services.docgen.intent import is_docgen_intent, route
|
from ..services.docgen.intent import is_docgen_intent, route
|
||||||
from ..services.docgen.pipeline import generate_document, name_for_prompt
|
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 (
|
from ..services.docgen.templating_bioflow import (
|
||||||
BIOFLOW_TEMPLATE_NAME,
|
BIOFLOW_TEMPLATE_NAME,
|
||||||
render_bioflow_report,
|
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:
|
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:
|
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:
|
for t in templates:
|
||||||
if str(t.get("id")) == template_id:
|
if str(t.get("id")) == template_id:
|
||||||
return t
|
return t
|
||||||
@@ -527,6 +548,10 @@ async def _run_docgen_task(
|
|||||||
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
|
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
|
||||||
|
|
||||||
template_key = str(result.get("template_key") or "")
|
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 "")
|
template_css = str(result.get("css") or "")
|
||||||
tpl_display = str(result.get("template_name") or "") or (
|
tpl_display = str(result.get("template_name") or "") or (
|
||||||
(template or {}).get("name", "") or (template or {}).get("title", "")
|
(template or {}).get("name", "") or (template or {}).get("title", "")
|
||||||
|
|||||||
@@ -800,3 +800,52 @@ def test_startup_cleanup_marks_running_tasks_error(db, tmp_path):
|
|||||||
|
|
||||||
_check()
|
_check()
|
||||||
_run(database.close())
|
_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
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
HTML(与 PDF 导出/预览同一份产物)用 BeautifulSoup 遍历成 python-docx 文档,
|
HTML(与 PDF 导出/预览同一份产物)用 BeautifulSoup 遍历成 python-docx 文档,
|
||||||
保证 DOCX 与 PDF 预览版式一致(红头/标题层级/表格/图片同源)。
|
保证 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 视觉对齐):
|
样式约定(与模板 css 视觉对齐):
|
||||||
- .doc-letterhead:红字(RGBColor(255,0,0))居中、22pt 黑体(红头文件要件)
|
- .doc-letterhead:红字(RGBColor(255,0,0))居中、22pt 黑体(红头文件要件)
|
||||||
- h1/h2/h3:黑体分级字号 22/16/14pt 加粗(>h3 折叠到 h3)
|
- h1/h2/h3:黑体分级字号 22/16/14pt 加粗(>h3 折叠到 h3)
|
||||||
@@ -44,14 +50,15 @@ _FIGURE_PROXY = "/api/figures/proxy"
|
|||||||
_IMAGE_HTTP_TIMEOUT = 8.0
|
_IMAGE_HTTP_TIMEOUT = 8.0
|
||||||
_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
# 已支持元素外的顶层块元素一律按段落处理(模板 main 内预期就是这些)
|
# 叶块:直接转 docx 元素、不再下钻的标签
|
||||||
_BLOCK_TAGS = {
|
_LEAF_BLOCKS = {
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
"p", "div", "li", "blockquote", "pre", "table", "figure", "img",
|
"p", "table", "ul", "ol", "li", "blockquote", "pre",
|
||||||
"ol", "ul", "section", "article", "header", "footer", "main",
|
"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,
|
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:
|
def _collect_direct_text(node: Tag) -> str:
|
||||||
"""节点的直接文本(不含后代元素),用于 alt 兜底。"""
|
"""节点的直接文本(不含后代元素),用于判断容器是否有自己的文本。"""
|
||||||
return "".join(
|
return "".join(
|
||||||
str(child) for child in node.children if isinstance(child, NavigableString)
|
str(child) for child in node.children if isinstance(child, NavigableString)
|
||||||
).strip()
|
).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
|
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,
|
def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
|
||||||
bold: bool | None = None) -> None:
|
bold: bool | None = None) -> None:
|
||||||
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。"""
|
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。
|
||||||
|
|
||||||
|
嵌套块级(ul/ol/img)不在行内渲染——由调用方按块处理。
|
||||||
|
"""
|
||||||
if isinstance(node, NavigableString):
|
if isinstance(node, NavigableString):
|
||||||
text = str(node)
|
text = str(node)
|
||||||
if text:
|
if text:
|
||||||
@@ -123,7 +160,7 @@ def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
|
|||||||
if not isinstance(node, Tag):
|
if not isinstance(node, Tag):
|
||||||
return
|
return
|
||||||
name = node.name.lower() if node.name else ""
|
name = node.name.lower() if node.name else ""
|
||||||
if name in ("script", "style"):
|
if name in ("script", "style", "ul", "ol", "img"):
|
||||||
return
|
return
|
||||||
if name in ("br",):
|
if name in ("br",):
|
||||||
paragraph.add_run().add_break()
|
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())
|
run = paragraph.add_run(node.get_text())
|
||||||
_set_run_font(run, "Courier New", max(size_pt - 1.0, 8.0), bold=bold)
|
_set_run_font(run, "Courier New", max(size_pt - 1.0, 8.0), bold=bold)
|
||||||
return
|
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> 后代塌成纯文本)
|
# 未知行内元素:递归子节点(避免把 <b>/<code> 后代塌成纯文本)
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
|
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
|
||||||
|
|
||||||
|
|
||||||
def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
||||||
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。返回是否产出了内容。"""
|
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。"""
|
||||||
name = (node.name or "").lower()
|
name = (node.name or "").lower()
|
||||||
text = _collect_direct_text(node)
|
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))
|
run = para.add_run(node.get_text(" ", strip=True))
|
||||||
_set_run_font(run, _FONT_HEADING, _HEADING_SIZES[level], bold=True)
|
_set_run_font(run, _FONT_HEADING, _HEADING_SIZES[level], bold=True)
|
||||||
para.paragraph_format.space_before = Pt(10)
|
para.paragraph_format.space_before = Pt(10)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name in ("ul", "ol"):
|
if name in ("ul", "ol"):
|
||||||
ordered = name == "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 = doc.add_paragraph(style=style)
|
||||||
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
||||||
_add_inline_runs(para, child, _FONT_BODY, 12.0)
|
_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 父)
|
if name == "li": # 游离 li(无 ul/ol 父)
|
||||||
para = doc.add_paragraph(style="List Bullet")
|
para = doc.add_paragraph(style="List Bullet")
|
||||||
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
||||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
_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":
|
if name == "table":
|
||||||
_add_table(doc, node)
|
_add_table(doc, node)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name in ("figure", "figcaption") or (name == "div" and node.find("img")):
|
if name in ("figure", "figcaption"):
|
||||||
for img in node.find_all("img", recursive=False) or node.find_all("img"):
|
if name == "figure":
|
||||||
_add_image(doc, img)
|
imgs = node.find_all("img", recursive=False) or node.find_all("img")
|
||||||
if name == "figure" and not node.find("img"):
|
if imgs:
|
||||||
_add_image(doc, node.img) # type: ignore[arg-type]
|
for img in imgs:
|
||||||
# figcaption 文本
|
_add_image(doc, img)
|
||||||
caption = node.find("figcaption")
|
caption = node.find("figcaption")
|
||||||
if caption is not None:
|
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 = doc.add_paragraph()
|
||||||
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
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)
|
_set_run_font(run, _FONT_BODY, 10.5)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name == "img":
|
if name == "img":
|
||||||
_add_image(doc, node)
|
_add_image(doc, node)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name == "pre":
|
if name == "pre":
|
||||||
para = doc.add_paragraph()
|
para = doc.add_paragraph()
|
||||||
run = para.add_run(node.get_text())
|
run = para.add_run(node.get_text())
|
||||||
_set_run_font(run, "Courier New", 10.5)
|
_set_run_font(run, "Courier New", 10.5)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name == "blockquote":
|
if name == "blockquote":
|
||||||
para = doc.add_paragraph(style="Quote")
|
para = doc.add_paragraph(style="Quote")
|
||||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
||||||
return True
|
return
|
||||||
|
|
||||||
if name in ("hr", "br", "script", "style", "meta", "link"):
|
if name in ("hr", "br"):
|
||||||
return False
|
return
|
||||||
|
|
||||||
# 默认段落(p/div/section 等):直接文本优先,无直接文本则全文(避免丢内容)
|
# 默认段落(p/带直接文本的 div/section 等):图片后代先按块内嵌,再落文本
|
||||||
|
for img in node.find_all("img", recursive=False):
|
||||||
|
_add_image(doc, img)
|
||||||
para = doc.add_paragraph()
|
para = doc.add_paragraph()
|
||||||
if text:
|
if text:
|
||||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
_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:
|
if body_text:
|
||||||
run = para.add_run(body_text)
|
run = para.add_run(body_text)
|
||||||
_set_run_font(run, _FONT_BODY, 12.0)
|
_set_run_font(run, _FONT_BODY, 12.0)
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _add_table(doc: Document, table_el: Tag) -> None:
|
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]
|
doc.core_properties.title = (title or "document")[:200]
|
||||||
|
|
||||||
body = soup.body or soup
|
body = soup.body or soup
|
||||||
# 红头(.doc-letterhead):红字居中 22pt 黑体;空串不产出段落
|
# 红头(.doc-letterhead):红字居中 22pt 黑体;处理后从树中摘除
|
||||||
|
# (红头常包在 doc-cover 内、main 外,避免遍历时重复落段)
|
||||||
letterhead_el = body.find(class_="doc-letterhead")
|
letterhead_el = body.find(class_="doc-letterhead")
|
||||||
letterhead = ""
|
|
||||||
if letterhead_el is not None:
|
if letterhead_el is not None:
|
||||||
letterhead = letterhead_el.get_text(" ", strip=True)
|
letterhead = letterhead_el.get_text(" ", strip=True)
|
||||||
if letterhead:
|
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)
|
run = para.add_run(letterhead)
|
||||||
_set_run_font(run, _FONT_HEADING, _LETTERHEAD_SIZE, bold=True,
|
_set_run_font(run, _FONT_HEADING, _LETTERHEAD_SIZE, bold=True,
|
||||||
color=RGBColor(255, 0, 0))
|
color=RGBColor(255, 0, 0))
|
||||||
|
letterhead_el.decompose()
|
||||||
|
|
||||||
# 主内容容器(render_html 用 <main> 包 body;bioflow 模板同理)
|
# 主内容:main 优先;递归下钻包裹容器(doc-body 等),保文档序
|
||||||
main = body.find("main") or body
|
main = body.find("main") if body is not None else None
|
||||||
# 遍历 main 的直接子元素(跳过已处理的红头与 meta)
|
root = main if main is not None else body
|
||||||
for child in main.children:
|
for block in _walk_blocks(root):
|
||||||
if not isinstance(child, Tag):
|
_add_rich_block(doc, block)
|
||||||
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)
|
|
||||||
|
|
||||||
out = Path(out_path) if out_path is not None else Path(f"{title or 'document'}.docx")
|
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)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -312,19 +312,31 @@ async def generate_document(
|
|||||||
outline_source = "llm"
|
outline_source = "llm"
|
||||||
if template:
|
if template:
|
||||||
tpl_id = str(template.get("id") or "")
|
tpl_id = str(template.get("id") or "")
|
||||||
try:
|
# 内置模版(r3-42-01,2026-09-10):_pick_template 已解析为
|
||||||
content = await client.get_document(tpl_id)
|
# {id: key, name: title, source: "builtin"}——无库内大纲,直接定稿
|
||||||
except client.DocgenUnavailable:
|
# template_key/template_name,不再走 vstore 拉取(拉「official」这个
|
||||||
content = None
|
# id 会 404);库内模版(id 为 vstore document_id)才拉明文大纲。
|
||||||
if content:
|
if template.get("source") == "builtin":
|
||||||
template_outline = _template_outline_from_content(content)
|
template_key = tpl_id
|
||||||
if template_outline:
|
template_name = str(template.get("name") or tpl_id)
|
||||||
outline_source = "library"
|
else:
|
||||||
template_name = str(template.get("name") 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 "库内模版")
|
||||||
|
|
||||||
# 3. 库内未命中:内置模版分类 + 联网结构参考(best-effort,默认启用)
|
# 3. 库内未命中:内置模版分类 + 联网结构参考(best-effort,默认启用)
|
||||||
if not template_outline:
|
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:
|
if web_search:
|
||||||
try:
|
try:
|
||||||
web_outline = await websearch.search_template_outline(description)
|
web_outline = await websearch.search_template_outline(description)
|
||||||
|
|||||||
@@ -125,3 +125,72 @@ def test_bold_italic_code_runs(tmp_path):
|
|||||||
code_found = any(r.font.name == "Courier New"
|
code_found = any(r.font.name == "Courier New"
|
||||||
for p in doc.paragraphs for r in p.runs)
|
for p in doc.paragraphs for r in p.runs)
|
||||||
assert code_found
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user