# PubMed 搜索合规修复全记录 > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > > **累计**:14 轮,200 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制 > **时间跨度**:2026-07-24 ~ 2026-07-29 > **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行) --- ## 目录 1. [第一轮:Phase 0-7 基础修复(34 项)](#第一轮phase-0-7-基础修复) 2. [第二轮:第二轮审计修复(8 项)](#第二轮第二轮审计修复) 3. [第三轮:第三轮审计修复(14 项)](#第三轮第三轮审计修复) 4. [第四轮:字段补全与语义优化(11 项)](#第四轮字段补全与语义优化) 5. [第五轮:第 5 轮全面审计修复(4 项)](#第五轮第-5-轮全面审计修复) 6. [第六轮:第 6 轮全面审计修复(12 项)](#第六轮第-6-轮全面审计修复) 7. [第七轮:第 7 轮深度审计修复(20 项)](#第七轮第-7-轮深度审计修复) 8. [第八轮:第 8 轮深度审计修复(12 项)](#第八轮第-8-轮深度审计修复) 9. [第九轮:第 9 轮深度审计修复(5 项)](#第九轮第-9-轮深度审计修复) 10. [第十轮:第 10 轮深度审计修复(6 项)](#第十轮第-10-轮深度审计修复) 11. [第十一轮:第 11 轮深度审计修复(16 项)](#第十一轮第-11-轮深度审计修复) 12. [第十二轮:第 12 轮深度审计修复(21 项)](#第十二轮第-12-轮深度审计修复) 13. [第十三轮:第 13 轮深度审计修复(21 项)](#第十三轮第-13-轮深度审计修复) 14. [第十四轮:第 14 轮深度审计修复(21 项)](#第十四轮第-14-轮深度审计修复) 15. [遗留限制](#遗留限制) --- ## 第一轮: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` | 含 ``/`` 等内联标记的标题截断 | `.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`: ```python 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.py` L862-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.py` L428-433 - **问题**:`NOT NOT cancer` → 第二个 "NOT" 被降级为搜索字面词,变成 `NOT "NOT" AND cancer` - **根因**:语法定义 `not_expr → NOT not_expr | primary`,但实现直接跳到 `_parse_primary(result, negated=True)`,丢失递归 - **修复**:改为递归调用 `_parse_not_expr` 并 toggle `is_not` #### F4: SearchView Custom Range 不发送日期 - **文件**:`SearchView.vue` L296-309 - **问题**:选择"自定义年份范围"→ 搜索,请求体不含任何日期参数,返回全库结果 - **根因**:`datePreset === 'custom'` 分支未处理。仅 `!datePreset.value` 和 `datePreset.value !== 'custom'` 两个条件都失败 - **修复**:增加 `datePreset === 'custom'` 分支发送 `year_from`/`year_to` #### F5: HomeView.restoreFromUrl 恢复不全 - **文件**:`HomeView.vue` L348-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.py` L1119-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_terms` gate 随字段新增未同步更新 - **修复**:在条件判断中添加 `pp.sb_terms`, `pp.stat_terms`, `pp.uid_terms`, `pp.dep_from` #### P0-2: `[SB]` 映射到错误领域 - **文件**:`search_engine.py` L830-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.py` L882 - **问题**:`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 同步中被丢弃 - **修复**:把这些参数加入 `searchKey` computed 依赖和 URL 替换逻辑 #### P0-5: 日期精度丢失 - **文件**:`SearchView.vue` - **问题**:URL 中的 `date_from=2025-03-15` 恢复后变成 `2025-03-14` 或丢失 - **根因**:使用 `date_from`/`date_to` ref 存储(受 DatePicker 组件双向绑定影响),而非原始 URL 值 - **修复**:增加 `urlDateFrom`/`urlDateTo` ref 直接存储原始日期字符串 ### 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_TAGS` set:新增 8 个标签名 - `_FIELD_TAG_MAP`:注册映射关系 - `_SPECIAL_FIELDS`:加 `OT`、`GEN`、`PMC` - `ParsedPubmedQuery`:加 `ot_terms`/`gene_terms`/`pmc_terms` list - `_dispatch_term`:加 3 个 elif 分支 - `search_engine.py`:两个 `has_pubmed_terms` gate 加新字段 ### 语义修复(3 项) #### P4-8: `[OT]` → keywords JSONB(不再映射到 all) - **问题**:`[OT]` 映射到 "all" 意味着在标题/摘要/PMID/DOI 全字段搜索,语义过宽。PubMed 的 `[OT]` 只搜索 Other Keywords(`keywords` JSONB 列) - **修复**: - `_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_expr` L498 - **问题**:`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.py` `parse_pubmed_query` L679 - **问题**:`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.py` `is_pubmed_syntax` L652 - **问题**:全角括号 `[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.py` `extract_pubmed_query_for_prisma` L706 - **问题**:归一化 regex `\[([\w:]+)\]` 不含 `/`,`[Title/Article]` 不被匹配、保持原文 - **根因**:regex 字符类不含 `/` - **修复**:`[\w:]` → `[\w/:]` ### P5-5: Tokeniser 缺口字符被静默丢弃(BUG-7) - **文件**:`pubmed_query_parser.py` `tokenise()` 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_MAP` L54 - **问题**:`_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.py` L222 - **问题**:`re.sub(r'\[[\w-]+\]', '', query)` —— `[\w-]` 不含 `/`,`[Title/Abstract]` 不会被擦除,留在降级查询中作为普通文本 - **根因**:字符类缺 `/` - **修复**:`[\w-]` → `[\w/-]` ### P6-4: ATM 展开未剥离括号(Flat Text BUG 5) - **文件**:`search_engine.py` L284 - **问题**:`_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.py` L229-235 - **问题**:`GlobalTag.name_zh.ilike(...)` 可能返回大量匹配(如 "癌"),导致 subquery 膨胀 - **修复**:追加 `.limit(100)` 限制 ### P6-6: year_from / year_to 使用 falsy 检测(BUG-15) - **文件**:`search_engine.py` L312-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.py` tokenise 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.py` L498-509 - **状态**:已在第五阶段实现,审计确认正确(递归调用 `_parse_not_expr`) ### P6-12: 前端日期发送 + restoreFromUrl(F4+F5) - **文件**:`SearchView.vue` L300-302、`HomeView.vue` L364-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.py` `search()` 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.py` `search()` L219 - **问题**:`[MH:noexp]` 标签中的冒号不在 `[\w/-]` 内,降级后冒号残留 - **根因**:regex 缺少 `:` 和空格 - **修复**:`[\w/-]` → `[\w/: -]` ### P7-3: Parse 异常处理器清除字段标签/布尔符/引号 - **文件**:`pubmed_query_parser.py` `parse_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.py` `search()` 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.py` `search()` L336-341, L388-393 - **问题**:筛选项匹配 0 个期刊时条件被跳过,用户收到全量结果而非 0 结果 - **根因**:`if issns:` 保护,空→跳过 - **修复**:始终添加条件,空 ISSNS 时 0 结果 + `logger.warning` ### P7-11: ATM 展开异常日志化 - **文件**:`search_engine.py` `search()` 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.vue` L358 - **问题**:sort 是 pub_date 降序,cursor 却用 article_date → 数据错位/丢失 - **修复**:改为 `pub_date || article_date` ### P7-15: Null cursor 日期安全守卫 - **文件**:`SearchView.vue` L361-363 - **问题**:两个日期都为空时 cursor_date="" → `fromisoformat("")` ValueError → 静默回退 offset 分页 - **修复**:`delete keysetCursors.value[p+1]` 当两日期都为空 ### P7-16: syncSearchToUrl 移到 finally 块 - **文件**:`SearchView.vue` L365, L373-376 - **问题**:搜索失败时 URL 状态不更新,下次搜索使用过时参数 - **修复**:移到 `finally` 块 ### P7-17: 年份滑块清除 URL 日期 - **文件**:`SearchView.vue` `onYearSliderChange()` L89 - **问题**:拖动年份滑块后 URL 残留 `date_from`/`date_to` 与滑块设置冲突 - **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''` ### P7-18: resetAllFilters 清除 URL 日期 - **文件**:`SearchView.vue` `resetAllFilters()` L528 - **问题**:重置筛选后 urlDateFrom/urlDateTo 依然存在 - **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''` ### P7-19: MAX_TERMS 保护 - **文件**:`pubmed_query_parser.py` `tokenise()` - **问题**:超长查询(>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 不含 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` 检测交替循环(#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) - `booleanOp` ref 驱动 `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` ### 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。当 `authors` JSON 数组包含非 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` 是 Python `set` 字面量(`{...}`),没有 `.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_date` lambda 只校验格式(是否为 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` 条件 --- 截至 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` 回归不可见 | 需字段级测试 | 低 | --- ## 附录:测试覆盖统计 | 测试文件 | 用例数 | 范围 | |---------|--------|------| | `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_engine` `StopAsyncIteration`(测试数据缺失) + 4 项 `pubmed_api` `_tag_article` import(函数已移入 pipeline)