关于加强安全生产的通知
+一、总体要求
+1.1 具体要求
+正文内容。
+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 [] 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/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_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 = """
+正文内容。
+