fix: 第21轮搜索审计修复 — OR模式NOT回退/子组虚条目/日期OR模式等10项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

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 — 使用模块级导入
This commit is contained in:
34047007@qq.com
2026-07-28 16:52:31 +08:00
parent 53cf1d6b70
commit c68aa060f0
5 changed files with 110 additions and 29 deletions
+14 -7
View File
@@ -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,
+9 -17
View File
@@ -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 项应独立 ANDPubMed: 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 项也一起 ORPubMed: A OR NOT B ≠ A AND NOT B
conditions.append(or_(*term_conditions))
elif pp.boolean_operator == "mixed":
# mixed 模式下 NOT 项应独立 ANDPubMed: 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
+78 -1
View File
@@ -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 深度盲区 |
---
+6 -1
View File
@@ -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 }
+3 -3
View File
@@ -958,7 +958,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
<template #footer>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:13px;color:var(--text-muted)">已选择 {{ pubTypes.length }} 项</span>
<NButton size="small" type="primary" @click="showPubTypeModal = false; goToPage(1)">确定</NButton>
<NButton size="small" type="primary" @click="showPubTypeModal = false">确定</NButton>
</div>
</template>
</NModal>
@@ -978,7 +978,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end">
<NButton size="small" type="primary" @click="showLangModal = false; goToPage(1)">确定</NButton>
<NButton size="small" type="primary" @click="showLangModal = false">确定</NButton>
</div>
</template>
</NModal>
@@ -994,7 +994,7 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end">
<NButton size="small" type="primary" @click="showAgeModal = false; goToPage(1)">确定</NButton>
<NButton size="small" type="primary" @click="showAgeModal = false">确定</NButton>
</div>
</template>
</NModal>