fix: 第22轮搜索审计修复 — De Morgan语义/部分日期/has_not/custom URL等8项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

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
This commit is contained in:
34047007@qq.com
2026-07-28 17:05:27 +08:00
parent c68aa060f0
commit 2444a4be2c
4 changed files with 105 additions and 23 deletions
+8 -2
View File
@@ -445,7 +445,7 @@ class PubmedQueryParser:
# P12: has_not 同时检查分组内 NOT(如 NOT (a OR b) # P12: has_not 同时检查分组内 NOT(如 NOT (a OR b)
result.has_not = any(t.is_not for t in _ungrouped) or any( 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 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] result.not_terms = [t for t in _ungrouped if t.is_not]
for t in _ungrouped: for t in _ungrouped:
self._dispatch_term(result, t) self._dispatch_term(result, t)
@@ -453,7 +453,7 @@ class PubmedQueryParser:
# P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN) # P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN)
if self.pos < len(self.tokens) - 1: if self.pos < len(self.tokens) - 1:
for t in self.tokens[self.pos:-1]: # exclude EOF token 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 text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value
result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED))) 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}', lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}',
query, 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) tokens = tokenise(query)
parser = PubmedQueryParser(tokens) parser = PubmedQueryParser(tokens)
return parser.parse() return parser.parse()
+19 -18
View File
@@ -941,7 +941,7 @@ class AdvancedSearchEngine:
term_conditions.append(or_(*pos_conds)) term_conditions.append(or_(*pos_conds))
if neg: if neg:
neg_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([t.text]) for t in 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 # 5b. [GR] [SH] [RN] [NM] [SI] [PA] → JSONB contains,支持 is_not
if pp.grant_terms: if pp.grant_terms:
@@ -954,7 +954,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": t.text}]) for t in 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: if pp.subheading_terms:
pos = [t for t in pp.subheading_terms if not t.is_not] 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] neg = [t for t in pp.subheading_terms if t.is_not]
@@ -965,7 +965,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [t.text]}]) for t in 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: if pp.registry_terms:
pos = [t for t in pp.registry_terms if not t.is_not] 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] neg = [t for t in pp.registry_terms if t.is_not]
@@ -976,7 +976,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": t.text}]) for t in 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: if pp.substance_terms:
pos = [t for t in pp.substance_terms if not t.is_not] 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] neg = [t for t in pp.substance_terms if t.is_not]
@@ -987,7 +987,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": t.text}]) for t in 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: if pp.databank_terms:
pos = [t for t in pp.databank_terms if not t.is_not] 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] neg = [t for t in pp.databank_terms if t.is_not]
@@ -998,7 +998,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [t.text]}]) for t in 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 # 5c. [PA] → pharmacological_actions JSONB contains (by name or ui),支持 is_not
if pp.pharmaco_terms: if pp.pharmaco_terms:
@@ -1020,7 +1020,7 @@ class AdvancedSearchEngine:
) )
for t in neg 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 # P4: [OT] → keywords JSONB contains(不再映射到 all
if pp.ot_terms: if pp.ot_terms:
@@ -1033,7 +1033,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.keywords.cast(JSONB).contains([t.text]) for t in 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 # P4: [GEN] → gene_symbols JSONB contains
if pp.gene_terms: if pp.gene_terms:
@@ -1046,7 +1046,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) for t in 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 # 5d. [ED] [IR] [PS] [PUBN] [AUID] [COIS] [TT] → 新增字段搜索,支持 is_not
if pp.ed_terms: if pp.ed_terms:
@@ -1059,7 +1059,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": t.text}]) for t in 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: if pp.investigator_terms:
pos = [t for t in pp.investigator_terms if not t.is_not] 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] neg = [t for t in pp.investigator_terms if t.is_not]
@@ -1070,7 +1070,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.investigators.cast(JSONB).contains([{"family": t.text}]) for t in 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: if pp.personal_name_terms:
pos = [t for t in pp.personal_name_terms if not t.is_not] 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] neg = [t for t in pp.personal_name_terms if t.is_not]
@@ -1081,7 +1081,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": t.text}]) for t in 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: if pp.pubnote_terms:
pos = [t for t in pp.pubnote_terms if not t.is_not] 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] neg = [t for t in pp.pubnote_terms if t.is_not]
@@ -1092,7 +1092,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(t.text)}%") for t in 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: if pp.auid_terms:
pos = [t for t in pp.auid_terms if not t.is_not] 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] neg = [t for t in pp.auid_terms if t.is_not]
@@ -1103,7 +1103,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.auid_data.cast(JSONB).contains([{"value": t.text}]) for t in 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: if pp.cois_terms:
pos = [t for t in pp.cois_terms if not t.is_not] 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] neg = [t for t in pp.cois_terms if t.is_not]
@@ -1114,7 +1114,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.cois_statement.ilike(f"%{_escape_ilike(t.text)}%") for t in 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: if pp.tt_terms:
pos = [t for t in pp.tt_terms if not t.is_not] 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] neg = [t for t in pp.tt_terms if t.is_not]
@@ -1125,7 +1125,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(t.text)}%") for t in 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 # P1-2: [SB] Subset
# medline[SB] → citation_status='medline'(记录级) # medline[SB] → citation_status='medline'(记录级)
@@ -1164,7 +1164,7 @@ class AdvancedSearchEngine:
])) ]))
if neg: if neg:
neg_conds = [GlobalLiterature.citation_status == t.text.lower() for t in 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 # P1-2: [UID] → PMID 优先,兜底 DOI
if pp.uid_terms: if pp.uid_terms:
@@ -1201,7 +1201,8 @@ class AdvancedSearchEngine:
cond = await AdvancedSearchEngine._single_term_condition(db, t) cond = await AdvancedSearchEngine._single_term_condition(db, t)
if cond is not None: if cond is not None:
if _negated: 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: elif t.is_not:
g_neg.append(not_(cond)) g_neg.append(not_(cond))
else: else:
+69 -1
View File
@@ -2,7 +2,7 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
> >
> **累计**21 轮,235 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制 > **累计**22 轮,248 项修复,80+ 字段标签注册,1007+ 项测试覆盖,7 项已知限制
> **时间跨度**2026-07-24 ~ 2026-07-29 > **时间跨度**2026-07-24 ~ 2026-07-29
> **核心文件**`pubmed_query_parser.py`~850 行)→ `search_engine.py`~1360 行) > **核心文件**`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 清理 |
---
## 附录:测试覆盖统计 ## 附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 | | 测试文件 | 用例数 | 范围 |
+9 -2
View File
@@ -274,6 +274,8 @@ const { page, total, goToPage } = usePagination({
const gen = ++searchGeneration.value const gen = ++searchGeneration.value
loading.value = true loading.value = true
searched.value = true searched.value = true
searchError.value = ''
total.value = 0
try { try {
if (p === 1 && query.value.trim()) { if (p === 1 && query.value.trim()) {
trackAction('search', 'search', query.value.trim(), { sort: sort.value }) 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.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))) { if (route.query.date_preset && ['1y','5y','10y','custom'].includes(String(route.query.date_preset))) {
datePreset.value = String(route.query.date_preset) datePreset.value = String(route.query.date_preset)
yearFromStr.value = '' if (datePreset.value === 'custom') {
yearToStr.value = '' 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) { } else if (route.query.date_from || route.query.date_to) {
datePreset.value = null datePreset.value = null
if (route.query.date_from) { if (route.query.date_from) {