Bug-R21-1 (CRITICAL): OR模式NOT分离过度 — A OR NOT B语义错误,恢复or_(*conditions) Bug-R21-2 (CRITICAL): 双重嵌套括号sub_group_refs虚条目 — 仅当_ungrouped非空时写入 Bug-R21-3 (HIGH): negated_date_ranges被覆盖 — =改为|= Bug-R21-4 (MEDIUM): 日期/PMID/DOI/PMC条件在OR模式始终AND — 合并为or_(*conditions) Bug-R21-5 (MEDIUM): _parse_atom无条件消费任何词符 — 类型验证守卫 Bug-R21-6 (MEDIUM): 模态框双重goToPage(1) — 按钮只关弹窗,搜索由watcher触发 Bug-R21-7 (MEDIUM): page.value失败后不回退 — 捕获异常恢复前一页 Bug-R21-8 (LOW): 空引号""[TI]产生空Term — text为空时跳过 Bug-R21-9 (LOW): 冗余函数内import re — 使用模块级导入
96 KiB
PubMed 搜索合规修复全记录
本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
累计:21 轮,235 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制 时间跨度:2026-07-24 ~ 2026-07-29 核心文件:
pubmed_query_parser.py(~850 行)→search_engine.py(~1360 行)
目录
- 第一轮:Phase 0-7 基础修复(34 项)
- 第二轮:第二轮审计修复(8 项)
- 第三轮:第三轮审计修复(14 项)
- 第四轮:字段补全与语义优化(11 项)
- 第五轮:第 5 轮全面审计修复(4 项)
- 第六轮:第 6 轮全面审计修复(12 项)
- 第七轮:第 7 轮深度审计修复(20 项)
- 第八轮:第 8 轮深度审计修复(12 项)
- 第九轮:第 9 轮深度审计修复(5 项)
- 第十轮:第 10 轮深度审计修复(6 项)
- 第十一轮:第 11 轮深度审计修复(16 项)
- 第十二轮:第 12 轮深度审计修复(21 项)
- 第十三轮:第 13 轮深度审计修复(21 项)
- 第十四轮:第 14 轮深度审计修复(21 项)
- 遗留限制
第一轮: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 同步/恢复
第十一轮:第 11 轮深度审计修复(16 项)
日期:2026-07-28
提交:090938f
数量:16 项
触发:用户第 6 次要求全面检查
测试:1007 全部通过 + 前端 build 通过
P0-1: Keyset 翻页 title="" or "__NULL__" 导致下一页空(CRITICAL)
- 文件:
search_engine.py:1692-1695 - 根因:
_cursor_from_item中lit.title or "__NULL__"— 当 title 为空字符串""时,Python 的or短路抛出""产生"__NULL__"哨兵值。后续_keyset_condition生成title IS NULL AND id > :uid,由于 title 有NOT NULL约束,此条件永远返回零行 - 修复:NOT NULL 列直接使用
lit.title(无哨兵);journal列(可为 NULL)保留... if ... is not None else "__NULL__"而非or - 同行修复:相同的
lit.journal or "__NULL__"也修复为... if ... is not None else ...,因为""是 journal 的合法值,不应被哨兵化
P1-1: 布尔操作符 boolean_operator 受括号内 AND/OR 污染(HIGH)
- 文件:
pubmed_query_parser.py:312-322 - 根因:
boolean_operator检测对全 token 流扫描 AND/OR,不区分括号内外。(A OR B) AND C中 OR 在括号内,但代码同时看到 OR 和 AND → 判定为"mixed"(抛出错误) - 修复:新增
depth追踪,只在depth=0时统计 AND/OR - 验证:
test_complex_nested、test_a4e_double_paren、test_a4e_double_paren_operators的boolean_operator预期从"mixed"修正为"and"
P1-2: is_pubmed_syntax() 误识别英文单词「and/or/not」(HIGH)
- 文件:
pubmed_query_parser.py:752 - 根因:
re.search使用re.IGNORECASE标志。"diet and exercise in cancer"中的and被识别为 PubMed 布尔符 → 触发 PubMed 路径 → 删除 "and"、"in" 等 stop words → 搜索结果恶化 - 修复:移除
re.IGNORECASE。PubMed 官方仅识别大写AND/OR/NOT为布尔符 - 验证:
test_lowercase_boolean_detected断言从assert is_pubmed_syntax改为assert not is_pubmed_syntax - P20 修订:
is_pubmed_syntax()重新添加re.IGNORECASE(R20 Bug-R20-3)。停用词过滤早已移除,不再有退化风险;统一处理大小写可捕获cancer and therapy等小写 PubMed 查询
P1-3: _relevance_query 对 MeSH-only 查询为空(HIGH)
- 文件:
search_engine.py:605-612 - 根因:
" ".join(plain_parts).strip() or ""—breast[MAJR]这类纯 MeSH 查询的plain_parts为空,_relevance_query返回""→best_match路径不会对 tsvector 排序 → 退化到 date sort - 修复:改为
"...".strip() or query,保留原始查询作为相关性排序回退 - 影响:修复后 MeSH-only 查询的正确相关性排序工作
P1-4: pmid_terms 缺少 int() 异常处理(HIGH)
- 文件:
search_engine.py:1229-1233 - 根因:
pmid_terms处理路径将文本直接int(term.text),非数字 PMID 格式(如 DOI 格式内容)导致ValueError崩溃 - 修复:添加
try/except ValueError+ DOI ILIKE 兜底,匹配_single_term_condition已有的模式 - 验证:
10.1000/xyz[PMID]这类非数字输入不再崩溃
P1-5: 部分日期 YYYY-MM[DP] 展开为单日而非整月(MEDIUM)
- 文件:
pubmed_query_parser.py:408-447 - 根因:
2024-01[DP]被解析器直接当作日期值2024-01-01处理,范围查询2024-01-01:2024-01-01只能命中 1 天而非整月 - 修复:新增
_PARTIAL_DATE_RE和_expand_partial_date()辅助函数,YYYY-MM格式展开为YYYY-MM-01:YYYY-MM-31(31 天) - 影响:修复
DP、EDAT、CRDT三个字段的部分日期展开
P1-6: 前端 #N 引用重复导致循环引用误判(MEDIUM)
- 文件:
AdvancedPubSearchView.vue:resolveQuery()、useSearchHistory.ts:expandQuery() - 根因:历史引用
#N展开时,若同一个N在展开列表中多次出现(如同一条#1在两个位置被引用),refs数组包含重复元素。循环检测逻辑refs.includes(ref)遇到重复#1误判为循环 - 修复:两处都加入
const uniqueRefs = [...new Set(refs)]去重
P2-1: cache invalidate_search_cache 未清理 filter-options
- 文件:
cache.py:173 - 修复:
await self.delete("filter-options")加入失效列表
P2-2: worker 管道异常时缓存未清理
- 文件:
worker.py:28-33,44-50 - 根因:
daily_ftp_update和daily_citation_update的cache.invalidate_search_cache()在正常路径执行,但异常退出时跳过清理 - 修复:
try/finally包裹,保证无论成功还是异常都清理搜索缓存
P2-3: keyset cursor_val 空字符串通过 is None 检查
- 文件:
search_engine.py:1624 - 根因:
cursor_val is None or cursor_id is None— 空字符串""不满足is None,检查通过,后续 SQL 出错后静默回退到 OFFSET - 修复:改为
not cursor_val or cursor_id is None
P2-4: 前端模板条件 sort === 'date' 硬编码
- 文件:
SearchView.vue:908 - 根因:只有
date排序触发 keyset 条件渲染,实际KEYSET_COLUMN_SORTS包含date/cited/title/journal/first_author五种 - 修复:
sort === 'date'→KEYSET_SORTS.has(sort)
P2-5: resetAllFilters 未重置 showCustomYear
- 文件:
SearchView.vue - 修复:重置时补充
showCustomYear.value = false
P2-6: _field_condition("all") 中文路径遗漏 author/journal ILIKE(DOCS ONLY)
- 备注:第 10 轮修复了中文路径加入 author/journal ILIKE,已在文档中补全。代码已正确
第十二轮:第 12 轮深度审计修复(21 项)
日期:2026-07-28
提交:6f861c8
数量:21 项(2 P0 + 8 P1 + 6 P2 + 5 前端/类型)
触发:用户第 7 次要求全面检查
测试:1007 全部通过 + 前端 build 通过
P0-1: _normalize_field_label 函数缺失导致 (a OR b)[TI] 崩溃(CRITICAL)
- 文件:
pubmed_query_parser.py:619 - 根因:
_parse_primary对括号组后带字段标签的语法(a OR b)[TI]调用_normalize_field_label(_raw_field),但此函数从未定义 →NameError。此语法在 PubMed 官方合法,表现为"将整个括号组的搜索结果在 TI 字段中再过滤" - 修复:新增
_normalize_field_label()函数,通过_FIELD_TAG_MAP和_SPECIAL_FIELDS查找标签映射,MH:NOEXP特殊处理返回"MH" - 验证:
(lung OR breast)[TI]不再崩溃
P0-2: 共享列表变异导致 result.groups 被污染(CRITICAL)
- 文件:
pubmed_query_parser.py:577-587 - 根因:
_parse_and_expr中left = self._parse_not_expr(result)返回的是result.groups中同一个 Python list 对象的引用。随后left.extend(right)直接修改了result.groups中存储的列表,导致后续遍历时出现重复/错乱项 - 修复:改为
left = list(self._parse_not_expr(result))— 创建副本后再 extend - 影响:修复
(A OR B) AND C类查询中result.groups被意外修改的 bug
P0-3: POST /search/advanced 缺少用户认证(CRITICAL)
- 文件:
features.py:278-283 - 根因:高级搜索端点只声明了
Depends(get_db),没有Depends(get_current_user)。虽然多租户隔离在get_current_user中设置,AdvancedSearchEngine.search内部不依赖 user 参数,但此端点可被未认证用户调用,且缺少统一的审计入口 - 修复:添加
user: dict = Depends(get_current_user)参数 - 影响:高级搜索端点与普通搜索端点(
literature.py)认证策略一致
P1-1: has_not 忽略括号内 NOT 词(HIGH)
- 文件:
pubmed_query_parser.py:345-347 - 根因:
has_not属性只检查_ungrouped(group_id < 0的顶级词)。NOT (A OR B)时a.is_not=True正确设置,但词属于 group,不在_ungrouped中 →result.has_not = False - 修复:添加
or any(t.is_not for g in result.groups for t in g)检查所有分组内的is_not - 验证:
NOT (cancer OR tumor)[TI]的has_not从 False 修正为 True
P1-2: 紧凑日期 YYYYMMDD 未归一化(HIGH)
- 文件:
pubmed_query_parser.py:736-749 - 根因:
_parse_range中的日期格式处理只支持YYYY-MM-DD和YYYY/MM/DD等含分隔符的格式。PubMed 官方支持 8 位紧凑格式20240115[DP],原代码直接传递给 SQL → 类型不匹配错误 - 修复:新增正则检测
^\d{8}$的紧凑日期值,自动归一化为YYYY-MM-DD - 验证:
20240115[DP]正确解析为2024-01-15
P1-3: 日期字段传入非日期文本导致 SQL 错误(HIGH)
- 文件:
pubmed_query_parser.py:760-775 - 根因:
abc:def[DP]被拆分为abc和def两个 Term,后续直接拼接 SQL 范围查询 →invalid input syntax for type date错误。第三方 API 或其他系统误传非日期内容到日期字段时崩溃 - 修复:新增
_valid_date()函数校验日期合法性(格式 + 月份/日范围);无效值回退为普通文本Term(字段标签变为普通搜索词),不抛出异常 - 验证:
abc:def[DP]不再崩溃,退化到文本搜索
P1-4: Keyset NULLSLAT 过渡导致 ~50% 空值行跳过(HIGH)
- 文件:
search_engine.py:1637-1681 - 根因:每个 sort 分支的第三 ORDER BY 子句
nullslast()对应的_keyset_condition生成and_(col.is_(None), id < cursor_id)。随机 UUID 无排序语义,id < cursor_id条件会过滤掉约 50% 的 NULL 行 - 修复:三级 keyset 条件移除
id约束,仅保留col.is_(None) - 影响:修复后排序列为 NULL 的行不再被随机跳过,翻页结果完整
P1-5: _cursor_from_item first_author 对非 dict JSON 报错(HIGH)
- 文件:
search_engine.py:1703 - 根因:
authors[0].get("family")假设authors[0]是 dict。当authorsJSON 数组包含非 dict 值(如null或字符串)时 →AttributeError: 'NoneType' object has no attribute 'get' - 修复:添加
if authors and isinstance(authors[0], dict):保护,否则返回"__NULL__" - 验证:
authors: [null]或authors: ["Molnar, V"]不再崩溃
P1-6: _expand_mesh_tag_ids 返回 None 时条件静默丢弃(HIGH)
- 文件:
search_engine.py:856-882, 1280-1282 - 根因:
_expand_mesh_tag_ids()未找到匹配的 MeSH 词时返回None。调用方直接将None追加到term_conditions列表 → 等同于忽略此搜索条件。用户搜索一个不存在/未收录的 MeSH 词时无任何反馈,隐式返回全部文献 - 修复:当
cond is None且not is_neg时,追加text("FALSE")(无匹配 = 零结果,正确语义)。NOT 路径下None仍然合法(否定一个不存在的 MeSH = 全部通过) - 验证:
nonexistent_mesh[MeSH]不再返回全部文献,返回零结果
P1-7: _relevance_query 包含否定词(HIGH)
- 文件:
search_engine.py:608-611 - 根因:构建
plain_parts时遍历所有terms未过滤t.is_not。否定词 "NOT X" 中的 X 被纳入相关性排序 tsquery → 相关性分数被不应出现的否定词影响 - 修复:四个
plain_parts.append路径全部添加if not t.is_not过滤
P1-8: 普通搜索 PubMed 语法检测在词数限制之后(HIGH)
- 文件:
literature.py:275-332 - 根因:
len(q.split()) > 100的词数检查在is_pubmed_syntax()解析/清洗之前。PubMed 带字段标签的查询cancer[TI] OR tumor[TI] OR ...虽然语义上只有少数真实词,但split()将每个cancer[TI]算作一词 → 密集字段标签查询被错误拒绝 - 修复:先执行
is_pubmed_syntax()和 field tag 清洗,再检查清洗后的文本长度 - 验证:
cancer[TI] AND (lung[TI] OR breast[TI] OR colon[TI])(清洗后仅 5 词)不再被误阻止
P1-9: 普通搜索缺少错误处理(HIGH)
- 文件:
literature.py:275-332 - 根因:整个 DB 查询块无 try/except。搜索缓存 MISS + DB 故障时返回 500 给用户,前端无降级展示
- 修复:
try/except Exception包裹,返回{"items":[], "total":0, "error":"搜索服务暂不可用"} - 影响:用户可见的"搜索服务暂不可用"提示,而非白页或 500
P1-10: #N 引用解析引号感知不完整(HIGH)
- 文件:
AdvancedPubSearchView.vue:227-235、useSearchHistory.ts:28-35 - 根因:
expandQuery()/resolveQuery()中/#(\d+)/g全局匹配未排除引号内的#N。历史记录中"PD-1 #1 biomarker"的#1被错误展开 - 修复:替换为
"[^"]*"|'[^']*'|#(\d+)正则,先匹配引号内容(直接返回原文),再匹配引号外的#N - 验证:
"mechanism #1" 和 "review #1"中的#1不再被展开
P2-1: _expand_partial_date 月越界(LOW)
- 文件:
pubmed_query_parser.py:700-730 - 根因:
YYYY-13这类非法月份传入_expand_partial_date后直接构建YYYY-13-01→ SQL 日期解析报错 - 修复:添加月份范围检查
1 <= int(month) <= 12;非法月份回退为全年范围YYYY-01-01到YYYY-12-31
P2-2: _single_term_condition MH/MAJR 返回 FALSE 而非 None
- 文件:
search_engine.py:1280-1282 - 修复:
_expand_mesh_tag_ids返回 None 时,MH/MAJR 返回text("FALSE")替代原先的None,保持与 P1-6 一致的语义
P2-3: field 验证器缺少 language/volume/issue/pages/lid
- 文件:
features.py:108-114 - 根因:
@field_validator('field')的白名单只包含all/title/abstract/author/affiliation/journal,实际搜索引擎支持language/volume/issue/pages/lid的全路径搜索 - 修复:添加
'language', 'volume', 'issue', 'pages', 'lid'到允许列表
P2-4: @field_validator 缺失 retracted/negative_result/tag_ids
- 文件:
features.py:116-140 - 根因:高级搜索接口
AdvancedSearchRequest模型未对枚举值retracted(yes/no/only)和negative_result(yes/no/only)做验证;tag_ids未做 UUID 格式验证。异常值直接传入 DB 查询 - 修复:新增三个
@field_validator:check_retracted、check_negative_result、check_tag_ids
P2-5: 高级搜索 resolveQuery 重复调用
- 文件:
AdvancedPubSearchView.vue:307-323 - 根因:
validateQuery内部先调用了一次resolveQuery,外层expanded又调用一次。重复解析消耗性能且可能暴露循环引用漏洞 - 修复:
validateQuery返回解引用后的结果,外层复用
P2-6: SearchRequestBody.page 声明为 required 但运行期删除
- 文件:
types/index.ts:329 - 根因:TypeScript 接口声明
page: number(必填),但features.py的get_search_cache_key在构建缓存键时对page=1调用del norm["page"]。前端类型声明与后端实际行为不一致 - 修复:
page: number→page?: number(可选)
P2-7: restoreFromQuery 未设置 showCustomYear
- 文件:
SearchView.vue - 根因:从 URL query string 恢复
year_from和year_to时重置了筛选面板但忘记设置showCustomYear.value = true,导致年份输入框不可见 - 修复:在
year_from和year_to恢复路径后添加showCustomYear.value = true
第十三轮:第 13 轮深度审计修复(21 项)
日期:2026-07-29
提交:1d34535
数量:21 项(1 P0 + 3 P1 + 2 P2 + 4 MINOR)
触发:用户第 8 次要求全面检查
测试:1007 全部通过 + 前端 build 通过
P0-1: _SPECIAL_FIELDS 为 set 类型,误调用 .get() 导致 AttributeError(CRITICAL)
- 文件:
pubmed_query_parser.py:44-49 - 根因:Round 12 新增的
_normalize_field_label()函数在第 48 行调用_SPECIAL_FIELDS.get(raw)。但_SPECIAL_FIELDS是 Pythonset字面量({...}),没有.get()方法。Python 在求值_FIELD_TAG_MAP.get(raw, _SPECIAL_FIELDS.get(raw))时会先计算第二个参数,无论raw是否在_FIELD_TAG_MAP中都会触发AttributeError。parse_pubmed_query的except只捕获(ParseError, IndexError, ValueError),AttributeError传播到调用方 → 500 错误 - 修复:拆分为三行:
if raw in _FIELD_TAG_MAP: return _FIELD_TAG_MAP[raw]+if raw in _SPECIAL_FIELDS: return raw+return None - 影响:Round 12 引入的回归。
(cancer)[TI]、(a OR b)[DP]等所有带字段标签的括号组全面崩溃。本轮修复后恢复正常 - 验证:
(lung cancer OR breast cancer)[TI]不再崩溃
P1-1: exclude_preprints 丢弃 is_preprint=NULL 记录(HIGH)
- 文件:
search_engine.py:541-542 - 根因:
GlobalLiterature.is_preprint == False生成WHERE is_preprint = false。is_preprint为NULL的旧文献(未解析此字段)被排除。NULL = false在 SQL 三值逻辑中为NULL→ 被 WHERE 过滤 - 修复:改为
GlobalLiterature.is_preprint != True,生成is_preprint IS DISTINCT FROM true(NULL-safe,保留 false 和 NULL 行) - 验证:开启
exclude_preprints筛选后,is_preprint=NULL的记录不再被静默丢弃
P1-2: 普通搜索 boolean="or" 模式下数字词和文本词被 AND 连接(HIGH)
- 文件:
search_engine.py:341-405 - 根因:
boolean="or"时数字词条件(如 PMID 匹配)和文本词条件各自 OR 化后作为独立的元素加入conditions列表。最终and_(*conditions)将两者 AND 连接。例如"12345 cancer"且boolean="or":用户期望PMID=12345 OR 包含cancer,实际执行PMID=12345 AND 包含cancer - 修复:在数字词和文本词处理完成后,如果
boolean == "or",将_term_start之后的所有词条件合并为一个or_(*_term_conds) - 验证:
"30221571 pembrolizumab"且boolean="or",结果应为 PMID 30221571 或包含 pembrolizumab 的文章(OR),而非同时满足
P1-3: Journal 排序 keyset 忽略 journal_iso 排序列(HIGH)
- 文件:
search_engine.py:1618-1621 - 根因:
_apply_order_by("journal")返回[journal ASC, journal_iso ASC, id ASC]。但_keyset_condition只处理journal和id,完全忽略journal_iso。同名期刊不同 ISO 缩写(如Nature/Nature (Lond.)/Nature (London))的文献在 keyset 翻页时被错误跳过 - 修复:从 journal ORDER BY 中移除
journal_iso(keyset 分页不支持多列 tiebreaker,其他所有排序模式均使用单列 + id) - 影响:journal + journal_iso 组合排序在非 keyset 路径(总数据量少时用 OFFSET)也不影响结果正确性,仅影响同行期刊的展示顺序
P2-1: 日期字段 _dispatch_term 未验证非日期文本(MEDIUM)
- 文件:
pubmed_query_parser.py:436-533 - 根因:
cancer[DP]、foo[EDAT]等非日期文字传入日期字段时,_dispatch_term的 else 分支直接赋值result.date_from = term.text。"cancer"作为非法日期值传入 PostgreSQL 查询 →invalid input syntax for type date - 修复:7 个日期字段(DP/EDAT/CRDT/MHDA/LR/DCOM/DEP)的 else 分支加入
_validate_date_str()检查,非法文本路由到plain_terms - 验证:
cancer[DP]不再导致 SQL 错误,退化到文本搜索
P2-2: 日期范围回退保留日期字段标签(MEDIUM)
- 文件:
pubmed_query_parser.py:752-754 - 根因:
lung:cancer[DP]范围中cancer不是合法日期,_parse_range返回Term(txt, field="DP", ...)。_dispatch_term的 DP 分支将"lung:cancer[DP]"作为非法日期值处理 - 修复:回退 Term 的
field设为None(而非保留field),使其路由到plain_terms - 影响:非法日期范围内容降级到纯文本搜索
MINOR-1: 二月 始终被扩展为 29 天,非闰年产生非法日期
- 文件:
pubmed_query_parser.py:31-41 - 根因:
_LAST_DAY[2] = 29对所有年份生效。2023-02[DP]被展开为2023-02-01到2023-02-29,其中2023-02-29是非法日期 - 修复:新增
_is_leap_year()函数;_expand_partial_date()中对month == 2 and last_day == 29且非闰年时置last_day = 28
MINOR-2: _validate_date_str 缺失日历正确性校验
- 文件:
pubmed_query_parser.py:749-751 - 根因:Round 12 的
_valid_datelambda 只校验格式(是否为 YYYY 或 YYYY-MM-DD),不校验月份范围(1-12)和日期范围(1-月末)。2024-13-01、2024-01-32等非法日历日期通过校验 - 修复:新增
_validate_date_str()替代原 lambda,完整校验格式 + 月份范围 + 日期范围 + 闰年 2 月
MINOR-3: 尾部 AND 产生空 Term
- 文件:
pubmed_query_parser.py:595-597 - 根因:
_parse_and_expr消耗 AND token 后未检查 EOF。cancer AND中AND后的_parse_not_expr推进到 EOF 后返回空 Term - 修复:
self.advance()后检查self.peek().type == TokenType.EOF → break
MINOR-4: 精确短语 "all" 搜索缺少 journal ILIKE 回退
- 文件:
search_engine.py:1415-1424 - 根因:
exact=True时_field_condition("all")只搜索 tsvector(phraseto_tsquery)和 affiliation ILIKE。journal/journal_iso 不在 tsvector 中(注释 line 1454 确认),"Nature" 作为精确短语搜索时无法匹配期刊名。非精确路径(line 1456-1462)正确包含 journal ILIKE - 修复:在精确短语路径中加入
GlobalLiterature.journal.ilike(pat)和GlobalLiterature.journal_iso.ilike(pat)
第十四轮:第 14 轮深度审计修复(21 项)
日期:2026-07-29
提交:daf169d
数量:21 项(2 HIGH + 2 MINOR)
触发:用户第 9 次要求全面检查
测试:1007 全部通过 + 前端 build 通过
BUG-1 (HIGH): 非 DP 日期字段单年值未设置专用 *_from/*_to
- 文件:
pubmed_query_parser.py:480-561 - 根因:
2024[EDAT]、2024[CRDT]等非 DP 日期字段的单年值只设置了共享的year_from/year_to,未设置专用的edat_from/edat_to。_pubmed_conditions(search_engine.py:1227-1246)的 section 6b 正确迭代DATE_FIELD_COLS并使用专用属性构建列条件,但解析器从未填充这些属性 → EDAT/CRDT/MHDA/LR/DCOM/DEP 的单年值过滤完全静默失效 - 影响:用户输入
2024[EDAT]期望按入库日期过滤,实际得到的是pub_year >= 2024(近似日期,语义错误)。EDAT 列过滤完全 skipped - 修复:6 个非 DP 日期字段的单年值分支改为设置专用的
*_from=YYYY-01-01和*_to=YYYY-12-31,不再设置year_from/year_to
BUG-2 (HIGH): 跨日期字段 year_from/year_to 互相覆盖
- 文件:
pubmed_query_parser.py:480-561 - 根因:
year_from/year_to是ParsedPubmedQuery的共享属性。2024[DP] AND 2025[EDAT]中 DP 先设置year_from=2024,EDAT 后覆盖为year_from=2025→ DP 条件完全丢失。最终引擎只看到pub_year >= 2025 - 影响:用户查询
2024[DP] AND 2025[EDAT]期望「2024年出版 AND 2025年入库」,实际得到「2025年出版」(DP 条件丢失) - 修复:非 DP 日期字段不再设置
year_from/year_to(仅设置专用字段)。DP 字段保持设置year_from/year_to。引擎 section 6b 已通过专用字段正确生成 SQL 条件
BUG-3 (MINOR): MHDA/LR/DCOM/DEP 缺少 _PARTIAL_DATE_RE 分支
- 文件:
pubmed_query_parser.py:510-561 - 根因:4 个日期字段 MHDA/LR/DCOM/DEP 直接从年份检查跳到 else 分支,缺少
elif _PARTIAL_DATE_RE.match(term.text)的展开步骤。2024-02[MHDA]被降级为纯文本搜索而非展开为整月范围。DP/EDAT/CRDT 已有此分支 - 修复:为 4 个字段各添加
_PARTIAL_DATE_RE展开分支,使用各自的专用属性(mhda_from/mhda_to等)
BUG-4 (MINOR): _single_term_condition 未处理括号组内日期字段
- 文件:
search_engine.py:1358-1362 - 根因:
_single_term_condition处理了所有 30+ 特殊字段(MH/PT/GR/SH/RN 等),但完全遗漏了日期字段(DP/EDAT/CRDT/MHDA/LR/DCOM/DEP)。括号组(2024[DP] OR 2025[DP])中的日期词回退到全文本 ILIKE%2024% - 修复:在回退前添加日期字段处理:4 位数年份展开为全年范围列条件,完整日期使用列等值条件。
DP → pub_year/pub_date,EDAT → entrez_date,CRDT → create_date,MHDA → meshed_date,LR → pubmed_revised,DCOM → date_completed,DEP → pub_date - 验证:
(2024[EDAT] OR 2025[EDAT])正确生成entrez_date 年内范围 OR条件
第十五轮:第 15 轮审计(1 项修复)
日期:2026-07-28
提交:5698d94
数量:1 项(MEDIUM)
触发:用户第 10 次要求全面检查
测试:1006 全部通过 + 前端 build 通过
审计范围:端到端字段分发审计、缓存/Facet 一致性审计、前端参数发送审计(3 并行 agent)
__RANGE_* 标记在括号组内被错误过滤
- 文件:
search_engine.py:1138 - 根因:
_parse_range()在解析2024:2025[EDAT]等日期范围时,为表明此语法已在顶层_parse_range中直接设置result.edat_from/edat_to,生成了一个副作用的__RANGE_EDAT__标记 Term(_is_range_end=True)。该标记在顶层被正确过滤(不进入_dispatch_term),但出现在括号组内(2024:2025[EDAT] AND cancer)时,_parse_primary将其与组内其他词一起放入result.groups。_pubmed_conditions遍历组内词调用_single_term_condition()时,__RANGE_EDAT__无对应 handler → 回退到_field_condition("all", "2024:2025"),将范围值当作纯文本搜索 → SQL 中多出一条无意义条件search_tsv @@ plainto_tsquery('2024:2025'),返回零结果(静默数据丢失) - 影响:任何包含
(start:end[date-field] ...)括号组的 PubMed 查询,日期范围条件按顶层 AND 正确应用,但括号组内多出一条多余的假条件,导致符合条件的文献被错误排除。单条2024:2025[EDAT](无括号组)不受影响 - 修复:在
_pubmed_conditions的组遍历循环中,在调用_single_term_condition前检查getattr(t, '_is_range_end', False),是则跳过 - 验证:
(2024:2025[EDAT] AND cancer)不再因为多余的__RANGE_EDAT__过滤而返回零结果。完全等效于2024:2025[EDAT] AND cancer
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| 端到端字段分发(parser→engine) | ✅ 所有 30+ 字段标签正确分发。__RANGE_* 在组内回退已修复 |
| 缓存键参数完备性 | ✅ _search_cache_key + _facet_cache_key 包含全部参数 |
| Facet 查询与主查询一致性 | ✅ 条件完全一致 |
DATE_FIELD_COLS 列名映射 |
✅ 6 列全部正确 |
| 前端参数发送 | ✅ SearchView.vue 完整发送全部 28 个参数 |
| Plan P1-4/P1-7/P1-8/F-1 | ✅ 前期轮次已全部实现 |
第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
日期:2026-07-29
提交:de1f4a4
数量:4 项修复 + 3 项记录
触发:用户第 11 次要求全面检查(Round 16,3 并行 agent:Normal 搜索边缘、前端参数、NOT 检测)
测试:1007 全部通过 + 前端 build 通过
Bug-1 (MEDIUM): _parse_primary 括号组内重复 group 赋值
- 文件:
pubmed_query_parser.py:706-709 - 根因:
_parse_or_expr在A OR B AND C时为[B, C]创建 sub-group。随后_parse_primary将所有 terms(含已 sub-group 的)再统一加到 parent group。sub-group 内的 term 同时出现在两个 group →_pubmed_conditions遍历 group 列表时为其生成两套条件 → SQL 中产生重复/多余的过滤条件,静默排除合法结果 - 影响:
NOT (A OR B AND C)类带 sub-group 的括号组查询可能返回零结果 - 修复:
_parse_primary只从t.group_id < 0(未分配)的 term 创建 parent group。sub-group 已分配的不再加入。同时增加 depth 守卫:_parse_or_expr在self._depth > 0(括号内)时直接 flat 返回,不创建 sub-group
Bug-2 (MEDIUM): all_not 混淆外部 NOT 与内部 NOT
- 文件:
search_engine.py:1134-1172 - 根因:
all_not = all(t.is_not for t in group)无法区分NOT (A OR B)(外部 NOT:应生成not_(or_(A, B))) 和(NOT A OR NOT B)(内部 NOT:应生成or_(not_(A), not_(B)))。两者都all_not=True,但语义完全不同 - 修复:
- 解析器端:新增
ParsedPubmedQuery.group_negated: list[bool]字段,_parse_primary在创建 parent group 时记录是否为外部 NOT wrapper - 引擎端:用
group_negated[idx]替代all_not,外部 NOT 走not_(combine_fn(g_neg)),内部 NOT 走combine_fn(g_pos + g_neg_with_not_)
- 解析器端:新增
- 验证:
NOT (A OR B)与(NOT A OR NOT B)生成不同的 SQL 条件组合
Bug-3 (MEDIUM): 搜索错误显示为"no results"
- 文件:
SearchView.vue:365-367 - 根因:catch 块只调用
toast.apiError()(瞬态通知提示),但results = []导致<NEmpty>显示"未找到匹配文献",用户以为搜索有结果只是条件过严,实际是后端错误 - 修复:新增
searchErrorref,catch 时设置明确错误信息,模板条件渲染<NResult>错误面板替代NEmpty。成功搜索时清除searchError
Bug-4 (LOW): UUID 类型转换在中文标签子查询中
- 文件:
literature.py:293-294 - 根因:
[str(t) for t in _tag_matches]将 UUID 转字符串后传给in_(...),某些驱动下可能导致类型不匹配 - 修复:改为
list(_tag_matches)传递原生 UUID 对象
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| Normal 搜索边缘情况 | ✅ _parse_primary 重复 group 已修复。PubMed 降级路径 field tag 清洗已正确。ATM 展开括号剥离已正确 |
| 前端参数发送 | ✅ SearchView.vue 完整发送全部 28 个参数,SearchRequestBody 类型正确 |
| NOT 检测 | ✅ group_negated 新增 track,all_not 已替换。NOT-wrapped parens 与 sub-group 交互部分缓解(depth guard)。剩余 De Morgan 双重否定场景(LOW,理论正确性,实际罕见) |
第十八轮:第 18 轮审计修复(6 项)
日期:2026-07-29
提交:5fa2fbe(与第 17 轮同一提交)
数量:6 项(1 HIGH + 5 MEDIUM)
触发:用户第 13 次要求全面检查(Round 18,3 并行 agent:SQL 生成、Pub 高级搜索前端、解析器/搜索 parity)
测试:1007 全部通过 + 前端 build 通过
Bug-R18-1 (MEDIUM): MH:NOEXP 在括号组内被忽略
- 文件:
pubmed_query_parser.py:710-712 - 根因:
(lung OR breast)[MH:NOEXP]的[MH:NOEXP]末尾字段标签被_normalize_field_label返回"MH",但_noexp=True未传播到括号组内的各个 term。组内每个 term 照常走 MeSH 展开路径(mesh_headings JSONB contains),无视 NOEXP 要求 - 修复:在
_parse_primary中,_raw_field.startswith("MH:") and "NOEXP" in _raw_field.upper()时,将该 field 对应 group 内所有 term 标记_noexp=True - 验证:
(lung OR breast)[MH:NOEXP]正确使用mesh_headings JSONB contains而非explode
Bug-R18-2 (MEDIUM): 日期范围 full_date:year 同一年份不交换
- 文件:
pubmed_query_parser.py:806,812 - 根因:
2024-12-31:2024[DP]的 swap 条件int(start_val[:4]) > int(end_val)→2024 > 2024为 False,不触发交换。2024-12-31(完整日期)作为 start,2024(纯年份)作为 end,_parse_range解释器要求 start ≤ end 才能正确生成范围条件 - 修复:将两个 swap 条件从
>改为>= - 验证:
2024-12-31:2024[DP]正确交换为2024:2024-12-31
Bug-R18-3 (HIGH): 非 DOI "/" 路径缺失 journal_iso ILIKE
- 文件:
search_engine.py:1538-1547 - 根因:
_field_condition("all")的 "/" 分支(非 DOI、非 Chinese、非通配符)包含journal ILIKE但缺少journal_iso ILIKE。其他所有 ILIKE 分支(wildcard、text、Chinese)都同时包含journal和journal_iso。只有此分支遗漏了journal_iso - 影响:PubMed 等数据库中大量缩写刊名通过
journal_iso存储,纯文本搜索不含斜杠的词时,缩写刊名匹配性能低于应有水平 - 修复:在
or_(...)中加入GlobalLiterature.journal_iso.ilike(like_val)
Bug-R18-4 (MEDIUM): Chinese 路径缺失 journal_iso/pmid/doi ILIKE
- 文件:
search_engine.py:1549-1557 - 根因:Chinese 字符路径的
or_(...)仅包含title/abstract/author_names_text/journal ILIKE+affiliation EXISTS,比通配符分支少了journal_iso、pmid和doi字段 - 修复:补全
journal_iso.ilike(like_val)、cast(GlobalLiterature.pmid, String).ilike(like_val)、GlobalLiterature.doi.ilike(like_val)
Bug-R18-5 (MEDIUM): PA 仅检查 name 不检查 ui
- 文件:
search_engine.py:1011,1015,1369 - 根因:
pharmacological_actions JSONB contains只检查{"name": t.text}。但 PubMed PA 字段允许按 UI(唯一标识符)搜索,如d015056[PA](UI = D015056,对应名称 = "Antineoplastic Agents")。pharmacological_actionsJSONB 中同时存储name和ui,但代码只匹配name - 影响:按 UI 搜索 PA 时返回零结果
- 修复:批量路径(
_pubmed_conditions的 PA 块)和单路径(_single_term_condition)均改为or_(contains({"name": ...}), contains({"ui": ...}))
Bug-R18-6 (MEDIUM): text 变量遮蔽 SQLAlchemy text()
- 文件:
search_engine.py:1417 - 根因:
_single_term_condition中日期字段分支内text = term.text将text作为局部变量。虽然此后该分支未再调用text(),但text命名会遮蔽 Python 内置/导入的text()。若未来在该分支后增加 SQL text() 调用,将产生不易追踪的错误 - 修复:重命名为
_term_text
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| SQL 生成全路径 | ✅ "/" 分支 journal_iso 、Chinese 分支补全、PA ui 检查均已修复 |
| Pub 高级搜索前端 | ✅ 无代码级 bug(auth 限制属设计决策、#N 解析由前端覆盖、公共路由约束合理) |
| 解析器/搜索 parity | ✅ MH:NOEXP 组内传播、日期 swap 边界均已修复 |
| text() 变量名 | ✅ 已重命名为 _term_text |
第十九轮:第 19 轮审计修复(10 项)
日期:2026-07-29
提交:1401bb7
数量:10 项(3 HIGH + 6 MEDIUM + 1 LOW)
触发:用户第 14 次要求全面检查(Round 19,4 并行 agent:R18 回归、搜索引擎路径、解析器深度、前端搜索)
测试:1007 全部通过 + 前端 build 通过
Bug-R19-1 (HIGH): NOT (A OR B) 与 (NOT A OR NOT B) 结构完全一致
- 文件:
pubmed_query_parser.py:680-691 - 根因:
_parse_not_expr对NOT (A OR B)的处理是遍历组内所有 term 并翻转t.is_not,但不设置group_negated[gid]=True。引擎端看到的是:组 operator=or、所有 term 的is_not=True、group_negated=False→ 生成or_(not_(cond_A), not_(cond_B))。但 PubMed 语义是not_(or_(cond_A, cond_B))=NOT (A OR B)=NOT A AND NOT B。两者 De Morgan 不等价(一个是 AND,一个是 OR)。 - 影响:任何
NOT (...)查询的组内布尔逻辑完全错误。例如NOT (lung OR breast)返回NOT A OR NOT B结果而非NOT A AND NOT B。 - 修复:当
_parse_not_expr翻转 inner term 的is_not后,检查是否所有 term 都属于组(group_id >= 0)。若是,则撤销 per-term 翻转,改为设置result.group_negated[gid] = True。
Bug-R19-2 (MEDIUM): R18 日期 swap year:full_date 边界过宽
- 文件:
pubmed_query_parser.py:810 - 根因:R18 将两个 swap 条件统一改为
>=,但 year:full_date 和 full_date:year 应区别对待。2024:2024-03-01[DP](year:full_date,同年)不应 swap(用户意图 Q1),但int(2024) >= int(2024)→ True → 错误 swap 为 Q2-Q4。 - 修复:year:full_date 分支换回
>(仅 start year > end year 时才 swap),full_date:year 分支保留>=。
Bug-R19-3 (MEDIUM): group_negated 长度与 groups 不匹配
- 文件:
pubmed_query_parser.py:648-649 - 根因:
_parse_or_expr的 AND 聚类创建 sub-group 时追加到groups和group_operators,但未追加到group_negated。当父括号组和 AND sub-group 同时存在时,groups长度 >group_negated→_pubmed_conditions中group_negated[idx]→IndexError。 - 修复:sub-group 创建处追加
result.group_negated.append(False)。
Bug-R19-4 (LOW): 括号内 AND 聚类丢失
- 文件:
pubmed_query_parser.py:638-640 - 根因:
_depth > 0守卫跳过括号内的 AND subgroup 创建。(A OR B AND C)被扁平化为[A, B, C]后用 OR 组合 → 输出A OR B OR C而非正确语义A OR (B AND C)。 - 修复:移除
if self._depth > 0: return all_terms守卫(依赖 Bug-R19-3 的group_negated.append(False))。
Bug-R19-5 (MEDIUM): 混合大小写引号短语丢失 exact 标记
- 文件:
search_engine.py:430,433 - 根因:
_phrase_terms_set = set(p.lower() ...)使用小写键,但term in _phrase_terms_set用原始大小写比较。"Lung Cancer" in {"lung cancer"}→ False → exact_phrase 不被强制。 - 影响:
"Lung Cancer"在exact_phrase=False模式下退化为plainto_tsquery(词序无关),可能匹配 "Cancer Lung"。 - 修复:改为
term.lower() in _phrase_terms_set。
Bug-R19-6 (HIGH): 公共搜索完全不可用
- 文件:
features.py:282,literature.py:256 - 根因:
/features/search/advanced和/literature/search两个搜索端点都依赖get_current_user,user参数未被任何函数体使用。匿名用户访问时返回 401。公共搜索路由完全无法使用。 - 修复:移除两个端点的
user: dict = Depends(get_current_user)依赖。
Bug-R19-7 (MEDIUM): message.warning() 副作用在 Vue computed 中
- 文件:
AdvancedPubSearchView.vue:244-246 - 根因:
resolveQuery()在#N引用不存在时调用message.warning()。该函数从translatedcomputed 调用(每次 queryText 变化时重评估),导致响应式循环中弹出 toasts。 - 修复:移除
message.warning()调用。#N不存在时直接返回原样(validateQuery已在校验时给出错误提示,此处冗余)。
Bug-R19-8 (MEDIUM): 搜索错误信息不区分状态码
- 文件:
SearchView.vue:369 - 根因:catch 块对所有错误使用统一提示",忽略 401/400/429/500 的差异化信息。公共搜索用户看到"网络问题"提示,实际是未登录。
- 修复:检查
e?.response?.status,区分 401(未登录)、400(参数错误)、429(限流)、500(服务异常)的场景。
Bug-R19-9 (HIGH): R18 text 重命名遗留未引用
- 文件:
search_engine.py:1445,1448 - 根因:R18 将
text = term.text重命名为_term_text = term.text,但两处引用_vds(text)和fromisoformat(text)仍使用原变量名text(解析为 SQLAlchemytext函数对象 → AttributeError → 降级)。 - 影响:括号组内非 4 位数字的日期字段(如
(2024-01-01[DP] OR cancer))静默降级到纯文本搜索。 - 修复:两处
text→_term_text。
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| R18 回归(text 重命名) | ✅ _vds(_term_text) + fromisoformat(_term_text) 已修复 |
| R18 回归(日期 swap) | ✅ year:full_date 恢复 >,full_date:year 保留 >= |
| 解析器 NOT 语义 | ✅ NOT (A OR B) 现已正确使用 group_negated |
| 解析器组结构 | ✅ group_negated 长度对齐、AND 聚类括号内启用 |
| 搜索引擎路径 | ✅ 混合大小写短语 exact 标记已修复 |
| 前端搜索 | ✅ 公共搜索可用、错误信息区分、message.warning 副作用消除 |
第二十轮:第 20 轮审计修复(6 项)
日期:2026-07-29
提交:2b6ffd6(与第 19 轮同一提交基础上追加)
数量:6 项(2 HIGH + 3 MEDIUM + 1 LOW)
触发:用户第 15 次要求全面检查(Round 20,4 并行 agent:R19 回归/OR 模式、分词器/边界、集成/租户、前端集成)
测试:1007 全部通过 + 前端 build 通过
Bug-R20-1 (HIGH): AND 子组在括号内被提升为顶层 AND 条件
- 文件:
pubmed_query_parser.py(_parse_primary+_parse_or_expr)、search_engine.py(组处理循环) - 根因:第 19 轮移除了
_depth > 0守卫,_parse_or_expr在括号内创建 AND 子组(如(A OR B AND C)→ sub-group[B,C],parent group[A])。但引擎将两个组独立处理后全部 AND 在一起 →A AND B AND C。正确语义应为A OR (B AND C)。 - 影响:任何括号内混用 AND/OR 的查询(如
(lung OR breast cancer))结果被严重过滤。 - 修复:
- 解析器:新增
ParsedPubmedQuery.sub_group_refs: list[list[int]]记录 parent→child 关系 _parse_primary在创建父组时记录子组 GID- 引擎跳过子组(由父组处理),父组处理时包含自身 term + 子组条件,用父组操作符组合
- 解析器:新增
- 验证:
(A OR B AND C)正确生成or_(A, and_(B, C))
Bug-R20-2 (HIGH): OR 模式 NOT 条件未独立 AND
- 文件:
search_engine.py:1234-1236 - 根因:
boolean_operator == "or"时所有条件(含not_(cond))被 OR 在一起:or_(cond_A, not_(cond_B))→ 匹配 A OR 非 B(几乎全库)。PubMed 语义:A OR B NOT C=(A OR B) AND NOT C。 - 影响:任何 OR+NOT 混合查询(如
cancer OR NOT review)返回结果极大膨胀。 - 修复:OR 模式复用
mixed模式的 NOT 分离逻辑:将UnaryExpressionNOT 条件分离出来独立 AND。
Bug-R20-3 (MEDIUM): is_pubmed_syntax() 不识别小写布尔运算符
- 文件:
pubmed_query_parser.py:917 - 根因:布尔运算符正则
r'\b(AND|OR|NOT)\b'缺少re.IGNORECASE。is_pubmed_syntax("cancer and tumor")返回False→ 查询不触发 PubMed 路径。 - 影响:使用小写布尔运算符的 PubMed 查询丢失所有字段语义。
- 修复:
re.search(..., re.IGNORECASE)。
Bug-R20-4 (MEDIUM): 长格式字段标签不被识别
- 文件:
pubmed_query_parser.py(_ALL_FIELD_TAGS+_FIELD_TAG_MAP) - 根因:
[Title]、[All Fields]、[MeSH Terms]等长格式标签不在_ALL_FIELD_TAGS中,被降级为普通 WORD。示例:cancer[Title]→ 三个普通词。 - 修复:
_ALL_FIELD_TAGS新增 40+ 长格式标签;_FIELD_TAG_MAP新增到规范内部名的映射。 - 验证:
cancer[Title]→ field="title"、breast[MeSH Major Topic]→ field="MAJR"
Bug-R20-5 (MEDIUM): 400 错误 URL 持久化导致刷新循环
- 文件:
SearchView.vue:379 - 根因:
syncSearchToUrl()在finally中无条件运行,400 参数被持久化到 URL。刷新后恢复相同参数 → 再 400 → 死循环。 - 修复:
syncSearchToUrl()移到try块末尾(仅成功时同步)。
Bug-R20-6 (LOW): 空括号 [] 产生空 Term
- 文件:
pubmed_query_parser.py:221-224 - 根因:UNKNOWN_FIELD 匹配
[],strip('[]')产生空字符串 → 创建空 Term。 - 修复:
stripped为空时跳过。
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| R19 回归(AND 子组) | ✅ sub_group_refs 层级追踪 + 引擎层级处理 |
| OR 模式 NOT 语义 | ✅ 分离 NOT 条件独立 AND |
| 分词器/边界 | ✅ re.IGNORECASE、长格式标签、空括号跳过 |
| 前端集成 | ✅ 400 URL 持久化循环修复 |
第十七轮:第 17 轮审计修复(3 项)
日期:2026-07-29
提交:5fa2fbe
数量:3 项(1 HIGH + 2 MEDIUM)
触发:用户第 12 次要求全面检查(Round 17,3 并行 agent:_is_flat_text 降级路径、facet 一致性、前端参数映射)
审计:facet 一致性和缓存键验证通过,未发现问题
测试:1007 全部通过 + 前端 build 通过
P0-1 (HIGH): _search_cache_key 在 sub-path A 中被污染
- 文件:
search_engine.py:314-318 - 根因:
_search_cache_key在第 186 行用原始query预计算。第 314-318 行在 sub-path A 中对query原地修改(剥离 field tags/布尔符/引号/括号),但缓存键未更新。后续请求命中此缓存时,返回的是剥离后的空查询结果。 - 触发条件:仅当
is_pubmed_syntax(query)=True但parse_pubmed_query(query)返回空有效字段时(如"NOT"、"AND OR"等无意义查询)。实际影响极小,但属于正确性 bug。 - 修复:在
query原地修改后重新调用_search_cache_key()和_facet_cache_key(),确保缓存键反映剥离后的查询内容。
P0-2 (MEDIUM): _pubmed_conditions 无异常保护
- 文件:
search_engine.py:282-284 - 根因:
_pubmed_conditions()调用无 try/except。内部虽有零散异常处理,但AttributeError/TypeError等会传播到search()外 → 500 错误(literature.py:330已有全局保护,但高级搜索引擎没有)。 - 修复:包裹 try/except Exception,异常时
logger.exception()并降级到 flat text 路径:_is_flat_text = True; conditions = []
P0-3 (MEDIUM): 引号短语不强制 exact_phrase
- 文件:
search_engine.py:334-393 - 根因:flat text 路径从查询中提取引号短语
"lung cancer"作为独立词,但传入_field_condition(term, exact_phrase)时使用全局exact_phrase参数。exact_phrase=False时,引号短语被拆散为lung AND cancer而非保持<->短语搜索。 - 修复:记录引号短语集
_phrase_terms_set,传入_field_condition时:引号短语始终exact=True,其他词使用全局exact_phrase。
审计结果汇总
| 审计维度 | 结果 |
|---|---|
_is_flat_text 降级路径 |
✅ 缓存键中毒(P0-1)已修复、异常保护(P0-2)已添加、引号短语(P0-3)已修复 |
| Fragment/缓存完整性 | ✅ parse_pubmed_query 异常降级 clean、回退路径比普通搜索更全面 |
year_counts facet 一致性 |
✅ 筛选条件与主查询完全一致、缓存键差异仅限于分页参数(设计意图)、无问题 |
| Facet 缓存键完备性 | ✅ _facet_cache_key 包含全部 26 个筛选参数,无缺失 |
| 前端参数映射 | ✅ SearchView.vue 发送全部 28 个参数、HomeView 参数子集缩小属设计意图、is_oa 后端功能被 is_free_full_text 覆盖、无实际 gap |
截至 2026-07-29,剩余 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 回归不可见 |
需字段级测试。第 15 轮修复了 __RANGE_* 组内回退 |
低 |
| L10 | _has_or 深度盲区 |
_parse_primary 在父组范围内搜索 OR token,不限制括号深度。(A AND (B OR C)) 中父组操作符错误为 "or" |
极低(仅在复杂嵌套触发) |
第二十一轮:第 21 轮审计修复(10 项)
日期:2026-07-29
提交:53cf1d6..(第 20 轮后追加)
数量:10 项(2 CRITICAL + 1 HIGH + 4 MEDIUM + 3 LOW)
触发:用户第 16 次要求全面检查(第 21 轮,4 并行审计 agent)
测试:1007+ 全部通过 + 前端 build 通过
Bug-R21-1 (CRITICAL): OR 模式 NOT 分离过度 — A OR NOT B 语义错误
- 文件:
search_engine.py:1265-1278 - 根因:R20 将 OR 模式的
or_(*term_conditions)改为分离 UnaryExpression NOT 后独立 AND。cancer OR NOT review被编译为cancer AND NOT review(仅检索 cancer 且不是 review 的文献),而非正确的 PubMed 语义cancer OR NOT review(所有 cancer 文献 + 所有非 review 文献)。 - 影响:OR+NOT 组合查询结果严重过窄。违反"搜索功能必须与 PubMed 完全一致"硬性要求。
- 修复:恢复为
conditions.append(or_(*term_conditions))。
Bug-R21-2 (CRITICAL): 双重嵌套括号 sub_group_refs 虚条目 — 搜索词被丢弃
- 文件:
pubmed_query_parser.py:791-798 - 根因:
_parse_primary在_ungrouped为空(所有词已在前一层分好组)时仍写入sub_group_refs,导致sub_group_refs比groups多一项。组索引 0 出现在虚条目的子列表中 → 引擎_is_child为 True → 整个组被continue跳过。 - 触发:任何双重嵌套括号
((cancer[MH]))或外层括号内全部是已分组内容的表达式。 - 修复:仅当
_ungrouped非空(即真正创建父组)时才写入sub_group_refs。
Bug-R21-3 (HIGH): negated_date_ranges 被覆盖而非合并
- 文件:
pubmed_query_parser.py:461 - 根因:第 461 行
=直接覆盖集合,_dispatch_term中单日期 NOT 的add()被丢弃。NOT "2024-01-01"[DP]→ 引擎误以正日期过滤。 - 修复:
=改为|=。
Bug-R21-4 (MEDIUM): 日期/PMID/DOI/PMC 条件在 OR 模式下始终 AND
- 文件:
search_engine.py:1377-1380 - 根因:
_pubmed_conditions末尾所有日期/ID 条件conditions.append()→and_(*conditions)强制 AND。cancer OR 2000:2020[DP]实际等同cancer AND pub_date in 2000-2020。 - 修复:OR 模式时
conditions = [or_(*conditions)]。
Bug-R21-5 (MEDIUM): _parse_atom 无条件消费任何词符
- 文件:
pubmed_query_parser.py:836-839 - 根因:
self.advance()不验证类型。cancer OR OR lung→ 第二个 OR 被当作 WORD。 - 修复:读取前验证
self.peek().type是原子类型。
Bug-R21-6 (MEDIUM): 模态框触发两次 goToPage(1)
- 文件:
SearchView.vue:961/981/997 - 根因:按钮
@click同时设置showModal = false(触发 watcher)和直接goToPage(1)。 - 修复:按钮只设置
showModal = false,搜索由 watcher 触发。
Bug-R21-7 (MEDIUM): page.value 失败后不回退
- 文件:
usePagination.ts:23-26 - 根因:
goToPage在fetchFn前设置page.value = n。 - 修复:捕获异常后恢复
page.value。
Bug-R21-8 (LOW): 空引号 ""[TI] 产生空 Term
- 文件:
pubmed_query_parser.py:843-846 - 根因:
""被 tokeniser 匹配为 QUOTED,strip('"')后为空。 - 修复:
if not text: return []。
Bug-R21-9 (LOW): 冗余函数内 import re
- 文件:
pubmed_query_parser.py:997、search_engine.py:358/372 - 修复:移除冗余函数级导入,使用模块级
import re。
审计结果汇总
| 审计维度 | 结果 |
|---|---|
| R20 回归(OR NOT 分离) | ✅ 已 revert |
| R20 回归(sub_group_refs 虚条目) | ✅ _ungrouped 守卫 |
| 搜索引擎代码 | ✅ negated_date_ranges、OR 模式日期条件、冗余导入 |
| 解析器/分词器 | ✅ _parse_atom 类型验证、空引号守卫 |
| 前端集成 | ✅ 双重 goToPage、page 回滚 |
| 已知限制更新 | L4 已修复移除、新增 L10 _has_or 深度盲区 |
附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 |
|---|---|---|
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 等) |
| 全量测试套件 | 1006 | 全部通过(含前 15 轮 287 项搜索专项 + 719 项通用测试) |
预存失败(13 项):9 项
feed_engineStopAsyncIteration(测试数据缺失) + 4 项pubmed_api_tag_articleimport(函数已移入 pipeline)