From 2444a4be2c30df8ed5fc1fd37b92074f0c69d116 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 17:05:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC22=E8=BD=AE=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20De=20Morgan?= =?UTF-8?q?=E8=AF=AD=E4=B9=89/=E9=83=A8=E5=88=86=E6=97=A5=E6=9C=9F/has=5Fn?= =?UTF-8?q?ot/custom=20URL=E7=AD=898=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug-R22-1 (MEDIUM): OR模式特殊字段NOT产生错误De Morgan语义 — 17处修正 Bug-R22-2 (MEDIUM): has_not不反映日期范围NOT — 添加_date_range_markers检查 Bug-R22-3 (MEDIUM): YYYY-MM[DP]未引号部分日期无法解析 — 预处理器标准化 Bug-R22-4 (MEDIUM): date_preset=custom URL恢复丢失年份范围 — 读取URL参数 Bug-R22-5 (MEDIUM): 错误状态下分页总数残留 — 搜索开始时重置total Bug-R22-6 (LOW): 搜索错误状态未在开始时清除 — 搜索开始时重置searchError Bug-R22-7 (LOW): DATE token不在未消耗标记恢复处理器中 — 添加DATE类型 Bug-R22-8 (LOW): 外部NOT组内is_not被忽略 — 保留t.is_not --- backend/app/services/pubmed_query_parser.py | 10 ++- backend/app/services/search_engine.py | 37 +++++------ docs/13-搜索修复全记录.md | 70 ++++++++++++++++++++- frontend/src/views/app/SearchView.vue | 11 +++- 4 files changed, 105 insertions(+), 23 deletions(-) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index e9ff22a..7fe588e 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -445,7 +445,7 @@ class PubmedQueryParser: # P12: has_not 同时检查分组内 NOT(如 NOT (a OR b)) result.has_not = any(t.is_not for t in _ungrouped) or any( t.is_not for g in result.groups for t in g - ) + ) or any(t.is_not for t in result._date_range_markers) result.not_terms = [t for t in _ungrouped if t.is_not] for t in _ungrouped: self._dispatch_term(result, t) @@ -453,7 +453,7 @@ class PubmedQueryParser: # P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN) if self.pos < len(self.tokens) - 1: for t in self.tokens[self.pos:-1]: # exclude EOF token - if t.type in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER): + if t.type in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER, TokenType.DATE): text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED))) @@ -1008,6 +1008,12 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery: lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}', query, ) + # P5: Normalize YYYY-MM (partial month) to YYYY-MM-01 when followed by date field tag + query = re.sub( + r'(\b\d{4}-\d{2})(?!-\d)(?=\s*\[(?:DP|EDAT|DEP|CRDT|MHDA|LR|DCOM)\])', + r'\1-01', + query, + ) tokens = tokenise(query) parser = PubmedQueryParser(tokens) return parser.parse() diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 017b550..a75f8e9 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -941,7 +941,7 @@ class AdvancedSearchEngine: term_conditions.append(or_(*pos_conds)) if neg: neg_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # 5b. [GR] [SH] [RN] [NM] [SI] [PA] → JSONB contains,支持 is_not if pp.grant_terms: @@ -954,7 +954,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.subheading_terms: pos = [t for t in pp.subheading_terms if not t.is_not] neg = [t for t in pp.subheading_terms if t.is_not] @@ -965,7 +965,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [t.text]}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.registry_terms: pos = [t for t in pp.registry_terms if not t.is_not] neg = [t for t in pp.registry_terms if t.is_not] @@ -976,7 +976,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.substance_terms: pos = [t for t in pp.substance_terms if not t.is_not] neg = [t for t in pp.substance_terms if t.is_not] @@ -987,7 +987,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.databank_terms: pos = [t for t in pp.databank_terms if not t.is_not] neg = [t for t in pp.databank_terms if t.is_not] @@ -998,7 +998,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [t.text]}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # 5c. [PA] → pharmacological_actions JSONB contains (by name or ui),支持 is_not if pp.pharmaco_terms: @@ -1020,7 +1020,7 @@ class AdvancedSearchEngine: ) for t in neg ] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # P4: [OT] → keywords JSONB contains(不再映射到 all) if pp.ot_terms: @@ -1033,7 +1033,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.keywords.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # P4: [GEN] → gene_symbols JSONB contains if pp.gene_terms: @@ -1046,7 +1046,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # 5d. [ED] [IR] [PS] [PUBN] [AUID] [COIS] [TT] → 新增字段搜索,支持 is_not if pp.ed_terms: @@ -1059,7 +1059,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.investigator_terms: pos = [t for t in pp.investigator_terms if not t.is_not] neg = [t for t in pp.investigator_terms if t.is_not] @@ -1070,7 +1070,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.investigators.cast(JSONB).contains([{"family": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.personal_name_terms: pos = [t for t in pp.personal_name_terms if not t.is_not] neg = [t for t in pp.personal_name_terms if t.is_not] @@ -1081,7 +1081,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.pubnote_terms: pos = [t for t in pp.pubnote_terms if not t.is_not] neg = [t for t in pp.pubnote_terms if t.is_not] @@ -1092,7 +1092,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(t.text)}%") for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.auid_terms: pos = [t for t in pp.auid_terms if not t.is_not] neg = [t for t in pp.auid_terms if t.is_not] @@ -1103,7 +1103,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.auid_data.cast(JSONB).contains([{"value": t.text}]) for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.cois_terms: pos = [t for t in pp.cois_terms if not t.is_not] neg = [t for t in pp.cois_terms if t.is_not] @@ -1114,7 +1114,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.cois_statement.ilike(f"%{_escape_ilike(t.text)}%") for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) if pp.tt_terms: pos = [t for t in pp.tt_terms if not t.is_not] neg = [t for t in pp.tt_terms if t.is_not] @@ -1125,7 +1125,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(t.text)}%") for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # P1-2: [SB] Subset # medline[SB] → citation_status='medline'(记录级) @@ -1164,7 +1164,7 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.citation_status == t.text.lower() for t in neg] - term_conditions.append(not_(or_(*neg_conds))) + term_conditions.extend(not_(c) for c in neg_conds) # P1-2: [UID] → PMID 优先,兜底 DOI if pp.uid_terms: @@ -1201,7 +1201,8 @@ class AdvancedSearchEngine: cond = await AdvancedSearchEngine._single_term_condition(db, t) if cond is not None: if _negated: - g_neg.append(cond) + # R22: preserve inner is_not when group is externally negated + g_neg.append(not_(cond) if t.is_not else cond) elif t.is_not: g_neg.append(not_(cond)) else: diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 33bc857..287f463 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -2,7 +2,7 @@ > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > -> **累计**:21 轮,235 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制 +> **累计**:22 轮,248 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制 > **时间跨度**:2026-07-24 ~ 2026-07-29 > **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行) @@ -1588,6 +1588,74 @@ --- +## 第二十二轮:第 22 轮审计修复(8 项) + +**日期**:2026-07-29 +**提交**:`c68aa06..`(第 21 轮后追加) +**数量**:8 项(5 MEDIUM + 3 LOW) +**触发**:用户第 17 次要求全面检查(第 22 轮,4 并行审计 agent) +**测试**:1007+ 全部通过 + 前端 build 通过 + +### Bug-R22-1 (MEDIUM): OR 模式下特殊字段 NOT 产生错误 De Morgan 语义 + +- **文件**:`search_engine.py:944-1167` +- **根因**:`term_conditions.append(not_(or_(*neg_conds)))` 在 OR 模式下产生 `NOT(A OR B)` = `NOT A AND NOT B`。正确应为 `or_(not_(A), not_(B))` = `NOT A OR NOT B`。 +- **影响**:`NOT "Review"[PT] OR NOT "Clinical Trial"[PT]` 搜索结果过窄。 +- **修复**:将全部 17 处 `term_conditions.append(not_(or_(*neg_conds)))` 改为 `term_conditions.extend(not_(c) for c in neg_conds)`。AND 模式下语义等价,OR/mixed 模式语义修正。 + +### Bug-R22-2 (MEDIUM): `has_not` 不反映日期范围 NOT + +- **文件**:`pubmed_query_parser.py:446` +- **根因**:`_ungrouped` 过滤掉 `_is_range_end=True` 的标记,`has_not` 不检查 `_date_range_markers`。 +- **修复**:`has_not` 额外检查 `result._date_range_markers`。 + +### Bug-R22-3 (MEDIUM): `YYYY-MM[DP]` 未引号部分日期无法解析 + +- **文件**:`pubmed_query_parser.py:1010-1015` +- **根因**:`2024-01[DP]` 分词为 `NUMBER(2024) WORD(-01) FIELD([DP])`。`-01` 不匹配 `_PARTIAL_DATE_RE`。 +- **修复**:预处理器将 `YYYY-MM[date_field]` 标准化为 `YYYY-MM-01[date_field]`。 + +### Bug-R22-4 (MEDIUM): `date_preset=custom` URL 恢复丢失年份范围 + +- **文件**:`SearchView.vue:393-396` +- **根因**:`restoreFromQuery` 在 custom 分支无条件清空 yearFromStr/yearToStr。 +- **修复**:custom 分支读取 URL 中的 `year_from/year_to`。 + +### Bug-R22-5 (MEDIUM): 错误状态下分页总数残留 + +- **文件**:`SearchView.vue:274-276` +- **根因**:catch 块清空 results 但不清除 total。 +- **修复**:搜索开始时重置 `total.value = 0`。 + +### Bug-R22-6 (LOW): 搜索错误状态未在开始时清除 + +- **文件**:`SearchView.vue:274-276` +- **根因**:searchError 仅成功后清空。 +- **修复**:搜索开始时设置 `searchError.value = ''`。 + +### Bug-R22-7 (LOW): DATE token 不在未消耗标记恢复处理器中 + +- **文件**:`pubmed_query_parser.py:456` +- **根因**:未消耗标记处理器仅捕获 WORD/QUOTED/NUMBER。 +- **修复**:添加 `TokenType.DATE`。 + +### Bug-R22-8 (LOW): 外部 NOT 组内 `is_not` 被忽略 + +- **文件**:`search_engine.py:1203` +- **根因**:`NOT (A OR NOT B)` → `not_(or_(A, B))`,内部 NOT B 被吞掉。 +- **修复**:组内保留 `t.is_not`。 + +### 审计结果汇总 + +| 审计维度 | 结果 | +|---------|------| +| R21 回归 | ✅ 无回归 | +| 搜索引擎代码 | ✅ De Morgan 语义 17 处修正、negated 组内 is_not | +| 解析器/分词器 | ✅ YYYY-MM 预处理器、has_not 日期范围、DATE 恢复 | +| 前端集成 | ✅ custom URL 恢复、total/error 清理 | + +--- + ## 附录:测试覆盖统计 | 测试文件 | 用例数 | 范围 | diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 924443d..a9e4baf 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -274,6 +274,8 @@ const { page, total, goToPage } = usePagination({ const gen = ++searchGeneration.value loading.value = true searched.value = true + searchError.value = '' + total.value = 0 try { if (p === 1 && query.value.trim()) { trackAction('search', 'search', query.value.trim(), { sort: sort.value }) @@ -392,8 +394,13 @@ function restoreFromQuery() { if (route.query.sort && VALID_SORTS.has(String(route.query.sort))) sort.value = String(route.query.sort) if (route.query.date_preset && ['1y','5y','10y','custom'].includes(String(route.query.date_preset))) { datePreset.value = String(route.query.date_preset) - yearFromStr.value = '' - yearToStr.value = '' + if (datePreset.value === 'custom') { + if (route.query.year_from) yearFromStr.value = String(route.query.year_from) + if (route.query.year_to) yearToStr.value = String(route.query.year_to) + } else { + yearFromStr.value = '' + yearToStr.value = '' + } } else if (route.query.date_from || route.query.date_to) { datePreset.value = null if (route.query.date_from) {