From c68aa060f0e74261cce76ed6d6b475bb08fc2184 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 16:52:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC21=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=20OR=E6=A8=A1?= =?UTF-8?q?=E5=BC=8FNOT=E5=9B=9E=E9=80=80/=E5=AD=90=E7=BB=84=E8=99=9A?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE/=E6=97=A5=E6=9C=9FOR=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E7=AD=8910=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 使用模块级导入 --- backend/app/services/pubmed_query_parser.py | 21 ++++-- backend/app/services/search_engine.py | 26 +++---- docs/13-搜索修复全记录.md | 79 ++++++++++++++++++++- frontend/src/composables/usePagination.ts | 7 +- frontend/src/views/app/SearchView.vue | 6 +- 5 files changed, 110 insertions(+), 29 deletions(-) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 934da65..e9ff22a 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -458,7 +458,8 @@ class PubmedQueryParser: result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED))) # Recompute negated_date_ranges from marker terms (after NOT toggling from recursive _parse_not_expr) - result.negated_date_ranges = { + # R21: use |= not = to preserve negated_date_ranges added by _dispatch_term single-date NOTs + result.negated_date_ranges |= { t.field.replace("__RANGE_", "").replace("__", "") for t in result._date_range_markers if t.is_not } @@ -788,7 +789,7 @@ class PubmedQueryParser: _child_gids = sorted(set(t.group_id for t in terms if t.group_id >= 0)) for t in _ungrouped: t.group_id = _parent_gid - if _ungrouped or _child_gids: + if _ungrouped: # P20: ensure sub_group_refs is aligned with groups while len(result.sub_group_refs) < _parent_gid: result.sub_group_refs.append([]) @@ -796,7 +797,8 @@ class PubmedQueryParser: result.sub_group_refs.append(_child_gids) else: result.sub_group_refs.append([]) - if _ungrouped: + # R21: only write sub_group_refs when creating a parent group, + # preventing phantom entries when _ungrouped is empty (all terms already in child groups) result.groups.append(_ungrouped) _has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos]) result.group_operators.append("or" if _has_or else "and") @@ -831,9 +833,15 @@ class PubmedQueryParser: and t2.type in (TokenType.NUMBER, TokenType.DATE, TokenType.WORD)): return self._parse_range(result, negated) - # Normal atom + # Normal atom — only consume if peek is a valid atomic token + t0 = self.peek() + if t0.type not in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER, TokenType.DATE): + return [] token = self.advance() text = token.value.strip('"') if token.type == TokenType.QUOTED else token.value + # R21: skip empty quoted text ""[TI] + if not text: + return [] is_exact = (token.type == TokenType.QUOTED) field = None _noexp = False # P1-4 @@ -986,17 +994,16 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery: try: # P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits query = unicodedata.normalize('NFKC', query) - import re as _re # 将 YYYY/MM/DD 或 YYYY/M/D 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE # Anchored with context boundaries to avoid over-matching inside URLs/paths - query = _re.sub( + query = re.sub( r'(^|[\[\s":])(\d{4})/(\d{1,2})/(\d{1,2})(?=\s|$|[\[\]":])', lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}', query, ) # P5: Normalize single-digit month/day (2024-1-1 → 2024-01-01) to match DATE token pattern # Anchored with context boundaries to avoid over-matching inside non-date text - query = _re.sub( + query = re.sub( r'(^|[\[\s":])(\d{4})-(\d{1,2})-(\d{1,2})(?=\s|$|[\[\]":])', lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}', query, diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index a6840b8..017b550 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -355,8 +355,7 @@ class AdvancedSearchEngine: exclude_preprints=exclude_preprints, ) # 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE - import re as _re - _CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-﫿]') + _CHINESE_RE = re.compile(r'[一-鿿㐀-䶿豈-﫿]') if _CHINESE_RE.search(query): tag_matches = (await db.execute( select(GlobalTag.id).where(GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip())}%')) @@ -369,8 +368,7 @@ class AdvancedSearchEngine: if query.strip(): _term_start = len(conditions) # P13: track term condition range for OR combining # 提取引号短语作为完整词,避免 "lung cancer" 被拆散 - import re as _phrase_re - _phrase_pat = _phrase_re.compile(r'"([^"]*)"') + _phrase_pat = re.compile(r'"([^"]*)"') _phrases = _phrase_pat.findall(query) _query_no_quotes = _phrase_pat.sub(' ', query) _rest = [t.strip().strip('"').strip("'") for t in _query_no_quotes.split() if t.strip()] @@ -1263,19 +1261,8 @@ class AdvancedSearchEngine: # 将 term_conditions 加入 conditions if term_conditions: if pp.boolean_operator == "or": - # OR 模式:NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C) - from sqlalchemy.sql.elements import UnaryExpression - from sqlalchemy.sql import operators as _sa_ops - _pos = [c for c in term_conditions - if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)] - _neg = [c for c in term_conditions - if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv] - if _neg: - if _pos: - conditions.append(or_(*_pos)) - conditions.extend(_neg) - else: - conditions.append(or_(*term_conditions)) + # OR 模式:所有词条件 OR 在一起。NOT 项也一起 OR(PubMed: A OR NOT B ≠ A AND NOT B) + conditions.append(or_(*term_conditions)) elif pp.boolean_operator == "mixed": # mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C) from sqlalchemy.sql.elements import UnaryExpression @@ -1374,6 +1361,11 @@ class AdvancedSearchEngine: cond = not_(cond) conditions.append(cond) + # R21: OR 模式下日期/PMID/DOI/PMC 条件应 OR 进词条件,而非始终 AND + # A OR 2000:2020[DP] 应 = articles about A OR articles in 2000-2020 + if pp.boolean_operator == "or" and conditions: + conditions = [or_(*conditions)] + return conditions @staticmethod diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 86bb73f..33bc857 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -2,7 +2,7 @@ > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > -> **累计**:20 轮,224 项修复,80+ 字段标签注册,1007 项测试覆盖,7 项已知限制 +> **累计**:21 轮,235 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制 > **时间跨度**:2026-07-24 ~ 2026-07-29 > **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行) @@ -1508,6 +1508,83 @@ | 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 深度盲区 | --- diff --git a/frontend/src/composables/usePagination.ts b/frontend/src/composables/usePagination.ts index 47b4539..f35ca74 100644 --- a/frontend/src/composables/usePagination.ts +++ b/frontend/src/composables/usePagination.ts @@ -21,8 +21,13 @@ export function usePagination(opts: UsePaginationOptions) { const hasMore = computed(() => page.value < totalPages.value) async function goToPage(n: number) { + const prev = page.value page.value = n - await fetchFn(n) + try { + await fetchFn(n) + } catch { + page.value = prev + } } return { page, pageSize, total, totalPages, hasMore, goToPage } diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index a57ab87..924443d 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -958,7 +958,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {}) @@ -978,7 +978,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {}) @@ -994,7 +994,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})