Merge branch 'debug/backend1' into feature/bioflow-conversation-orchestration

This commit is contained in:
2026-09-11 14:21:22 +08:00
16 changed files with 551 additions and 17 deletions
@@ -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 []
+11
View File
@@ -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.",
}
+53
View File
@@ -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]
@@ -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 == []
@@ -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 []
+10 -2
View File
@@ -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 必须按本表逐条通过
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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=""):
+8 -4
View File
@@ -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 内嵌 <style> 并回退常量(历史行为不变)。
"""
from .html2docx import docx_from_html_document as _sync
with tempfile.TemporaryDirectory() as tmp:
out = _sync(html, title=title, out_path=Path(tmp) / "document.docx")
out = _sync(html, title=title, out_path=Path(tmp) / "document.docx", css=css)
return out.read_bytes()
+21 -6
View File
@@ -35,12 +35,16 @@ from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
from . import html_css # 缺陷 #47a:模板 CSS 解析(预览同源)
logger = logging.getLogger(__name__)
# 字体:正文宋体 / 标题黑体(与 md2docx 一致;Windows 中文环境标准)
_FONT_BODY = "宋体"
_FONT_HEADING = "黑体"
# 缺陷 #47a(2026-09-11):红头/标题样式从 render_html 产物(<style> 内嵌
# CSS)解析,与预览/PDF 同源;以下仅为解析失败时的回退常量(保留 r3 行为)。
_HEADING_SIZES = {1: 22, 2: 16, 3: 14}
_LETTERHEAD_SIZE = 22.0
_IMAGE_MAX_WIDTH_IN = 6.0 # A4 正文可用宽度约 6 英寸
@@ -60,6 +64,9 @@ _LEAF_BLOCKS = {
# 遍历时整体跳过的标签
_SKIP_TAGS = {"script", "style", "meta", "link", "title", "head"}
# 当前文档解析出的标题字号(docx_from_html_document 设置;单线程转换,无并发问题)
_ACTIVE_HEADING_SIZES: dict[int, float] = dict(_HEADING_SIZES)
def _set_run_font(run, font_name: str, size_pt: float | None = None,
bold: bool | None = None, italic: bool | None = None,
@@ -192,7 +199,7 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
if level:
para = doc.add_heading("", level=level)
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, _ACTIVE_HEADING_SIZES.get(level, _HEADING_SIZES[level]), bold=True)
para.paragraph_format.space_before = Pt(10)
return
@@ -334,16 +341,24 @@ def _add_image(doc: Document, img: Tag) -> None:
_set_run_font(run, _FONT_BODY, 10.5)
def docx_from_html_document(html: str, title: str = "", out_path: str | Path | None = None) -> Path:
def docx_from_html_document(html: str, title: str = "", out_path: str | Path | None = None, *, css: str = "") -> Path:
"""HTML → docx。返回写入路径(out_path 为 None 时按 title 命名到当前目录)。
- 红头:.doc-letterhead 红字(RGBColor(255,0,0))居中 22pt 黑体
- h1-h3:黑体 22/16/14pt 加粗
- 红头:.doc-letterhead 颜色/字号从 render_html 产物解析(css 参数 +
html 内嵌 <style>),解析失败回退历史常量(FF0000/22pt)
- h1-h3:黑体,字号同样解析优先、回退 22/16/14pt
- 表格 Table Grid + 表头灰底;figure/img 内嵌(失败落 alt)
- 空 HTML 产出空文档(不抛)
"""
doc = Document()
soup = BeautifulSoup(html or "", "html.parser")
# 缺陷 #47a:红头/标题样式同源解析(render_html 产物 -> DOCX 与预览一致)
styles = html_css.parse_css_styles(html)
if css:
styles = html_css.merge_css_text(styles, css)
letterhead_color, letterhead_size = html_css.letterhead_style(styles)
global _ACTIVE_HEADING_SIZES
_ACTIVE_HEADING_SIZES = html_css.heading_sizes(styles)
# 标题:<title> 优先(render_html 已回填 {{TITLE}})
if not title:
@@ -361,8 +376,8 @@ def docx_from_html_document(html: str, title: str = "", out_path: str | Path | N
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run(letterhead)
_set_run_font(run, _FONT_HEADING, _LETTERHEAD_SIZE, bold=True,
color=RGBColor(255, 0, 0))
_set_run_font(run, _FONT_HEADING, letterhead_size, bold=True,
color=RGBColor(*letterhead_color))
letterhead_el.decompose()
# 主内容:main 优先;递归下钻包裹容器(doc-body 等),保文档序
+155
View File
@@ -0,0 +1,155 @@
"""services/docgen/html_css.py —— 自家 render_html 产物的 CSS 解析(缺陷 #47a,2026-09-11)。
render_html 返回 (html, css):html 是模板 wrapper(css 不内联,
css 由调用方单独持有——PDF 路径与预览拼装时注入)。html2docx 的输入
原本只有 html,缺陷 #47a 后调用方(docgen 路由)把 css 一并传入
(converter.docx_from_html_document(html, title, css)),本模块负责:
- 解析 html 内嵌 <style> 块(预览注入后的产物形态);
- 合并调用方传入的模板 CSS 文本(后出现者优先,与调用方拼装语义一致);
- 解析不到声明时回退历史硬编码常量(红头 FF0000 22pt、h1/h2/h3 22/16/14pt),
保证无样式 HTML / 解析失败的行为与 r3 一致。
解析只覆盖 CSS 安全子集(块流/字体/颜色,见 templates 各 css 文件头注释)。
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup, Tag
# ---- 历史硬编码常量(解析不出声明时的最终回退,行为与 r3 相同)----
_LEGACY_LETTERHEAD_COLOR = (255, 0, 0) # FF0000
_LEGACY_LETTERHEAD_SIZE_PT = 22.0
_LEGACY_HEADING_SIZES_PT = {1: 22.0, 2: 16.0, 3: 14.0}
_ColorRGB = tuple[int, int, int]
# rgb(r, g, b) / rgba(...) / #rgb / #rrggbb(容忍空白)
_RGB_RE = re.compile(
r"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})", re.IGNORECASE
)
_HEX6_RE = re.compile(r"#([0-9a-fA-F]{6})\b")
_HEX3_RE = re.compile(r"#([0-9a-fA-F]{3})\b")
# 1.2e3 / 12.5 / 12 后接 px|pt|em|rem(匹配声明值中的尺寸)
_SIZE_RE = re.compile(
r"(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*(px|pt|em|rem)\b",
re.IGNORECASE,
)
def _declarations(style: Tag) -> list[dict[str, str]]:
"""解析 <style> 文本为声明块列表:{selector, prop: value...}。
组合选择器("h3, h4, h5, h6")按逗号拆分为独立条目,
让 h3/h4… 各自可被单独查取(模板 CSS 只含简单元素/类选择器)。
"""
out: list[dict[str, str]] = []
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", style.get_text() or ""):
selectors = [sel.strip() for sel in match.group(1).split(",") if sel.strip()]
if not selectors:
continue
block: dict[str, str] = {}
for decl in re.findall(r"([\w-]+)\s*:\s*([^;]+);", match.group(2)):
block[decl[0].strip().lower()] = decl[1].strip()
if not block:
continue
for selector in selectors:
out.append({"selector": selector, "block": dict(block)})
return out
def parse_rgb(value: str) -> _ColorRGB | None:
"""CSS 颜色 → (r,g,b);支持 #rrggbb/#rgb/rgb()/rgba(),其余返回 None。"""
if not value:
return None
m = _HEX6_RE.search(value)
if m:
h = m.group(1)
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
m = _HEX3_RE.search(value)
if m:
h = m.group(1)
return (int(h[0], 16) * 17, int(h[1], 16) * 17, int(h[2], 16) * 17)
m = _RGB_RE.search(value)
if m:
r, g, b = (max(0, min(255, int(c))) for c in m.groups())
return (r, g, b)
return None
def parse_size_pt(value: str) -> float | None:
"""CSS 尺寸 → pt(px 按 96dpi:1px = 0.75pt);非尺寸返回 None。"""
if not value:
return None
m = _SIZE_RE.search(value)
if not m:
return None
number = float(m.group(1))
unit = m.group(2).lower()
if unit == "px":
return number * 0.75
if unit in ("pt", "em", "rem"):
return number
return None
def parse_css_styles(html: str) -> dict[str, dict[str, str]]:
"""HTML 内嵌 <style> 块 → {selector: {prop: value}}。
多个 <style> 按出现顺序合并,后出现者优先(与浏览器级联一致)。
无 <style> 时返回空 dict(调用方回退常量)。
"""
merged: dict[str, dict[str, str]] = {}
if not html:
return merged
soup = BeautifulSoup(html, "html.parser")
for style in soup.find_all("style"):
for decl in _declarations(style):
block = merged.setdefault(decl["selector"], {})
block.update(decl["block"])
return merged
def merge_css_text(
styles: dict[str, dict[str, str]], css: str
) -> dict[str, dict[str, str]]:
"""把调用方持有的模板 CSS 文本(render_html 返回的 css)并入解析结果。
css 文本通常不含 <style> 标签,这里包一层再走同一解析器;
后出现者优先(模板 CSS 覆盖 html 内嵌声明,与调用方拼装语义一致)。
"""
if not css:
return styles
merged = dict(styles)
wrapped = BeautifulSoup(f"<style>{css}</style>", "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)
}
+12 -3
View File
@@ -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):
+27
View File
@@ -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")
+90
View File
@@ -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 = """<!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>
<h2>一、总体要求</h2>
<h3>1.1 具体要求</h3>
<p>正文内容。</p>
</div></main>
</body></html>"""
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 产物形态——
# <main><div class="doc-body">…</div></main> 包裹层 + 嵌套 section
HTML_WRAPPED = """<!DOCTYPE html><html><head><meta charset="utf-8">
+5
View File
@@ -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"),
]
+46
View File
@@ -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",