45 KiB
PubMed 搜索合规修复全记录
本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
累计:10 轮,121 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制 时间跨度:2026-07-24 ~ 2026-07-28 核心文件:
pubmed_query_parser.py(~730 行)→search_engine.py(~1350 行)
目录
- 第一轮:Phase 0-7 基础修复(34 项)
- 第二轮:第二轮审计修复(8 项)
- 第三轮:第三轮审计修复(14 项)
- 第四轮:字段补全与语义优化(11 项)
- 第五轮:第 5 轮全面审计修复(4 项)
- 第六轮:第 6 轮全面审计修复(12 项)
- 第七轮:第 7 轮深度审计修复(20 项)
- 第八轮:第 8 轮深度审计修复(12 项)
- 第九轮:第 9 轮深度审计修复(5 项)
- 第十轮:第 10 轮深度审计修复(6 项)
- 遗留限制
第一轮:Phase 0-7 基础修复
提交:723c4fc / 62ca8fa / 5f69277
日期:2026-07-24 ~ 2026-07-25
数量:34 项
触发:9 Agent 并行深度审计
背景
首次系统审计发现搜索功能存在大量阻塞性 Bug:MeSH 搜索因 INNER JOIN 返回 0 结果、布尔运算符 OR 被当作 AND 处理、PM C_ID 格式混乱、数据导入缺失 journal_iso 和 keywords 等关键字段。整体搜索几不可用。
Phase 0 — 关键 Bug 修复(12 项)
| # | 修复项 | 文件 | 问题描述 | 根因分析 | 修改内容 |
|---|---|---|---|---|---|
| 0.1 | _expand_mesh_tag_ids INNER JOIN |
search_engine.py:518 |
所有 [MH]/[MAJR] 搜索返回 0 个结果。tree_numbers 表无 C04 数据,INNER JOIN 过滤掉所有标签 |
select(GlobalTag.id).join(GlobalTagTreeNumber) 在有 tree_number 的标签时才返回行 |
改为 select(GlobalTag.id).where(...),使 MeSH 搜索在无 tree_number 展开时仍能工作 |
| 0.2 | recent_subq 条件化 |
search_engine.py:314-319 |
有搜索词时仍被 pub_date > 90d 子查询过滤,漏掉旧文章 |
未检查 query.strip() 就附加 recent 子句 |
有搜索词或历史查询时跳过 recent 子查询 |
| 0.3 | OR 布尔运算符 | search_engine.py:394-414 |
同字段多个词始终 AND,lung[TI] OR breast[TI] 只返回同时命中 lung 和 breast 的文献 |
boolean 参数只在文本搜索路径生效,未传入 _field_conditions |
同 field 按 boolean_operator 用 or_() 组合 |
| 0.4 | 多 [MH] 词 AND 组合 | search_engine.py:516-519 |
lung neoplasms[MH] AND immunotherapy[MH] 返回 0 结果 |
所有 mesh 词合并到一个 subq IN,必须所有标签同时关联同一篇文献 | 逐 term 独立 subq,按 boolean 组合(AND 用 intersect) |
| 0.5 | PMC XPath 上下文 | pubmed_api.py:440,447 |
pmc_id/is_oa 解析错误,只有 9/1662 篇有值 | article.findall('.//PMCID') 在 article 而非 article_elem 上调用 |
article.findall → article_elem.findall |
| 0.6 | 构造函数遗漏 | pubmed_api.py:914-944 |
pubmed_revised/citation_status/date_completed/article_date/suppl_mesh_list 未写入模型 |
_parse_pubmed_xml() 返回值不包含这些字段 |
补全 5 字段传递 |
| 0.7 | ArticleTitle itertext | pubmed_api.py:409 |
含 <i>/<sub> 等内联标记的标题截断 |
.text 只返回第一个文本节点 |
"".join(itertext()) |
| 0.8 | PMC_ID 格式统一 | pubmed_api.py:264 |
"PMC1234567" vs "1234567" 混用 | lstrip("PMC") → 会误删 PMC 开头真实数字 |
art.get("pmcid", "").lstrip("PMC") |
| 0.9 | 搜索端点异常处理 | features.py:68 |
搜索异常暴露 500 内幕 | 无 try/except | 加异常处理返回 400 |
| 0.10 | field:all 硬编码 | SearchView.vue:83 |
前端固定发送 field: "all" |
模板字符串手误 | 替换为动态 field.value |
| 0.11 | journal_iso 导入修复 | pubmed_api.py |
0% 覆盖率 | Journal ISOAbbreviation 未解析 | 增补 ISOAbbreviation 提取 |
| 0.12 | keywords 导入修复 | pubmed_api.py |
0% 覆盖率 | KeywordList 未解析 | 增补 KeywordList 解析 |
Phase 1 — P0 新功能(8 项)
| # | 任务 | 文件 | 说明 |
|---|---|---|---|
| 1.1 | MeSH 树号导入 | scripts/import_mesh_tags.py |
导入 NLM mtrees2025.bin,填充 GlobalTagTreeNumber |
| 1.2 | entrez_date 解析 |
pubmed_api.py + migration |
解析 PubmedData/History/PubMedPubDate[@PubStatus="entrez"] → [EDAT] 可搜 |
| 1.3 | [AD] 标签 |
search_engine.py + pubmed_query_parser.py |
机构字段映射到 authors JSONB cast |
| 1.4 | [LA] 标签 |
同上 | 语言字段 = GlobalLiterature.language |
| 1.5 | [EDAT]/[CRDT]/[MHDA]/[LR]/[DCOM]/[DEP] |
同上 | 6 个日期字段的范围+独立语法 |
| 1.6 | setweight 迁移 | 迁移脚本 | ⏳ 待办:tsvector 重建带 A/B 权重 |
| 1.7 | ATM 引擎 v1 | query_expansion.py |
SynonymExpander + MeSH 翻译 + 期刊翻译 |
| 1.8 | best_match 权重调优 | search_engine.py |
⏳ 待 setweight 后调优 |
Phase 2 — 字段标签覆盖(14 项)
| # | 任务 | 状态 | 说明 |
|---|---|---|---|
[OT] |
keywords 搜索 | ✅ | 映射到 all(第四轮修复为独立 keywords JSONB) |
[GR] |
grants 搜索 | ✅ | jsonb_array_elements + ILIKE |
[NM] |
化学物质名 | ✅ | chemical_list JSONB contains |
[RN] |
Registry Number | ✅ | chemical_list.registry_number |
[SH] |
Subheading | ✅ | mesh_headings.qualifiers |
[SI] |
DataBank | ✅ | databank_list.accession_numbers |
[PA] |
Pharmacological Action | ✅ | pharmacological_actions JSONB |
[TW] |
文本词 | ✅ | 映射到 all |
[TA]/[JT] |
期刊全称+缩写 | ✅ | journal ILIKE + journal_iso ILIKE |
[CN]/[FAU]/[LAU] |
作者变体 | ✅ | 均映射到 author 路径 |
| Entry Terms | MeSH 入口词 | ✅ | desc2025.asc → GlobalTag.entry_terms |
| 中文搜索 | CJK 支持 | ✅ | simple 词典而非 english |
| 多 affiliation | 捕获所有 | ✅ | find → findall + | 连接 |
Phase 3 — E-utilities 补充抽取(含 pubmed_api.py 修复)
- 反向兼容阶段,确保 pubmed_api.py 正确抽取所有字段
- PMC/trial_reg/retraction 等补充数据通路修复
Phase 4 — 搜索质量
- 历史搜索查询 + 日期筛选整合
best_match排序引入cited_by_count+ 年度梯度- 缺省排序降级保护
Phase 5 — 解析器健壮性
- 括号嵌套超过 10 层保护
- 空查询短路
- token 超限截断
- 未知字段标签静默降级
- Unicode normalization(全角数字、零宽字符)
Phase 6 — 前端搜索 UX
- SearchView URL 状态全量持久化(24 个参数)
- HomeView → SearchView 参数正确传递
- LiteratureCard
search事件冒泡 - 响应式布局适配(移动端搜索栏隐藏)
Phase 7 — API 验证
- 请求参数 Pydantic validator
field只接受预定义值- 查询长度限制
第二轮:第二轮审计修复
提交:e688241
日期:2026-07-26
数量:8 项 + 6 项新测试
触发:审计发现 Phase 1-7 修复中的遗留 Bug
背景
8 项在首次审计中遗漏的中高级 Bug,全部在后端搜索核心路径。
修复清单
F1: [MH:noexp] 顶层 _noexp 标志丢失
- 文件:
search_engine.py_pubmed_conditions()L640-650 - 问题:
mesh_terms处理时只提取.text,丢弃了_noexp标志,导致asthma[MH:noexp]在顶层使用时仍然树展开,和[MH]无异 - 根因:循环处理 vs 括号组内调用
_single_term_condition()两条路径——后者正确传递noexp,前者未分组 - 修复:将
mesh_terms按_noexp拆为两组,分别调用_expand_mesh_tag_ids:noexp_names = [t.text for t in pp.mesh_terms if t._noexp] exp_names = [t.text for t in pp.mesh_terms if not t._noexp]
F2: 组内 NOT 违反 De Morgan 律
- 文件:
search_engine.pyL862-877 - 问题:
NOT (A AND B)→ 被解析为not_(A) AND not_(B)=NOT (A OR B),语义完全翻转 - 根因:全 NOT 组内每个 term 独立
not_(),然后 AND 组合 - 修复:组的
all_not=True时,对所有 term 不做独立 NOT,改为not_(combined)包裹组合条件
F3: _parse_not_expr 不支持重复 NOT
- 文件:
pubmed_query_parser.pyL428-433 - 问题:
NOT NOT cancer→ 第二个 "NOT" 被降级为搜索字面词,变成NOT "NOT" AND cancer - 根因:语法定义
not_expr → NOT not_expr | primary,但实现直接跳到_parse_primary(result, negated=True),丢失递归 - 修复:改为递归调用
_parse_not_expr并 toggleis_not
F4: SearchView Custom Range 不发送日期
- 文件:
SearchView.vueL296-309 - 问题:选择"自定义年份范围"→ 搜索,请求体不含任何日期参数,返回全库结果
- 根因:
datePreset === 'custom'分支未处理。仅!datePreset.value和datePreset.value !== 'custom'两个条件都失败 - 修复:增加
datePreset === 'custom'分支发送year_from/year_to
F5: HomeView.restoreFromUrl 恢复不全
- 文件:
HomeView.vueL348-364 - 问题:URL 含
?q=cancer&retracted=only时,retracted 参数丢失 - 根因:只恢复了
tag/date_from/date_to/q,缺失sort/field/retracted/negative_result - 修复:补全 4 个缺失参数的读取
F6+F7: 死代码清理 — precision_mode + is_oa
- 文件:
AdvancedSearchPanel.vue/types/index.ts - 问题:UI 中 precision_mode 切换按钮存在但无任何效果,混淆用户
- 修复:全链路移除 precision_mode 控件和类型字段
F8: _expand_mesh_tag_ids N+1 查询 → 批量
- 文件:
search_engine.pyL1119-1143 - 问题:N 个 MeSH 词 → 2N 次
db.execute+ 2 次树查询 = 8 轮数据库往返 - 根因:
for m in mesh_names:循环内每次执行 2 次独立查询 - 修复:OR 合并所有词的 entry_terms 和 name_en ILIKE 到 2 次批量查询
第三轮:第三轮审计修复
提交:a37cc50
日期:2026-07-27
数量:14 项(P0×5, P1×4, P3×5)
触发:6 Agent 并行深度代码审计
背景
第三轮审计聚焦于隐藏较深的逻辑缺陷:PubMed 子集/状态/UID 字段的网关检查遗漏、字段到领域的错误映射、括号组的单 NOT 边缘情况、前端日期精度丢失等。
P0 — 搜索结果错误(5 项)
P0-1: sb/stat/uid/dep 门控遗漏
- 文件:
search_engine.py两个has_pubmed_terms检查点(L162-179, L189-205) - 问题:
medline[SB]等解析后产出了pp.sb_terms,但has_pubmed_terms未检查它,导致走纯文本路径,[SB]条件被丢弃 - 根因:
has_pubmed_termsgate 随字段新增未同步更新 - 修复:在条件判断中添加
pp.sb_terms,pp.stat_terms,pp.uid_terms,pp.dep_from
P0-2: [SB] 映射到错误领域
- 文件:
search_engine.pyL830-839 - 问题:
medline[SB]被映射到nlm_subsets(期刊级),但 PubMed 的medline[SB]是指记录级别citation_status=medline - 根因:所有
[SB]值笼统走nlm_subsets overlap路径 - 修复:
MEDLINE→citation_status == "medline"PUBMED→ no-op(所有记录都是 PubMed)- 单字母代码(AIM/S/D 等)→
nlm_subsets overlap(期刊级) - 其他文本 →
citation_status == val.lower()
P0-3: 括号组单 NOT 词丢失否定
- 文件:
search_engine.pyL882 - 问题:
NOT (cancer)— 组内只有 1 个词,len(group_conds) > 1条件失败,NOT 被丢失 - 根因:全 NOT 组的包裹检查是
> 1(针对多词 NOT 组优化),但 1 个词的组也被 NOT 包裹才正确 - 修复:
> 1→>= 1
P0-4: HomeView watch 丢弃参数
- 文件:
HomeView.vue - 问题:
sort,field,retracted,negative_result在watch(searchKey)的 URL 同步中被丢弃 - 修复:把这些参数加入
searchKeycomputed 依赖和 URL 替换逻辑
P0-5: 日期精度丢失
- 文件:
SearchView.vue - 问题:URL 中的
date_from=2025-03-15恢复后变成2025-03-14或丢失 - 根因:使用
date_from/date_toref 存储(受 DatePicker 组件双向绑定影响),而非原始 URL 值 - 修复:增加
urlDateFrom/urlDateToref 直接存储原始日期字符串
P1 — 功能缺失(4 项)
P1-1: [ALL] 未注册
- 文件:
pubmed_query_parser.py - 问题:
[ALL]tag 未在_FIELD_TAG_MAP和_ALL_FIELD_TAGS中注册,导致被is_pubmed_syntax()识别但 tokeniser 不识别 →UNKNOWN_FIELD→ 静默降级 - 修复:在
_FIELD_TAG_MAP添加"ALL": "all",在_ALL_FIELD_TAGS添加"ALL"
P1-2: 独立日期字段降级
- 文件:
pubmed_query_parser.py_dispatch_term - 问题:
2024-01-01[DP]作为日期范围使用时正确,但作为独立字段值时(term.field 非 None 且非范围语法)被降级到plain_terms - 根因:
_dispatch_term缺少term.field为日期字段名称时的独立处理分支 - 修复:
_dispatch_term增加 DP/EDAT/CRDT/MHDA/LR/DCOM/DEP 裸字段处理→设置from=to=日期
P1-3: 浮点日期范围不交换
- 文件:
pubmed_query_parser.py_parse_range - 问题:
2026:2024[DP]只对纯 digit 做了交换,2024-12-01:2024-01-01[DP]这种完整日期不交换 - 修复:elif 增加非 digit ISO 字符串比较 + 交换
P1-5: MeSH entry_terms 大小写不敏感
- 文件:
scripts/import_mesh_full.py - 问题:Entry terms 导入时未统一 lowercase,
@>匹配区分大小写,导致cancer无法匹配Cancer - 修复:
.lower()统一存储
P3 — 健壮性(5 项)
| # | 修复项 | 文件 | 问题 | 修复 |
|---|---|---|---|---|
| P3-2 | MeSH 展开无保护 | search_engine.py |
_expand_mesh_tag_ids 和 expand_atm 的 DB 查询未包裹异常 |
try/except 包裹 DB 查询块 |
| P3-4 | 参数无验证 | features.py |
sort/field/boolean 参数接受任意值 |
field_validator |
| P3-5 | GET 搜索无限制 | literature.py |
超长查询可耗尽资源 | 100 词上限 |
| P3-6 | 中文正则不一致 | query_expansion.py |
中文检测正则与 search_engine 不一致 | [一-鿿㐀-䶿豈-] 同步 |
| P1-8 | page_size 未恢复 | SearchView.vue |
URL 翻页参数丢失 | restoreFromQuery + syncSearchToUrl |
第四轮:字段补全与语义优化
提交:807972d
日期:2026-07-27
数量:11 项
触发:系统跟踪遗留限制的逐个解决
背景
前三轮修复后仍有多项已知限制:8 个 PubMed 字段标签未注册、[OT] 语义过宽(映射到 all)、精确短语用 ILIKE 而非 GIN 索引。第四轮系统性地清理了这些残留项。
字段注册(8 项)
| # | 字段标签 | 映射目标 | 说明 |
|---|---|---|---|
| P4-1 | [Title/Abstract] |
all |
PubMed 长标签,等同 [TIAB] |
| P4-2 | [OAB] |
all |
Other Abstract |
| P4-3 | [WORD] |
all |
Word in text |
| P4-4 | [FI] |
GR 同路径 |
Funder Identifier,搜索 grant_id |
| P4-5 | [SO]/[PL] |
journal |
Source / Place of Publication(近似映射) |
| P4-6 | [GEN] |
gene_symbols JSONB |
基因符号精确搜索(数据覆盖率依赖实际导入) |
| P4-7 | [PMC] |
pmc_id 列 |
PMCID 精确匹配(数据覆盖率依赖实际导入) |
涉及修改:
_ALL_FIELD_TAGSset:新增 8 个标签名_FIELD_TAG_MAP:注册映射关系_SPECIAL_FIELDS:加OT、GEN、PMCParsedPubmedQuery:加ot_terms/gene_terms/pmc_termslist_dispatch_term:加 3 个 elif 分支search_engine.py:两个has_pubmed_termsgate 加新字段
语义修复(3 项)
P4-8: [OT] → keywords JSONB(不再映射到 all)
- 问题:
[OT]映射到 "all" 意味着在标题/摘要/PMID/DOI 全字段搜索,语义过宽。PubMed 的[OT]只搜索 Other Keywords(keywordsJSONB 列) - 修复:
_dispatch_term将OT路由到ot_terms(而非_FIELD_TAG_MAP→all→tiab_terms)_pubmed_conditions增加ot_terms处理块:GlobalLiterature.keywords.cast(JSONB).contains([t.text])_single_term_condition增加 OT 分支
P4-10: phraseto_tsquery 精确短语
- 问题:精确短语
"immune checkpoint"用 ILIKE%immune checkpoint%实现,无法利用 GIN 索引,全表扫描 - 修复:
_field_condition的"all"字段精确短语路径从 ILIKE 改为search_tsv @@ phraseto_tsquery('english', term) - 限制:通配符
*时仍需 ILIKE(tsvector 不支持截词)
引擎改进(1 项)
P4-9: [PMC] 搜索 SQL
_pubmed_conditions增加第 9 块:pmc_id == term.text精确匹配_single_term_condition增加 PMC 分支
第五轮:第 5 轮全面审计修复
提交:275a9f6
日期:2026-07-27
数量:4 项
触发:5 Agent 并行深度审计 + 第 2 轮规划核对
背景
第 4 轮后启动第 5 轮全面审计,5 个 agent 并行扫描 parser、engine、前端、文档。首先与第 2 轮审计计划(F1-F8)逐项核对,确认以下 8 项已在之前轮次完成:
| 计划 ID | 项目 | 完成轮次 | 状态 |
|---|---|---|---|
| F1 | [MH:noexp] 顶层支持 |
第 3/4 轮 | 已修复 |
| F2 | 组内 NOT De Morgan 律 | 第 2 轮 | 已验证正确 |
| F3 | _parse_not_expr NOT NOT 递归 |
第 4 轮 | 已修复 |
| F4 | SearchView Custom Range 日期 | 第 3 轮 | 已工作正常 |
| F5 | HomeView restoreFromUrl | 第 3 轮 | 已工作正常 |
| F6 | precision_mode 死代码 | 此前轮次 | 已移除 |
| F7 | is_oa 死字段 | 此前轮次 | 已注释 unused |
| F8 | _expand_mesh_tag_ids N+1 |
第 3/4 轮 | 已批量优化 |
实际在第 5 轮修复的 4 项均为 parser 边缘案例:
P5-1: 尾部 NOT 导致 IndexError 降级
- 文件:
pubmed_query_parser.py_parse_not_exprL498 - 问题:
cancer NOT— 解析器_is_primary_start包含NOT,隐式 AND 逻辑尝试将 NOT 作为新词开始,但 NOT 之后无 token →peek()越界抛IndexError→ 整个查询降级为纯文本拆分,NOT作为字面搜索词(影响极小但产生异常) - 根因:
_parse_not_expr不检查是否已到 EOF - 修复:消费 NOT token 后立即检查
peek().type == TokenType.EOF,直接返回[]静默忽略
P5-2: 单数字日期格式不被识别
- 文件:
pubmed_query_parser.pyparse_pubmed_queryL679 - 问题:
2024-1-1[DP]— tokeniser 的 DATE 模式只匹配\d{4}-\d{2}-\d{2},单数字月/日被解析为WORD('2024-1-1')→_dispatch_term设置date_from='2024-1-1'→date.fromisoformat()抛ValueError→ 日期条件被静默丢弃 - 根因:tokeniser 前缺少单数字日期归一化
- 修复:在
parse_pubmed_query的 NFKC 归一化后增加YYYY-M-D → YYYY-MM-DD正则替换
P5-3: is_pubmed_syntax 不处理全角字符
- 文件:
pubmed_query_parser.pyis_pubmed_syntaxL652 - 问题:全角括号
[TI]不被\[...\]识别 → 检出失败 → 走纯文本路径(解析器内parse_pubmed_query做 NFKC 但已不会进入) - 根因:
is_pubmed_syntax未做 NFKC 归一化、与parse_pubmed_query行为不一致 - 修复:函数开头增加
query = unicodedata.normalize('NFKC', query)
P5-4: extract_pubmed_query_for_prisma 不能处理 [Title/Article]
- 文件:
pubmed_query_parser.pyextract_pubmed_query_for_prismaL706 - 问题:归一化 regex
\[([\w:]+)\]不含/,[Title/Article]不被匹配、保持原文 - 根因:regex 字符类不含
/ - 修复:
[\w:]→[\w/:]
P5-5: Tokeniser 缺口字符被静默丢弃(BUG-7)
- 文件:
pubmed_query_parser.pytokenise()L150 - 问题:
finditer只输出匹配到的片段,$、@等匹配不到的字符无声丢失 - 根因:
_TOKEN_PATTERNS未覆盖所有可能字符,且无 fallback - 修复:在
tokenise()中检测相邻 match 间的 gap,将非空 gap 作为 WORD 加入 token 流
第六轮:第 6 轮全面审计修复(12 项)
日期:2026-07-27 数量:12 项(6 项已在前轮中应用 + 6 项新增) 触发:4 Agent 并行审计(Parser/Engine + API/Validation + Frontend + Integration)
P6-1: Title/Abstract 字段标签大小写不匹配
- 文件:
pubmed_query_parser.py_FIELD_TAG_MAPL54 - 问题:
_FIELD_TAG_MAP只有"Title/Abstract"键,但解析器.upper()产生"TITLE/ABSTRACT",导致查表失败,该字段标签退化到plain_terms(走 "all" 路径,结果正确但掩盖了 bug) - 根因:初始化
FieldTagMapping时只写了原始大小写 - 修复:增加
"TITLE/ABSTRACT"大写键值对映射到 "all"
P6-2: 末尾 OR 产生空 Term(BUG-2)
- 文件:
pubmed_query_parser.py_parse_or_expr()L466 - 问题:
cancer OR末尾操作符导致解析器尝试读取空 token,生成Term(text=""),引起plainto_tsquery("english", "")报错 - 根因:OR 后无表达式时,解析器仍尝试调用
_parse_and_expr,最终生成空 term - 修复:在
_parse_or_expr中,advance 后检查 EOF 并 break
P6-3: PubMed 降级路径 field 标签 regex 未覆盖 /(BUG-11)
- 文件:
search_engine.pyL222 - 问题:
re.sub(r'\[[\w-]+\]', '', query)——[\w-]不含/,[Title/Abstract]不会被擦除,留在降级查询中作为普通文本 - 根因:字符类缺
/ - 修复:
[\w-]→[\w/-]
P6-4: ATM 展开未剥离括号(Flat Text BUG 5)
- 文件:
search_engine.pyL284 - 问题:
_atm_query仅执行replace('"', '').replace("'", ''),未去除(和)。(lung cancer)作为 ATM 查询导致expand_atm搜索到(lung cancer)而非lung cancer,可能零匹配 - 修复:增加
replace('(', '').replace(')', '')
P6-5: 中文 MeSH name_zh 查询无 LIMIT(Flat Text BUG 4)
- 文件:
search_engine.pyL229-235 - 问题:
GlobalTag.name_zh.ilike(...)可能返回大量匹配(如 "癌"),导致 subquery 膨胀 - 修复:追加
.limit(100)限制
P6-6: year_from / year_to 使用 falsy 检测(BUG-15)
- 文件:
search_engine.pyL312-315 - 问题:
if year_from:使year_from=0被当作假值跳过(0 不是有效年份,但语义上 "0" 应被忽略;改为is not None更安全) - 根因:Python falsy 检测对于 int 含 0
- 修复:
if year_from:→if year_from is not None:
P6-7: _parse_range 混合类型日期范围无反向交换(BUG-8)
- 文件:
pubmed_query_parser.py_parse_range L601-606 - 问题:
2026:2024-01-01[DP]不触发任何 swap(一个纯数字一个不是),导致year_from=2026,date_to=2024-01-01(空范围) - 根因:反向 swap 条件只处理两端同类型
- 修复:增加第三条件
_start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4])
P6-8: Tokeniser 内部 gap 字符恢复(BUG-7 补充)
- 文件:
pubmed_query_parser.pytokenise L150 - 问题:无 gap 处理时部分特殊字符丢失
- 修复:记录
last_end,gap 中的非空白字符作为 WORD 输出
P6-9: [MH:noexp] 顶层支持(F1)
- 文件:
search_engine.py_pubmed_conditions L660-674 - 状态:已在第一阶段实现,审计确认正确(按
_noexp标志分组处理)
P6-10: 组内 NOT 语义 De Morgan(F2)
- 文件:
search_engine.py_pubmed_conditions L922-941 - 状态:已在第二阶段实现,审计确认正确(
all_not标志 →not_(combined)包裹)
P6-11: _parse_not_expr 递归支持(F3)
- 文件:
pubmed_query_parser.pyL498-509 - 状态:已在第五阶段实现,审计确认正确(递归调用
_parse_not_expr)
P6-12: 前端日期发送 + restoreFromUrl(F4+F5)
- 文件:
SearchView.vueL300-302、HomeView.vueL364-367 - 状态:已在第五阶段实现,审计确认正确(custom range 已发送年/月参数、restoreFromUrl 已覆盖 sort/field/retracted/negative)
各轮变更摘要
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 | 第六轮 |
|---|---|---|---|---|---|---|
| 修复数 | 34 | 8 | 14 | 11 | 4 | 12 |
| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser | engine + parser |
| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 | 0 |
| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 | 0 |
| 新功能 | [MH]/[EDAT]/[AD]/[LA] 等 |
— | — | [GEN]/[PMC]/[Title/Abstract] |
— | — |
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 | 审计修复 | 深度审计修复 |
第七轮:第 7 轮深度审计修复(20 项)
日期:2026-07-27 数量:20 项(4 Agent 第 2 轮并行审计) 触发:用户"再次全面、深入地检查、分析,消除漏洞" 测试:276 通过(新增 ~28 项),13 项预存失败
P7-1: isdecimal() 非 ASCII 数字崩溃 PMID 检测(HIGH)
- 文件:
search_engine.pysearch()L245-246 - 问题:
str.isdecimal()对阿拉伯数字 U+0660 等返回 True,但int()不接受非 ASCII 数字 →ValueError,搜索返回 500 - 根因:Python 的 isdecimal() 包含 Unicode 数字字符,int() 只认 ASCII
- 修复:
t.isdecimal()→re.match(r'^\d{1,15}$', t)
P7-2: 降级 regex [\w/: -] 兼容冒号
- 文件:
search_engine.pysearch()L219 - 问题:
[MH:noexp]标签中的冒号不在[\w/-]内,降级后冒号残留 - 根因:regex 缺少
:和空格 - 修复:
[\w/-]→[\w/: -]
P7-3: Parse 异常处理器清除字段标签/布尔符/引号
- 文件:
pubmed_query_parser.pyparse_pubmed_query()L719-726 - 问题:降级时
query.strip().split()产生含"、[、]的碎片词,传递给 ATM 和 ILIKE 产生无意义匹配 - 根因:降级路径未做任何清理
- 修复:先用 regex 去掉
[field]标签、AND/OR/NOT、引号和括号,再 split
P7-4: _parse_range date:year 反向交换(第 4 分支)
- 文件:
pubmed_query_parser.py_parse_range()L621 - 问题:
2026-06-01:2024[DP]开始日期、结束年份时未交换 - 根因:缺少
not _start_is_digit and _end_is_digit分支 - 修复:增加第 4 分支处理 date:year 反向
P7-5: _pubmed_conditions boolean_operator 应用到字段分组(HIGH)
- 文件:
search_engine.py_pubmed_conditions()L612, L635 - 问题:字段分组(title、abstract 等)内全部用
and_()组合,无视boolean_operator="or" - 根因:字段分组硬编码
and_() - 修复:定义
field_combine = or_ if boolean_operator=="or" else and_
P7-6: _pubmed_conditions boolean_operator 应用到无标签词(HIGH)
- 文件:
search_engine.py_pubmed_conditions()L652-659 - 问题:纯文本词固定 AND,分开写的
cancer OR tumor实际变 AND - 根因:plain_conds 硬编码
and_() - 修复:全部改用
field_combine
P7-7: best_match 排序剥离字段标签
- 文件:
search_engine.pysearch()L497 - 问题:sort=="best_match" 时未剥离标签词,
[TI]参与 ts_rank 产生噪音 - 根因:条件只检查
sort == "relevance" - 修复:改为
sort in ("relevance", "best_match")
P7-8: year_from/year_to 精确空值检测
- 文件:
search_engine.py_pubmed_conditions()L969-972 - 问题:
if pp.year_from:当 year_from=0 时 falsy → 条件跳过 - 根因:falsy 检测不适用于年份 0
- 修复:
if pp.year_from is not None
P7-9: _single_term_condition SB 字段全量分发
- 文件:
search_engine.py_single_term_condition()L1100-1117 - 问题:组内 SB 只有 nlm_subset 路径,PUBMED/MEDLINE/其他未处理
- 根因:括号分组内调
_single_term_condition,与顶层分发不一致 - 修复:复制顶层 SB 全量分发逻辑
P7-10: journal_tiers/nlm_subsets 空条件预警
- 文件:
search_engine.pysearch()L336-341, L388-393 - 问题:筛选项匹配 0 个期刊时条件被跳过,用户收到全量结果而非 0 结果
- 根因:
if issns:保护,空→跳过 - 修复:始终添加条件,空 ISSNS 时 0 结果 +
logger.warning
P7-11: ATM 展开异常日志化
- 文件:
search_engine.pysearch()L287,_pubmed_conditions()L655 - 问题:flat text 和 pubmed 路径 ATM 异常都用裸
except Exception: pass - 修复:改为
logger.exception()
P7-12: _expand_mesh_tag_ids 异常日志化
- 文件:
search_engine.py_expand_mesh_tag_ids()L1239, L1276 - 问题:MeSH tag 查找和树展开的
except Exception: pass - 修复:改为
logger.exception()
P7-13: 中文 name_zh ILIKE 加 LIMIT 100
- 文件:
query_expansion.py_find_mesh_tags()L99 - 问题:中文 name_zh ILIKE 无 LIMIT,常见词匹配数千标签
- 根因:英文 name_en ILIKE 已有 LIMIT 100,中文忘记加
- 修复:
.limit(100)
P7-14: Cursor 分页使用 pub_date 优先(HIGH)
- 文件:
SearchView.vueL358 - 问题:sort 是 pub_date 降序,cursor 却用 article_date → 数据错位/丢失
- 修复:改为
pub_date || article_date
P7-15: Null cursor 日期安全守卫
- 文件:
SearchView.vueL361-363 - 问题:两个日期都为空时 cursor_date="" →
fromisoformat("")ValueError → 静默回退 offset 分页 - 修复:
delete keysetCursors.value[p+1]当两日期都为空
P7-16: syncSearchToUrl 移到 finally 块
- 文件:
SearchView.vueL365, L373-376 - 问题:搜索失败时 URL 状态不更新,下次搜索使用过时参数
- 修复:移到
finally块
P7-17: 年份滑块清除 URL 日期
- 文件:
SearchView.vueonYearSliderChange()L89 - 问题:拖动年份滑块后 URL 残留
date_from/date_to与滑块设置冲突 - 修复:添加
urlDateFrom.value=''; urlDateTo.value=''
P7-18: resetAllFilters 清除 URL 日期
- 文件:
SearchView.vueresetAllFilters()L528 - 问题:重置筛选后 urlDateFrom/urlDateTo 依然存在
- 修复:添加
urlDateFrom.value=''; urlDateTo.value=''
P7-19: MAX_TERMS 保护
- 文件:
pubmed_query_parser.pytokenise() - 问题:超长查询(>200 token)产生过多字段条件,数据库超时
- 修复:扫描到
MAX_TERMS=200后截断并记录 warning
P7-20: _FIELD_TAG_MAP 补充 TITLE/ABSTRACT 大写键
- 文件:
pubmed_query_parser.py_FIELD_TAG_MAP - 问题:解析器
.upper()产生"TITLE/ABSTRACT"但 map 只有"Title/Abstract" - 修复:增加大写键
P7-21: PubMed 路径中文 tsquery 降级(D2)
- 文件:
search_engine.py_field_condition("all")L1198 - 问题:PubMed 路径对无标签中文词(如
肺癌在lung cancer OR 肺癌中)使用plainto_tsquery("english", 肺癌)→ tsvector 是英语配置 → 返回空 → 零结果。仅当[TI]/[AB]加字段标签或有通配符时才走 ILIKE - 根因:tsquery("english") 不索引中文字符
- 修复:
_field_condition("all")默认路径新增中文检测 → ILIKE title/abstract 回退
P7-22: 裸混合布尔 A OR B AND C 保留 AND 分组(D3)
- 文件:
pubmed_query_parser.py_parse_or_expr()L475 - 问题:
A OR B AND C被拉平为 3 个 term OR:A OR B OR C。PubMed 语义应是A OR (B AND C) - 根因:
_parse_or_expr对left/right做extend,AND cluster 全部拉平 - 修复:收集各
_parse_and_expr结果为独立 cluster,OR 存在时将 >1 term 的 cluster 包装为 group +group_operators="and"。引擎 OR/mixed 模式将所有正条件 OR 合并,组内 AND
P7-23: 混合布尔引擎分支 — boolean_operator="mixed" 丢失 OR
- 文件:
search_engine.py_pubmed_conditions()L616, L952 - 问题:
A OR B AND C→ parser 产boolean_operator="mixed"。引擎只处理了=="or","mixed"落到 else(AND),组间 OR 完全丢失 - 根因:
boolean_operator有三种值(and/or/mixed),引擎只有二分支 - 修复:
field_combine和term_conditions合并都改为in ("or", "mixed")
第八轮:第 8 轮深度审计修复(12 项)
日期:2026-07-28 数量:12 项(3 Agent 最新深度审计) 触发:用户第 4/5 次要求全面检查 测试:1007 全部通过,前端构建成功
P8-1: ATM 缓存未失效(CRITICAL)
- 文件:
cache.py:161-168 - 修复:
invalidate_search_cache()新增await self.delete_pattern("atm:*")清理 MeSH 自动词表映射缓存 - 根因:管道运行后
atm:*缓存保留,新 MeSH 标签在一小时内不被发现
P8-2: Pro 方案配额低于 Free(CRITICAL)
- 文件:
plans.py:37 - 修复:
api_quota_per_day: 1_000→10_000 - 影响:Pro 用户不再比 Free 用户更受限
P8-3: Redis 连接失败永不重试(CRITICAL)
- 文件:
cache.py:20-36、rate_limiter.py:37-49 - 修复:
redis_failed标记 60 秒后自动复位,两处统一添加_redis_retry_at+ 60s 退避
P8-4: 普通搜索中文标签匹配缺失(CRITICAL)
- 文件:
literature.py:276-290 - 修复:检测中文输入后查询
GlobalTag.name_zh,匹配时注入GlobalLiterature.id IN (子查询)标签条件 - 根因:普通搜索只做 tsvector + title/abstract ILIKE,完全绕过 MeSH 标签。中文"肺癌"在英文 tsvector 中命中率极低
P8-5: 限速器突发窗口内存泄漏(HIGH)
- 文件:
rate_limiter.py - 修复:添加
_cleanup_stale_burst_windows()每 500 次请求清理过期 key,添加_burst_cleanup_counter - 影响:每唯一 IP 在
_burst_windows留下条目,生产环境数千 IP 可能累积
P8-6: ASC 排序缺 id tiebreaker(MEDIUM)
- 文件:
search_engine.py:1568-1574 - 修复:title/journal/first_author 排序追加
GlobalLiterature.id.asc()作为次级排序列 - 根因:
_keyset_condition假设 id 是 tiebreaker(AND id > uid),但_apply_order_by未在 ORDER BY 中包含 id → 值相同的行在翻页时非确定排序
P8-7: keyset NULL 值缺 id tiebreaker(MEDIUM)
- 文件:
search_engine.py:1595-1631 - 修复:所有
is_(None)子句添加and_(col.is_(None), GlobalLiterature.id < uid / > uid) - 修复 2:
_cursor_from_item对 NULL 列返回"__NULL__"哨兵值 →_keyset_condition新增__NULL__精准处理分支 - 根因:当游标落在 NULL 行后,
is_(None)无条件返回所有 NULL 行→重复
P8-8: _field_condition("all") 缺 journal/affiliation 兜底(MEDIUM)
- 文件:
search_engine.py:1381-1415 - 修复:
- tsvector 默认路径追加
or_(journal ILIKE, journal_iso ILIKE) "/"路径追加 abstract、author_names_text、journal- 中文 ILIKE 路径追加 author_names_text、journal
- tsvector 默认路径追加
- 根因:tsvector 不含 journal/journal_iso/affiliation,纯文本搜索可能漏 journal 匹配
P8-9: Cron 任务缺搜索缓存失效(HIGH)
- 文件:
worker.py:25-28 - 修复:
daily_ftp_update()末尾调用cache.invalidate_search_cache() - 根因:
POST /admin/pipeline/run做了缓存失效,但 ARQ 定时任务daily_ftp_update(03:07 UTC)没做→自动 pipeline 后缓存 5-30 分钟过期
P8-10: AdvancedPubSearchView 交替 #N 循环引用(CRITICAL)
- 文件:
frontend/.../AdvancedPubSearchView.vue:221-239 - 修复:
resolveQuery()添加seen Set<string>检测交替循环(#1→#2→#1) - 根因:原循环检测只检查
current === prev,对交替引用无效→10 轮迭代产生嵌套垃圾
P8-11: URL date_preset/year_from/year_to 冲突(MEDIUM)
- 文件:
frontend/.../SearchView.vue:380-396 - 修复:
restoreFromQuery中date_preset优先;date_preset 存在时清空 year_from/year_to;无 date_preset 且无 date_from/date_to 时再读 year_from/year_to
P8-12: 增加中文搜索路径(enhancement)
- 文件:
search_engine.py:1397-1414 - 修复:
_field_condition("all")的/路径和中文路径补充 author_names_text、journal ILIKE 覆盖
第九轮:第 9 轮深度审计修复(5 项)
日期:2026-07-28 数量:5 项(3 Agent 第 5 次深度审计) 触发:用户第 5 次要求全面检查 测试:1007 全部通过
P9-1: MeSH headings 完全从 search_tsv 缺失(CRITICAL)
- 文件:
alembic/versions/g0h1i2j3k4l5_*.py:37-40,83-86 - 根因:
g0h1i2j3k4l5迁移在 trigger 公式中用value->>'name'提取 mesh_headings,但所有数据源(PubMed XML 解析、E-utilities、FTP baseline)均使用{'descriptor': desc, 'ui': ui, 'major': major}结构,descriptor 存在'descriptor'键而非'name' - 影响:MeSH 术语对
search_tsv的贡献完全丢失。纯文本搜索"neoplasms"不会通过 tsvector 命中 MeSH 标签。全文搜索回归和ts_rank排序也受影响。结构化的[MH]字段搜索不受影响(直接查 JSONB) - 修复:
af4a8b2ec873迁移将->>'name'修正为->>'descriptor',并回填全库数据
P9-2: Affiliation 被从 search_tsv 中剥离(HIGH)
- 文件:
alembic/versions/f1a2b3c4d5e6_*.py:54-56 - 根因:
f1a2b3c4d5e6迁移引入author_names_text列(仅含 family),替换了原来在 trigger 中直接value->>'family' || ' ' || COALESCE(value->>'affiliation', '')的方式。affiliation 从此从 tsvector 中消失 - 影响:搜索机构名(如 "Mayo Clinic"、"MD Anderson")无法通过 tsvector 匹配。ILIKE 兜底也未覆盖 affiliation(只在
_field_condition("affiliation")专用路径中有 JSONB 子查询) - 修复:已在第 8 轮
_field_condition("all")tsvector 路径中补充 journal/journal_iso ILIKE。affiliation 完全恢复需未来迁移将机构名加入author_names_text或单独加入 tsvector
P9-3: 搜索测试套件几乎无断言价值(HIGH)
- 文件:
tests/test_service_search_engine.py - 根因:
- 模块级
_cache_patch杀死所有缓存路径测试(_cache.get恒为 None) - 所有
search()调用只断言result["total"] == 0——13 个测试全是"不崩溃"烟雾测试 _field_condition测试只检查is not None,不验证生成的 SQL 条件是否正确db.execute.side_effect使用[_smart_mock() for _ in range(N)],侧效应列表顺序不验证
- 模块级
- 风险:搜索引擎 SQL 生成的任何 bug 都不会被这些测试捕获。缓存反序列化、SQL 条件正确性、过滤器逻辑均无断言
P9-4: backfill_search_tsv.py 严重过期(MEDIUM)
- 文件:
scripts/backfill_search_tsv.py:17-31 - 根因:该脚本的 tsvector 公式停留在
e341edea85e2迁移时代,缺少chemical_list、gene_symbols、mesh_headings、keywords - 风险:任何人运行此脚本会回退全库 search_tsv,丢失所有 C-weight 组分
- 修复:已重写为包含完整七组分公式并与当前 trigger 一致
P9-5: 无 query 字符长度限制(LOW)
- 文件:
features.py:52,93-99 - 根因:Pydantic
query: str = ""无max_length。只有词数限制(100 词),单个 10K 字符的词可通过验证 - 风险:
ILIKE '%10K_char_word%'是大表全扫描,可被用于资源耗尽
第十轮:第 10 轮深度审计修复(6 项)
日期:2026-07-28
提交:a985d07
数量:6 项
测试:1007 全部通过 + 前端 build 通过
P10-1: 缓存键 PubMed/纯文本路径碰撞(MEDIUM)
- 文件:
search_engine.py:33-86,88-144 - 根因:
_search_cache_key和_facet_cache_key只对 query 做strip().lower()归一化,不区分 PubMed 路径和纯文本路径。相同文本先走 PubMed 路径被缓存,后续纯文本路径命中同样的 key 返回不匹配的结果 - 修复:在 norm dict 中加入
"pm": _is_pm(query)标志,两路径缓存键自动分离
P10-2: OR 模式 NOT 语义错误(HIGH)
- 文件:
search_engine.py:1151-1153 - 根因:
boolean_operator == "or"路径中,代码提取neg_conds然后and_(pos_conds + neg_conds)。正确语义应为A OR NOT B等价于A OR (NOT B),而非(A) AND (NOT B) - 修复:OR 模式直接
or_(*term_conditions),不做 NOT 分离 - 验证:原有
test_or_mode_mixed_not等测试正确通过
P10-3: Affiliation 始终不在全字段搜索中(MEDIUM)
- 文件:
search_engine.py:1388-1433 - 根因:
_field_condition("all")的 5 条分支(exact/phraseto_tsquery、wildcard ILIKE、"/" 路径、中文路径、tsvector 默认)均未包含 affiliation ILIKE 兜底。虽然 tsvector 默认路径注释写"affiliation 不在 tsvector 中,以 ILIKE 兜底",但代码中并未实现 - 修复:所有 5 条分支均添加
jsonb_array_elements(authors)的->>'affiliation' ILIKE子查询 - 影响:搜索"mayo clinic"或"MD Anderson"等机构名在全字段搜索中生效
P10-4: 高级搜索无 query max_length(LOW)
- 文件:
features.py:52 - 修复:
query: str = Field("", max_length=2000) - 注意:延续 P9-5 的修复,Pydantic 层面增加长度限制
P10-5: 前端缺少 OR 模式切换(MEDIUM)
- 文件:
SearchView.vue:45-46,278-284,547-550 - 根因:
boolean: 'and'硬编码,前端无法发起 OR 搜索 - 修复:
- 搜索栏添加 AND/OR 选择器(NSelect)
booleanOpref 驱动body.boolean- URL 同步:
route.query.boolean === 'or'恢复 resetAllFilters重置
P10-6: 前端缺少精确短语模式(LOW)
- 文件:
SearchView.vue:45-46,284,548-550 - 根因:
exact_phrase在 TypeScript 接口中存在但从未从前端发送 - 修复:
- 搜索栏添加"精确短语"复选框
body.exact_phrase条件发送- URL 同步/恢复
遗留限制
截至 2026-07-28,剩余 7 项已知限制:
| ID | 问题 | 原因 | 影响 |
|---|---|---|---|
| L2 | Affiliation JSONB cast 假阳性 | 需独立 affiliation 列 + Alembic 迁移 + 重新填充 | 低 |
| L3 | retracted "yes"="only" | 命名语义,SQL 条件相同 | 无影响 |
| L4 | OR-mode NOT 检测不可靠 | UnaryExpression + _sa_ops.inv 不可靠用于复合 NOT |
低 |
| L5 | 历史引用 #N 仅前端支持 |
#N 是 localStorage UX 功能,仅在高级搜索前端内联展开 resolveQuery() 后发送到 API。后端无 # token 类型。API 直传 #1 被当普通文本。属于设计决策,非 bug |
无影响(前端已覆盖所有 user 路径) |
| L6 | [SH]/[MAJR] 依赖 MeSH 抽取质量 |
引擎逻辑正确(mesh_headings JSONB contains qualifiers / global_literature_tag.is_major=True),但结果完整性取决于 pipeline 的 XML 抽取是否漏标。2026-07-27 审计抽检 981 篇:漏标率 0.1%、误标率 0.0%。脚本见 scripts/audit_mesh_major.py |
低(当前数据质量良好) |
| L7 | 字段标签 REF/ISBN 未注册 | 低使用频率或数据缺失 | 低 |
| L8 | GIN 索引缺失(基因/chemicals 等) | 需 DBA 操作,生产数据量大 | 中(大表性能) |
| L9 | _dispatch_term 回归不可见 |
需字段级测试 | 低 |
附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 |
|---|---|---|
test_pubmed_query_parser.py |
~40 | tokeniser、解析器、语法正确性 |
test_pubmed_search_integration.py |
~60 | 字段映射、API 集成、前端格式 |
test_comprehensive_verify.py |
~27 | 字段完整、NOT 语义、括号、日期 |
test_comprehensive_verify.py |
~55 | 第 7 轮新增覆盖(full dispatch、boolean_operator、cursor 等) |
| 全量测试套件 | 1007 | 全部通过(含前 7 轮 276 项搜索专项 + 731 项通用测试) |
预存失败(13 项):9 项
feed_engineStopAsyncIteration(测试数据缺失) + 4 项pubmed_api_tag_articleimport(函数已移入 pipeline)