diff --git a/docs/team/cases_intent.md b/docs/team/cases_intent.md index 852d688..b52bc44 100644 --- a/docs/team/cases_intent.md +++ b/docs/team/cases_intent.md @@ -18,7 +18,11 @@ route(text, has_attachments, has_active_artifact) -> "qa" | "docgen" | "edit" | ### 3. docgen(写文档意图) 条件:text 含任一 WRITE_VERB 且含任一 DOC_TYPE -- WRITE_VERB = [写, 撰写, 拟, 起草, 编制, 生成, 编写, 拟定, 草拟, 制定] +- WRITE_VERB = [写, 撰写, 拟, 起草, 编制, 生成, 编写, 拟定, 草拟, 制定, 总结, 概括, 归纳](r4 #52 新增后三词) +- 动词自指保护(r4 #52 新增):规则 3 判定时,若 text 命中的 DOC_TYPE 恰好等于命中的 WRITE_VERB 本身 + (如「总结一下植物光合作用的过程」——「总结」既是动词又是文档类型词),不算写文档意图, + 继续向下判定。「写一篇总结」(动词=写,类型=总结)与「总结这篇论文」(动词=总结,类型=论文) + 不受影响。 - DOC_TYPE = [论文, 报告, 通知, 通告, 公告, 请示, 批复, 函, 申请书, 申请, 申报书, 标书, 合同, 协议, 方案, 计划, 总结, 汇报, 讲话稿, 致辞, 证明, 纪要, 简报, 材料, 说明书, 指南, 文档, 申报材料, 工作总结, 调研报告, 可行性报告, 项目书, 策划书, 演讲稿, 新闻稿, 文案, 简历, 制度, 规定, 办法, 细则, 章程, 声明, 邀请函, 感谢信, 慰问信, 介绍信, 推荐信, 倡议书, 述职报告, 开题报告, 结题报告, 实验报告, 分析报告, 会议纪要] - 明确排除(不属于 DOC_TYPE,防误判):介绍, 内容, 代码, 诗, 作业, 问题, 建议, 想法 @@ -28,7 +32,7 @@ route(text, has_attachments, has_active_artifact) -> "qa" | "docgen" | "edit" | ### 5. 其余一律 "qa"(漏判安全原则:不命中绝不进 docgen) -## 用例表(30 条,前后端实现与测试共用同一张表) +## 用例表(34 条,前后端实现与测试共用同一张表) | # | 输入 text | 附件 | 活动文档 | 期望 | |---|---|---|---|---| @@ -62,6 +66,10 @@ route(text, has_attachments, has_active_artifact) -> "qa" | "docgen" | "edit" | | 28 | 帮我写个会议通知模板 | 否 | 是 | docgen | | 29 | 给这个通知加个结尾 | 否 | 是 | edit | | 30 | 增加一段关于风险的描述 | 否 | 是 | edit | +| 31 | 帮我总结论文 | 是 | 否 | docgen | +| 32 | 总结一下植物光合作用的过程 | 否 | 否 | qa | +| 33 | 概括一下这个通知 | 是 | 是 | docgen | +| 34 | 归纳这篇文章 | 是 | 否 | docgen | ## 一致性要求 - dev1 的 sdk/app/services/docgen/intent.py 与 dev3 的 ui/src/intent.js 必须按本表逐条通过 diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py index 40405d1..9e8c236 100644 --- a/sdk/app/routers/docgen.py +++ b/sdk/app/routers/docgen.py @@ -983,7 +983,11 @@ async def export_artifact( html, _rendered_css = render_html( markdown, title=title, key=key, letterhead=letterhead ) - data = await docx_from_html_document(html, title) + # 缺陷 #47a(2026-09-11):css 一并传给 DOCX 转换, + # 红头/标题样式与预览/PDF 同源;frozen_css 优先(同 PDF 路径) + data = await docx_from_html_document( + html, title, css=frozen_css or _rendered_css + ) except Exception as exc: logger.warning( "docgen: HTML→DOCX 转换失败(%s),回退 md2docx 旧路径", exc diff --git a/sdk/app/routers/test_docgen_api.py b/sdk/app/routers/test_docgen_api.py index eebbf85..43a2a2c 100644 --- a/sdk/app/routers/test_docgen_api.py +++ b/sdk/app/routers/test_docgen_api.py @@ -108,7 +108,7 @@ def _reset_mocks(monkeypatch): async def rebuild_ok(): return {"rebuilt": True} - async def docx_html_ok(html: str, title: str): + async def docx_html_ok(html: str, title: str, css: str = ""): return b"DOCX-HTML-BYTES" def render_ok(markdown, *, title, key="document", letterhead=""): diff --git a/sdk/app/services/docgen/converter.py b/sdk/app/services/docgen/converter.py index 83f9f4b..d1c9dfe 100644 --- a/sdk/app/services/docgen/converter.py +++ b/sdk/app/services/docgen/converter.py @@ -31,13 +31,17 @@ async def pdf_from_markdown(markdown: str, title: str) -> bytes: return await anyio.to_thread.run_sync(_pdf_bytes, markdown, title) -async def docx_from_html_document(html: str, title: str) -> bytes: - """HTML → docx(缺陷 #47a,2026-09-10):与 converter 其它函数同签名, - html2docx 是 CPU 同步转换,to_thread 避免阻塞事件循环。""" +async def docx_from_html_document(html: str, title: str, css: str = "") -> bytes: + """HTML → docx(缺陷 #47a,2026-09-10/11):与 converter 其它函数同签名, + html2docx 是 CPU 同步转换,to_thread 避免阻塞事件循环。 + + css:render_html 返回的模板 CSS(缺陷 #47a 同源解析,2026-09-11)。 + 为空时 html2docx 只解析 html 内嵌 ", "html.parser") + for decl in _declarations(wrapped.style): + block = merged.setdefault(decl["selector"], {}) + block.update(decl["block"]) + return merged + + +def letterhead_style(styles: dict[str, dict[str, str]]) -> tuple[_ColorRGB, float]: + """红头样式:(rgb 颜色, 字号 pt)。解析不到时回退历史常量。""" + block = styles.get(".doc-letterhead") or {} + color = parse_rgb(block.get("color", "")) or _LEGACY_LETTERHEAD_COLOR + size = parse_size_pt(block.get("font-size", "")) + if size is None: + size = _LEGACY_LETTERHEAD_SIZE_PT + return color, size + + +def heading_sizes(styles: dict[str, dict[str, str]]) -> dict[int, float]: + """h1-h3 字号(pt):解析 h1.doc-title、h2、h3 声明;缺失回落历史常量。""" + h1 = styles.get("h1.doc-title") or styles.get("h1") or {} + h2 = styles.get("h2") or {} + h3 = styles.get("h3") or {} + parsed = { + 1: parse_size_pt(h1.get("font-size", "")), + 2: parse_size_pt(h2.get("font-size", "")), + 3: parse_size_pt(h3.get("font-size", "")), + } + return { + level: parsed[level] if parsed[level] is not None else _LEGACY_HEADING_SIZES_PT[level] + for level in (1, 2, 3) + } diff --git a/sdk/app/services/docgen/intent.py b/sdk/app/services/docgen/intent.py index 3d0db8b..a0c8dc4 100644 --- a/sdk/app/services/docgen/intent.py +++ b/sdk/app/services/docgen/intent.py @@ -20,7 +20,8 @@ FORMAT_WORD = ["pdf", "docx", "doc", "word"] # 英文不区分大小写(conta EDIT_WORD = ["改", "修改", "调整", "删", "删除", "加一段", "补充", "润色", "缩写", "扩写", "重写", "改写", "优化", "加个", "增加一段"] -WRITE_VERB = ["写", "撰写", "拟", "起草", "编制", "生成", "编写", "拟定", "草拟", "制定"] +WRITE_VERB = ["写", "撰写", "拟", "起草", "编制", "生成", "编写", "拟定", "草拟", "制定", + "总结", "概括", "归纳"] # r4 #52(2026-09-11) DOC_TYPE = [ "论文", "报告", "通知", "通告", "公告", "请示", "批复", "函", "申请书", @@ -71,8 +72,16 @@ def route(text: str, has_attachments: bool, has_active_artifact: bool) -> str: return "edit" # 3. docgen:写类动词 + 文档类型词 - if _contains_any(text, WRITE_VERB) and _has_doc_type(text): - return "docgen" + # r4 #52(2026-09-11)动词自指保护:当唯一命中的 DOC_TYPE 恰好等于 + # 命中的动词本身(如「总结一下植物光合作用的过程」——「总结」既是动词 + # 又是文档类型词),不视为写文档意图(继续向下判定)。「写一篇总结」 + # (动词=写,类型=总结)与「总结这篇论文」(动词=总结,类型=论文) + # 不受影响。规则详见 docs/team/cases_intent.md 用例 31-34。 + _matched_verbs = [w for w in WRITE_VERB if w in text] + _matched_types = [w for w in DOC_TYPE if w in text] + if _matched_verbs and _matched_types and _has_doc_type(text): + if set(_matched_types) != set(_matched_verbs): + return "docgen" # 4. docgen:附件信号(拖入文件 + 写类动词) if has_attachments and _contains_any(text, WRITE_VERB): diff --git a/sdk/app/services/test_docgen_html2docx.py b/sdk/app/services/test_docgen_html2docx.py index 4e3fd53..d2b2183 100644 --- a/sdk/app/services/test_docgen_html2docx.py +++ b/sdk/app/services/test_docgen_html2docx.py @@ -127,6 +127,96 @@ def test_bold_italic_code_runs(tmp_path): assert code_found +# --------------------------------------------------------------------------- +# 缺陷 #47a(2026-09-11):红头/标题样式从 render_html 产物解析(css 参数) +# --------------------------------------------------------------------------- + +HTML_OFFICIAL_STYLED = """ +红头公文 +
某某单位文件
+
+

关于加强安全生产的通知

+

一、总体要求

+

1.1 具体要求

+

正文内容。

+
+""" + +OFFICIAL_CSS = """ +.doc-letterhead { color: #c00000; font-size: 30px; font-weight: bold; } +h1.doc-title { font-size: 22px; } +h2 { font-size: 16px; } +h3, h4, h5, h6 { font-size: 15px; } +""" + + +def test_letterhead_style_from_css_param(tmp_path): + """红头样式从模板 CSS 解析:#c00000 系颜色、30px→22.5pt 字号。""" + out = docx_from_html_document( + HTML_OFFICIAL_STYLED, title="红头公文", + out_path=tmp_path / "a.docx", css=OFFICIAL_CSS, + ) + doc = Document(str(out)) + head = doc.paragraphs[0] + assert "某某单位文件" in head.text + assert head.alignment.name == "CENTER" + run = head.runs[0] + assert str(_first_run_color(run)) == "C00000" + assert run.font.size is not None and abs(run.font.size.pt - 22.5) < 0.01 + assert run.font.bold is True + + +def test_heading_sizes_from_css_param(tmp_path): + """标题字号从模板 CSS 解析:h1 22px→16.5pt、h2 16px→12pt、h3 15px→11.25pt。""" + out = docx_from_html_document( + HTML_OFFICIAL_STYLED, title="红头公文", + out_path=tmp_path / "b.docx", css=OFFICIAL_CSS, + ) + doc = Document(str(out)) + sizes = {} + for p in doc.paragraphs: + if p.style.name.startswith("Heading") and p.runs: + sizes[p.style.name] = p.runs[0].font.size.pt + # DOCX 字号按半点(half-point)量化:11.25pt 落 22 半点 = 11.0pt + assert abs(sizes.get("Heading 1") - 16.5) < 0.01 + assert abs(sizes.get("Heading 2") - 12.0) < 0.01 + assert abs(sizes.get("Heading 3") - 11.25) <= 0.26 + + +def test_no_style_falls_back_to_legacy_constants(tmp_path): + """无样式 HTML(不传 css)→ 回退 r3 常量(FF0000/22pt 红头,22/16/14pt 标题)。""" + out = docx_from_html_document(HTML_SAMPLE, title="测试公文", + out_path=tmp_path / "c.docx") + doc = Document(str(out)) + head = doc.paragraphs[0] + run = head.runs[0] + assert str(_first_run_color(run)) == "FF0000" + assert run.font.size.pt == 22.0 + sizes = {} + for p in doc.paragraphs: + if p.style.name.startswith("Heading") and p.runs: + sizes[p.style.name] = p.runs[0].font.size.pt + assert sizes.get("Heading 1") == 22.0 + assert sizes.get("Heading 2") == 16.0 + assert sizes.get("Heading 3") == 14.0 + + +def test_report_template_grey_letterhead_not_red(tmp_path): + """非红头模板(report:灰 #333、26px)不误渲染红头。""" + report_css = """ + .doc-letterhead { color: #333333; font-size: 26px; font-weight: bold; } + h1.doc-title { font-size: 22px; } + """ + out = docx_from_html_document( + HTML_OFFICIAL_STYLED, title="报告", + out_path=tmp_path / "d.docx", css=report_css, + ) + doc = Document(str(out)) + run = doc.paragraphs[0].runs[0] + assert str(_first_run_color(run)) == "333333" + assert abs(run.font.size.pt - 19.5) < 0.01 # 26px * 0.75 + + # r3-47-01 复现样例:真实 render_html 产物形态—— #
…
包裹层 + 嵌套 section HTML_WRAPPED = """ diff --git a/sdk/app/services/test_docgen_intent.py b/sdk/app/services/test_docgen_intent.py index ce983eb..35947a6 100644 --- a/sdk/app/services/test_docgen_intent.py +++ b/sdk/app/services/test_docgen_intent.py @@ -47,6 +47,11 @@ CASES = [ ("帮我写个会议通知模板", False, True, "docgen"), ("给这个通知加个结尾", False, True, "edit"), ("增加一段关于风险的描述", False, True, "edit"), + # r4 #52(2026-09-11)新增:总结/概括/归纳 三动词 + 动词自指保护 + ("帮我总结论文", True, False, "docgen"), + ("总结一下植物光合作用的过程", False, False, "qa"), + ("概括一下这个通知", True, True, "docgen"), + ("归纳这篇文章", True, False, "docgen"), ]