From 00eb0bc1d03d8d76b9fc752f637b655ba88368ba Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 11 Sep 2026 14:18:43 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(r4-27):=20normalize=5Fsource=20title=20?= =?UTF-8?q?=E7=A9=BA=E5=80=BC=E5=9B=9E=E5=A1=AB(=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E5=90=8D/=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=AD=97=E6=AE=B5,=E6=AD=A3=E6=96=87=E7=89=87=E6=AE=B5?= =?UTF-8?q?=E7=BB=9D=E4=B8=8D=E5=8F=82=E4=B8=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消息级 sources items 的 title 为空导致前端文献列表渲染出正文片段。 回填优先级:顶层标题类字段(publication_title/article_title/journal_title/name) → metadata 标题类字段(paper_title/source_entity/name/source) → 顶层 source(断言主体实体名);全部缺失保持 null(前端有 fallback)。 paper_id/score/snippet 等既有字段不受影响。 Co-Authored-By: Claude Fable 5 --- sdk/app/services/normalize.py | 27 ++++++++++++++++++ sdk/app/services/test_normalize.py | 46 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/sdk/app/services/normalize.py b/sdk/app/services/normalize.py index fc3d40d..0fd94b6 100644 --- a/sdk/app/services/normalize.py +++ b/sdk/app/services/normalize.py @@ -20,6 +20,16 @@ def pick(obj: dict, *names: str, default=None): # --------------------------------------------------------------------------- # §8 Source 归一化(缺失字段键必须存在,值为 null) # --------------------------------------------------------------------------- +# title 回填优先级(缺陷 #27,2026-09-11):title/paper_title 缺失时, +# 依次从以下字段取可读文本(论文标题优先于实体名),全部缺失才保持 null。 +# 排除 content/evidence/text/snippet 正文字段——正文片段不是标题。 +_TITLE_FALLBACK_TOPLEVEL = ( + "publication_title", "article_title", "journal_title", "name", +) +_TITLE_FALLBACK_METADATA = ( + "paper_title", "publication_title", "article_title", "journal_title", + "source_entity", "name", "source", +) def normalize_source(item: dict[str, Any] | None) -> dict[str, Any]: """单个 source item → API.md §8 形态(所有键存在,缺失为 null)。 @@ -41,6 +51,23 @@ def normalize_source(item: dict[str, Any] | None) -> dict[str, Any]: source = pick(item, "source") # 断言主体实体名(上游语义) title = pick(item, "title", "paper_title") + # 缺陷 #27:title 为空时按固定优先级回填—— + # 顶层标题类字段 → metadata 标题类字段 → 顶层 source(断言主体实体名)。 + # 正文字段绝不参与(正文片段不是标题)。 + if not title: + for _key in _TITLE_FALLBACK_TOPLEVEL: + _candidate = pick(item, _key) + if _candidate: + title = _candidate + break + if not title and isinstance(pick(item, "metadata"), dict): + for _key in _TITLE_FALLBACK_METADATA: + _candidate = pick(item["metadata"], _key) + if _candidate: + title = _candidate + break + if not title: + title = source doi = pick(item, "doi") url = pick(item, "url", "doi_url") diff --git a/sdk/app/services/test_normalize.py b/sdk/app/services/test_normalize.py index 8723245..f51a207 100644 --- a/sdk/app/services/test_normalize.py +++ b/sdk/app/services/test_normalize.py @@ -84,6 +84,52 @@ def test_normalize_source_only_doi_derives_url(): assert src["metadata"] == {} +# --------------------------------------------------------------------------- +# §8 title 回填(缺陷 #27,2026-09-11) +# --------------------------------------------------------------------------- + +def test_normalize_source_title_fill_from_source_entity(): + """title 为空 -> 回填 source(断言主体实体名),正文片段绝不参与。""" + src = normalize_source({ + "source": "OsSPL14", + "title": "", + "evidence": "## 正文片段开头(不是标题)", + "paper_id": "P1", + }) + assert src["title"] == "OsSPL14" + # title 即 source 回填:两者相等,source_entity 不落 metadata(不重复) + assert src["metadata"].get("source_entity") is None + assert src["paper_id"] == "P1" + + +def test_normalize_source_title_fill_from_metadata(): + """title/paper_title 均空 -> metadata 标题类字段回填。""" + src = normalize_source({ + "source": "entity", + "metadata": {"paper_title": "水稻抗病基因研究进展", "source_entity": "entity"}, + }) + assert src["title"] == "水稻抗病基因研究进展" + + +def test_normalize_source_title_not_overridden(): + """title 非空 -> 不回填(既有语义不变)。""" + src = normalize_source({ + "title": "Real Title", + "source": "Entity", + "paper_title": "Other", + "metadata": {"paper_title": "Meta"}, + }) + assert src["title"] == "Real Title" + assert src["metadata"]["source_entity"] == "Entity" + + +def test_normalize_source_title_all_empty_stays_none(): + """全空 -> 保持 null(前端有 fallback)。""" + src = normalize_source({"content": "snippet only", "paper_id": "P1"}) + assert src["title"] is None + assert src["snippet"] == "snippet only" + + def test_normalize_source_alias_chains(): src = normalize_source({ "content": "snippet text", From 28bee31d005d878ae501c5a742f1f685cdeebfa8 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 11 Sep 2026 14:18:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(r4-28):=20=E6=A3=80=E7=B4=A2=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=80=A7=E4=B8=8B=E9=99=90,=E6=97=A0=E5=85=B3?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E7=A9=BA=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _select_graph_balanced 引入相关性下限 0.25(score_gate.py 具名常量, env QA_MIN_SOURCE_SCORE 可覆盖):低于下限的候选不入选,全部低于下限 返回空;graph 配额语义不变,audit 增 min_source_score/below_min_source_score。 Qdrant 查询(direct_flow_retriever/v16_retriever 两处)加同源 score_threshold 双保险(collection distance=Cosine,0.25 与垃圾实测带 0.19-0.20 留余量、 远低于正常命中 >0.7)。pc_retriever 为禁用后端,零改动。 新增 test_score_gate.py:30 低->0、5 高+25 低->5、graph 配额、env 覆盖。 Co-Authored-By: Claude Fable 5 --- .../rag/qa_v1_1_73/direct_flow_retriever.py | 4 + agent/qa/src/rag/qa_v1_1_73/pipeline.py | 11 +++ agent/qa/src/rag/qa_v1_1_73/score_gate.py | 53 ++++++++++ .../qa/src/rag/qa_v1_1_73/test_score_gate.py | 98 +++++++++++++++++++ agent/qa/src/rag/qa_v1_1_73/v16_retriever.py | 5 + 5 files changed, 171 insertions(+) create mode 100644 agent/qa/src/rag/qa_v1_1_73/score_gate.py create mode 100644 agent/qa/src/rag/qa_v1_1_73/test_score_gate.py diff --git a/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py b/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py index ca793a4..196704a 100644 --- a/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py +++ b/agent/qa/src/rag/qa_v1_1_73/direct_flow_retriever.py @@ -16,6 +16,7 @@ from src.rag.normalized_release import ( ) from .schemas import EvidenceItem, RetrievalHypothesis +from .score_gate import min_source_score ALLOWED_QDRANT_COLLECTIONS = { "plant_srt_direct_v1_bge_m3_20260702", @@ -203,11 +204,14 @@ class DirectFlowRetriever: def qdrant_search(self, query: str, limit: int) -> List[EvidenceItem]: try: vector = self.embed(query) + # 缺陷 #28(2026-09-11):相关性下限双保险(与 pipeline + # _select_graph_balanced 同源同值,见 score_gate.py)。 response = self.qdrant.query_points( collection_name=self.collection, query=vector, limit=limit, with_payload=True, + score_threshold=min_source_score(), ) except Exception: return [] diff --git a/agent/qa/src/rag/qa_v1_1_73/pipeline.py b/agent/qa/src/rag/qa_v1_1_73/pipeline.py index 1dbdf48..5f3f590 100644 --- a/agent/qa/src/rag/qa_v1_1_73/pipeline.py +++ b/agent/qa/src/rag/qa_v1_1_73/pipeline.py @@ -43,6 +43,7 @@ from .question_decomposition import QuestionDecompositionPlanner from .query_builder import QueryBuilder from .reranker import BGEReranker from .schemas import EvidenceItem, HypothesisEntity, RetrievalHypothesis +from .score_gate import min_source_score from .soft_gate_policy import ( LEGACY, doi_from_paper_id, @@ -3317,6 +3318,14 @@ class QAV1Pipeline: top_k_for_llm: int, graph_min_evidence: int, ) -> tuple[List[EvidenceItem], Dict[str, Any]]: + # 缺陷 #28(2026-09-11):相关性下限。低于下限的候选不入选; + # 全部低于下限 -> 返回空(前端呈现未命中,与 Qdrant + # score_threshold 双保险)。阈值见 score_gate.py(env 可覆盖)。 + _min_score = min_source_score() + _below_min = len(ranked) - sum( + 1 for item in ranked if item.final_score >= _min_score + ) # 被下限过滤的候选数(审计用,见 return 处) + ranked = [item for item in ranked if item.final_score >= _min_score] graph_items = [item for item in ranked if cls._is_graph_item(item)] other_items = [item for item in ranked if not cls._is_graph_item(item)] selected: List[EvidenceItem] = [] @@ -3365,6 +3374,8 @@ class QAV1Pipeline: "graph_selected": graph_selected, "non_graph_selected": len(selected) - graph_selected, "graph_quota_satisfied": graph_selected >= graph_min_evidence, + "min_source_score": _min_score, + "below_min_source_score": _below_min, "note": "Graph quota is enforced after BGE rerank. If fewer graph candidates exist, all available graph candidates are retained and the deficit is reported.", } diff --git a/agent/qa/src/rag/qa_v1_1_73/score_gate.py b/agent/qa/src/rag/qa_v1_1_73/score_gate.py new file mode 100644 index 0000000..2b1c65a --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/score_gate.py @@ -0,0 +1,53 @@ +"""score_gate.py —— 检索相关性下限(缺陷 #28,2026-09-11)。 + +语义:候选证据的最终得分(EvidenceItem.final_score)低于下限即视为 +「与问题无关」,不得进入 evidence_top30 / sources。 + +证据与阈值依据(2026-09-11 实测): +- 无关问题「景甜是谁」实测 30 条 final_score 全部 0.19-0.20(纯植物学 + 无关文献);正常命中(DISCOVERY.md §4.1 金标样本)vector_score=0.714、 + rerank_score=0.989、final_score=0.982。 +- BGE rerank 路径 final_score = 0.8*bge(sigmoid) + 0.2*rrf/max_rrf; + bge 是 sigmoid 概率,0.20 垃圾分对应 raw logit ≈ -1.4,0.25 对应 -1.1, + 0.30 对应 -0.85,正常命中的 sigmoid 通常 >0.9。 +- Qdrant collection distance=Cosine(score ∈ [-1,1];bge-m3 文本嵌入对 + 随机中文问题的 top-k 得分通常 <0.2)。 + +默认 0.25:与垃圾实测带(0.19-0.20)留 ~25% 余量,又显著低于正常命中 +(>0.7),同时为 0.2~0.3 边缘问题保留再排序机会(阈值过滤器只是预筛, +被过滤者已低于下限,保留也无意义)。 +env `QA_MIN_SOURCE_SCORE` 可覆盖(0-1,非法值回落默认),供复测按 +acceptance-r4 §8.3 授权临时调参。 + +本模块无第三方依赖、无 IO,可被 v16_retriever / direct_flow_retriever / +pipeline 直接 import(编译期与运行期均安全)。 +""" +from __future__ import annotations + +import os + +# 相关性下限(与 Qdrant score_threshold 同源同值,双保险) +DEFAULT_MIN_SOURCE_SCORE = 0.25 +_ENV_KEY = "QA_MIN_SOURCE_SCORE" + + +def _env_override() -> float: + """env 覆盖:QA_MIN_SOURCE_SCORE ∈ [0,1] 才生效,否则回落默认。""" + raw = os.environ.get(_ENV_KEY, "").strip() + if not raw: + return DEFAULT_MIN_SOURCE_SCORE + try: + value = float(raw) + except ValueError: + return DEFAULT_MIN_SOURCE_SCORE + if not (0.0 <= value <= 1.0): + return DEFAULT_MIN_SOURCE_SCORE + return value + + +def min_source_score() -> float: + """当前生效的相关性下限(模块导入期缓存 env 值,与部署环境一致)。""" + return min_source_score.value + + +min_source_score.value = _env_override() # type: ignore[attr-defined] diff --git a/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py b/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py new file mode 100644 index 0000000..a46ea4e --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_score_gate.py @@ -0,0 +1,98 @@ +"""test_score_gate.py —— 相关性下限选择单测(缺陷 #28,acceptance-r4 §2.4)。 + +运行(venv 内,源码树): + cd agent/qa/src/rag/qa_v1_1_73 + python -m pytest test_score_gate.py -v +(编译期 compile_qa.sh 会把全部 test_*.py 一并编进 .so 树,部署时不冲突; +正式单测都在源码树跑。) + +覆盖: +- 30 条全部低于下限 -> 选入 0(acceptance 桩) +- 5 高 + 25 低 -> 仅选入 5(遵循现有配额逻辑) +- graph 配额不受影响;低于下限的 graph 候选不入选 +- env 覆盖 QA_MIN_SOURCE_SCORE 生效与非法值回落 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +# 本文件位于 agent/qa/src/rag/qa_v1_1_73/,parents[2] = agent/qa/src +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from rag.qa_v1_1_73 import score_gate # noqa: E402 +from rag.qa_v1_1_73.schemas import EvidenceItem # noqa: E402 +from rag.qa_v1_1_73.pipeline import QAV1Pipeline # noqa: E402 + + +def _item(idx: int, *, final: float, graph: bool = False, key: str = "") -> EvidenceItem: + return EvidenceItem( + evidence_id=f"E{idx}", + kb="V16", + source=f"src{idx}", + relation="has", + target=f"tgt{idx}", + evidence=f"evidence body {idx}", + relation_id=(key or f"rid-{idx}"), + retrieval_methods=(["neo4j_bounded_expand"] if graph else ["qdrant_vector"]), + final_score=final, + ) + + +def _select(items, *, top_k=30, graph_min=0): + selected, audit = QAV1Pipeline._select_graph_balanced( + items, top_k_for_llm=top_k, graph_min_evidence=graph_min + ) + return selected, audit + + +def test_all_below_threshold_selects_zero(): + """acceptance §2.4:30 条全部低于下限 -> 选入 0。""" + items = [_item(i, final=0.20) for i in range(30)] + selected, audit = _select(items) + assert selected == [] + assert audit["policy"].startswith("graph_balanced_top30") + assert audit["below_min_source_score"] == 30 + assert audit["min_source_score"] == score_gate.min_source_score() + + +def test_five_high_twentyfive_low_selects_only_five(): + """acceptance §2.4:5 高 + 25 低 -> 仅选入 5(沿用现有配额与 rerank 序)。""" + items = [_item(i, final=0.90 - i * 0.01) for i in range(5)] + items += [_item(5 + i, final=0.20) for i in range(25)] + selected, audit = _select(items) + assert len(selected) == 5 + assert {item.evidence_id for item in selected} == {f"E{i}" for i in range(5)} + # 顺序保持 rerank 序 + assert [item.evidence_id for item in selected] == [f"E{i}" for i in range(5)] + assert audit["below_min_source_score"] == 25 + + +def test_below_threshold_graph_candidates_excluded(): + """低分 graph 候选不占用 graph 配额(配额逻辑语义不变)。""" + items = [ + _item(1, final=0.30, graph=True), + _item(2, final=0.21, graph=True), + _item(3, final=0.95, graph=False), + _item(4, final=0.19, graph=False), + ] + selected, audit = _select(items, top_k=30, graph_min=1) + assert [item.evidence_id for item in selected] == ["E1", "E3"] + assert audit["graph_selected"] == 1 + assert audit["below_min_source_score"] == 2 + + +def test_env_override_and_invalid_fallback(monkeypatch): + """env 解析函数行为:合法值生效;非法值/越界回落默认。""" + monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "0.35") + assert score_gate._env_override() == 0.35 + monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "abc") + assert score_gate._env_override() == score_gate.DEFAULT_MIN_SOURCE_SCORE + monkeypatch.setenv("QA_MIN_SOURCE_SCORE", "1.5") + assert score_gate._env_override() == score_gate.DEFAULT_MIN_SOURCE_SCORE + monkeypatch.delenv("QA_MIN_SOURCE_SCORE", raising=False) + # 生效值(value 属性)驱动 _select_graph_balanced 的过滤 + monkeypatch.setattr(score_gate.min_source_score, "value", 0.35) + items = [_item(i, final=0.30) for i in range(5)] + selected, _ = _select(items) + assert selected == [] diff --git a/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py b/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py index 1e92d3f..a03b50d 100644 --- a/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py +++ b/agent/qa/src/rag/qa_v1_1_73/v16_retriever.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List import requests from neo4j import GraphDatabase from qdrant_client import QdrantClient +from .score_gate import min_source_score TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{2,}|[\u4e00-\u9fff]{2,}") @@ -100,11 +101,15 @@ class V16IndependentRetriever: self, question: str, limit: int, include_candidate: bool ) -> List[Dict[str, Any]]: try: + # 缺陷 #28(2026-09-11):相关性下限双保险(与 pipeline + # _select_graph_balanced 同源同值)。collection distance=Cosine, + # 阈值语义 = 余弦相似度下限;低于下限的点直接不返回。 response = self.qdrant.query_points( collection_name=self.collection, query=self.embed(question), limit=limit * 2, with_payload=True, + score_threshold=min_source_score(), ) except Exception: return [] From f18286f0226f5f425a7348921308be65b1a4d210 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 11 Sep 2026 14:19:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(r4-47a/52):=20DOCX=20=E7=BA=A2=E5=A4=B4?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=B7=E5=BC=8F=E5=90=8C=E6=BA=90=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20+=20=E6=84=8F=E5=9B=BE=E8=AF=8D=E5=85=B8=E8=A1=A5?= =?UTF-8?q?=E6=80=BB=E7=BB=93/=E6=A6=82=E6=8B=AC/=E5=BD=92=E7=BA=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #47a: 红头/标题字号颜色从 render_html 产物解析(新增 docgen/html_css.py: 内嵌 style 块 + 调用方传入的模板 css,组合选择器展开、px->pt),解析失败 回退历史常量(FF0000/22pt、h1/h2/h3 22/16/14pt,行为不变)。converter 与 导出路由把 css 传入 DOCX 转换,与 PDF/预览同源;非红头模板(如 report 灰 #333)不误渲染红头。r3 的 doc-body 包裹/表格/图片结构不回归。 #52: intent.py WRITE_VERB 补总结/概括/归纳,新增动词自指保护(唯一命中的 DOC_TYPE 等于命中动词本身时不算写文档意图,如「总结一下植物光合作用的过程」 仍 qa);cases_intent.md 同步规则与用例 31-34(共 34 条)。 Co-Authored-By: Claude Fable 5 --- docs/team/cases_intent.md | 12 +- sdk/app/routers/docgen.py | 6 +- sdk/app/routers/test_docgen_api.py | 2 +- sdk/app/services/docgen/converter.py | 12 +- sdk/app/services/docgen/html2docx.py | 27 +++- sdk/app/services/docgen/html_css.py | 155 ++++++++++++++++++++++ sdk/app/services/docgen/intent.py | 15 ++- sdk/app/services/test_docgen_html2docx.py | 90 +++++++++++++ sdk/app/services/test_docgen_intent.py | 5 + 9 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 sdk/app/services/docgen/html_css.py 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"), ]