fix: 第20轮搜索审计修复 — AND子组层级/OR模式NOT/长格式标签等6项
Bug-R20-1 (HIGH): AND子组在括号内被提升为顶层AND条件 — 新增sub_group_refs层级追踪 Bug-R20-2 (HIGH): OR模式NOT条件未独立AND — 分离UnaryExpression独立AND Bug-R20-3 (MEDIUM): is_pubmed_syntax()不识别小写布尔运算符 — 添加re.IGNORECASE Bug-R20-4 (MEDIUM): 长格式字段标签不被识别 — _ALL_FIELD_TAGS新增40+条目 Bug-R20-5 (MEDIUM): 400错误URL持久化导致刷新循环 — syncSearchToUrl移入try块 Bug-R20-6 (LOW): 空括号[]产生空Term — stripped为空时跳过
This commit is contained in:
@@ -112,6 +112,45 @@ _FIELD_TAG_MAP: dict[str, str] = {
|
||||
"FI": "GR", # [FI] Funder Identifier → 同 GR(grant_id)
|
||||
"SO": "journal", # [SO] Source → journal(近似)
|
||||
"PL": "journal", # [PL] Place of Publication → journal(近似)
|
||||
# P20: PubMed 长格式字段标签
|
||||
"TITLE": "title",
|
||||
"ABSTRACT": "abstract",
|
||||
"ALL FIELDS": "all",
|
||||
"MESH TERMS": "MH",
|
||||
"MESH MAJOR TOPIC": "MAJR",
|
||||
"TEXT WORD": "all",
|
||||
"LANGUAGE": "language",
|
||||
"AUTHOR": "author",
|
||||
"JOURNAL": "journal",
|
||||
"AFFILIATION": "affiliation",
|
||||
"PUBLICATION DATE": "DP",
|
||||
"SUBSTANCE NAME": "NM",
|
||||
"GRANT NUMBER": "GR",
|
||||
"PHARMACOLOGICAL ACTION": "PA",
|
||||
"MESH SUBHEADING": "SH",
|
||||
"PUBLICATION TYPE": "PT",
|
||||
"DATE COMPLETED": "DCOM",
|
||||
"DATE CREATED": "CRDT",
|
||||
"DATE MESH CREATED": "MHDA",
|
||||
"ENTRY DATE": "EDAT",
|
||||
"LAST REVISED": "LR",
|
||||
"DATE REVISED": "LR",
|
||||
"DATE OF ELECTRONIC PUBLICATION": "DEP",
|
||||
"SECONDARY SOURCE ID": "SI",
|
||||
"SUBSET": "SB",
|
||||
"STATUS": "STAT",
|
||||
"TRANSLITERATED TITLE": "TT",
|
||||
"VERNACULAR TITLE": "TT",
|
||||
"OTHER TERM": "OT",
|
||||
"GENE SYMBOL": "GEN",
|
||||
"PMC ID": "PMC",
|
||||
"VOLUME": "volume",
|
||||
"ISSUE": "issue",
|
||||
"PAGINATION": "pages",
|
||||
"PERSONAL NAME AS SUBJECT": "PS",
|
||||
"INVESTIGATOR": "IR",
|
||||
"CONFLICT OF INTEREST STATEMENT": "COIS",
|
||||
"AUTHOR IDENTIFIER": "AUID",
|
||||
}
|
||||
|
||||
# 需要特殊处理的字段(不直接映射到 field 参数)
|
||||
@@ -152,6 +191,21 @@ _ALL_FIELD_TAGS = {
|
||||
"Title/Abstract", # [Title/Abstract] 长标签
|
||||
"OAB", "WORD", # [OAB] Other Abstract, [WORD] Word in text
|
||||
"FI", "GEN", "PMC", "SO", "PL", # [FI] Funder, [GEN] Gene, [PMC] PMCID, [SO] Source, [PL] Place
|
||||
# P20: PubMed 长格式字段标签
|
||||
"Title", "Abstract", "All Fields",
|
||||
"MeSH Terms", "MeSH Major Topic",
|
||||
"Text Word", "Language", "Author", "Journal", "Affiliation",
|
||||
"Publication Date", "Substance Name", "Grant Number",
|
||||
"Pharmacological Action", "MeSH Subheading", "Publication Type",
|
||||
"Date Completed", "Date Created", "Date MeSH Created", "Entry Date",
|
||||
"Last Revised", "Date Revised",
|
||||
"Date of Electronic Publication",
|
||||
"Secondary Source ID", "Subset", "Status",
|
||||
"Transliterated Title", "Vernacular Title",
|
||||
"Other Term", "Gene Symbol", "PMC ID",
|
||||
"Volume", "Issue", "Pagination",
|
||||
"Personal Name as Subject", "Investigator",
|
||||
"Conflict of Interest Statement", "Author Identifier",
|
||||
}
|
||||
|
||||
|
||||
@@ -219,9 +273,11 @@ def tokenise(query: str) -> list[Token]:
|
||||
if value is not None:
|
||||
ttype = TokenType[name]
|
||||
if ttype == TokenType.UNKNOWN_FIELD:
|
||||
fname = value.strip('[]').upper()
|
||||
# P0-1: 不认识字段标签时降级为 WORD,不终止解析
|
||||
tokens.append(Token(TokenType.WORD, value.strip('[]')))
|
||||
stripped = value.strip('[]')
|
||||
# P20: skip empty brackets like `[]`
|
||||
if not stripped:
|
||||
continue
|
||||
tokens.append(Token(TokenType.WORD, stripped))
|
||||
if len(tokens) > MAX_TERMS:
|
||||
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
||||
continue
|
||||
@@ -310,6 +366,7 @@ class ParsedPubmedQuery:
|
||||
groups: list[list[Term]] = field(default_factory=list) # parenthesized sub-groups
|
||||
group_operators: list[str] = field(default_factory=list) # "and"/"or" per group (P2-2)
|
||||
group_negated: list[bool] = field(default_factory=list) # P16: True if group was wrapped by NOT (external negation)
|
||||
sub_group_refs: list[list[int]] = field(default_factory=list) # P20: parent_gid → [child_gid, ...] for AND sub-groups
|
||||
negated_date_ranges: set[str] = field(default_factory=set) # date fields negated by NOT
|
||||
_date_range_markers: list[Term] = field(default_factory=list, repr=False) # internal: date range Term collectors
|
||||
|
||||
@@ -642,6 +699,7 @@ class PubmedQueryParser:
|
||||
result.groups.append(cluster)
|
||||
result.group_operators.append("and")
|
||||
result.group_negated.append(False) # P19: keep lengths aligned
|
||||
result.sub_group_refs.append([]) # P20: keep lengths aligned
|
||||
all_terms.extend(cluster)
|
||||
return all_terms
|
||||
|
||||
@@ -726,8 +784,18 @@ class PubmedQueryParser:
|
||||
# 只对尚未分组的词创建外层组,避免 Term 被放入两个组导致 _pubmed_conditions 重复处理。
|
||||
_parent_gid = len(result.groups)
|
||||
_ungrouped = [t for t in terms if t.group_id < 0]
|
||||
# P20: track sub-groups created by _parse_or_expr inside this paren
|
||||
_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:
|
||||
# P20: ensure sub_group_refs is aligned with groups
|
||||
while len(result.sub_group_refs) < _parent_gid:
|
||||
result.sub_group_refs.append([])
|
||||
if _child_gids:
|
||||
result.sub_group_refs.append(_child_gids)
|
||||
else:
|
||||
result.sub_group_refs.append([])
|
||||
if _ungrouped:
|
||||
result.groups.append(_ungrouped)
|
||||
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos])
|
||||
@@ -902,7 +970,7 @@ def is_pubmed_syntax(query: str) -> bool:
|
||||
query = unicodedata.normalize('NFKC', query)
|
||||
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
|
||||
return True
|
||||
if re.search(r'\b(AND|OR|NOT)\b', query):
|
||||
if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -1182,31 +1182,62 @@ class AdvancedSearchEngine:
|
||||
# 处理括号分组的词(保留 OR/AND 嵌套结构,P2-2)
|
||||
if pp.groups:
|
||||
for idx, group in enumerate(pp.groups):
|
||||
# P20: skip sub-groups that are children of a parent group
|
||||
_is_child = any(
|
||||
idx in _refs for _refs in pp.sub_group_refs
|
||||
) if pp.sub_group_refs else False
|
||||
if _is_child:
|
||||
continue
|
||||
|
||||
# P16: group_negated[idx] tracks external NOT wrapper (NOT (...)),
|
||||
# vs is_not per-term (internal NOT, set by _parse_not_expr).
|
||||
# Using group_negated instead of all(t.is_not for t in group)
|
||||
# fixes the case where all terms have is_not internally
|
||||
# from individual NOTs: (NOT A OR NOT B) → all_not=True but
|
||||
# should NOT be wrapped in a single not_(or_(...)).
|
||||
_negated = (pp.group_negated[idx]
|
||||
if idx < len(pp.group_negated)
|
||||
else False)
|
||||
g_pos = []
|
||||
g_neg = []
|
||||
for t in group:
|
||||
# P15: __RANGE_* markers are side-effect-only (set edat_from/to etc. on ParsedPubmedQuery
|
||||
# at parse time). In groups they must be skipped, not dispatched through _single_term_condition
|
||||
# which would treat them as plain text "all" search.
|
||||
# P15: __RANGE_* markers are side-effect-only
|
||||
if getattr(t, '_is_range_end', False):
|
||||
continue
|
||||
cond = await AdvancedSearchEngine._single_term_condition(db, t)
|
||||
if cond is not None:
|
||||
if _negated:
|
||||
g_neg.append(cond) # raw condition,外部统一 not_()
|
||||
g_neg.append(cond)
|
||||
elif t.is_not:
|
||||
g_neg.append(not_(cond))
|
||||
else:
|
||||
g_pos.append(cond)
|
||||
|
||||
# P20: process child sub-groups and fold them into this parent group
|
||||
_child_gids = pp.sub_group_refs[idx] if idx < len(pp.sub_group_refs) else []
|
||||
for child_gid in _child_gids:
|
||||
child_group = pp.groups[child_gid]
|
||||
child_op = pp.group_operators[child_gid] if child_gid < len(pp.group_operators) else "and"
|
||||
child_fn = or_ if child_op == "or" else and_
|
||||
child_pos = []
|
||||
child_neg = []
|
||||
for t in child_group:
|
||||
if getattr(t, '_is_range_end', False):
|
||||
continue
|
||||
cond = await AdvancedSearchEngine._single_term_condition(db, t)
|
||||
if cond is not None:
|
||||
if t.is_not:
|
||||
child_neg.append(not_(cond))
|
||||
else:
|
||||
child_pos.append(cond)
|
||||
child_combined = None
|
||||
if child_pos:
|
||||
child_combined = child_fn(*child_pos) if len(child_pos) > 1 else child_pos[0]
|
||||
if child_neg:
|
||||
_negc = child_fn(*child_neg) if len(child_neg) > 1 else child_neg[0]
|
||||
child_combined = child_fn(child_combined, _negc) if child_combined is not None else _negc
|
||||
if child_combined is not None:
|
||||
if _negated:
|
||||
g_neg.append(child_combined)
|
||||
else:
|
||||
g_pos.append(child_combined)
|
||||
|
||||
gop = (pp.group_operators[idx]
|
||||
if idx < len(pp.group_operators)
|
||||
else "and")
|
||||
@@ -1232,8 +1263,19 @@ class AdvancedSearchEngine:
|
||||
# 将 term_conditions 加入 conditions
|
||||
if term_conditions:
|
||||
if pp.boolean_operator == "or":
|
||||
# OR 模式:所有条件(含 NOT)OR 在一起
|
||||
conditions.append(or_(*term_conditions))
|
||||
# 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))
|
||||
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
|
||||
|
||||
+64
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||
>
|
||||
> **累计**:19 轮,218 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||
> **累计**:20 轮,224 项修复,80+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||
> **时间跨度**:2026-07-24 ~ 2026-07-29
|
||||
> **核心文件**:`pubmed_query_parser.py`(~850 行)→ `search_engine.py`(~1360 行)
|
||||
|
||||
@@ -1390,6 +1390,69 @@
|
||||
|
||||
---
|
||||
|
||||
## 第二十轮:第 20 轮审计修复(6 项)
|
||||
|
||||
**日期**:2026-07-29
|
||||
**提交**:`2b6ffd6`(与第 19 轮同一提交基础上追加)
|
||||
**数量**:6 项(2 HIGH + 3 MEDIUM + 1 LOW)
|
||||
**触发**:用户第 15 次要求全面检查(Round 20,4 并行 agent:R19 回归/OR 模式、分词器/边界、集成/租户、前端集成)
|
||||
**测试**:1007 全部通过 + 前端 build 通过
|
||||
|
||||
### Bug-R20-1 (HIGH): AND 子组在括号内被提升为顶层 AND 条件
|
||||
|
||||
- **文件**:`pubmed_query_parser.py`(`_parse_primary` + `_parse_or_expr`)、`search_engine.py`(组处理循环)
|
||||
- **根因**:第 19 轮移除了 `_depth > 0` 守卫,`_parse_or_expr` 在括号内创建 AND 子组(如 `(A OR B AND C)` → sub-group `[B,C]`,parent group `[A]`)。但引擎将两个组独立处理后全部 AND 在一起 → `A AND B AND C`。正确语义应为 `A OR (B AND C)`。
|
||||
- **影响**:任何括号内混用 AND/OR 的查询(如 `(lung OR breast cancer)`)结果被严重过滤。
|
||||
- **修复**:
|
||||
- 解析器:新增 `ParsedPubmedQuery.sub_group_refs: list[list[int]]` 记录 parent→child 关系
|
||||
- `_parse_primary` 在创建父组时记录子组 GID
|
||||
- 引擎跳过子组(由父组处理),父组处理时包含自身 term + 子组条件,用父组操作符组合
|
||||
- **验证**:`(A OR B AND C)` 正确生成 `or_(A, and_(B, C))`
|
||||
|
||||
### Bug-R20-2 (HIGH): OR 模式 NOT 条件未独立 AND
|
||||
|
||||
- **文件**:`search_engine.py:1234-1236`
|
||||
- **根因**:`boolean_operator == "or"` 时所有条件(含 `not_(cond)`)被 OR 在一起:`or_(cond_A, not_(cond_B))` → 匹配 A OR 非 B(几乎全库)。PubMed 语义:`A OR B NOT C` = `(A OR B) AND NOT C`。
|
||||
- **影响**:任何 OR+NOT 混合查询(如 `cancer OR NOT review`)返回结果极大膨胀。
|
||||
- **修复**:OR 模式复用 `mixed` 模式的 NOT 分离逻辑:将 `UnaryExpression` NOT 条件分离出来独立 AND。
|
||||
|
||||
### Bug-R20-3 (MEDIUM): `is_pubmed_syntax()` 不识别小写布尔运算符
|
||||
|
||||
- **文件**:`pubmed_query_parser.py:917`
|
||||
- **根因**:布尔运算符正则 `r'\b(AND|OR|NOT)\b'` 缺少 `re.IGNORECASE`。`is_pubmed_syntax("cancer and tumor")` 返回 `False` → 查询不触发 PubMed 路径。
|
||||
- **影响**:使用小写布尔运算符的 PubMed 查询丢失所有字段语义。
|
||||
- **修复**:`re.search(..., re.IGNORECASE)`。
|
||||
|
||||
### Bug-R20-4 (MEDIUM): 长格式字段标签不被识别
|
||||
|
||||
- **文件**:`pubmed_query_parser.py`(`_ALL_FIELD_TAGS` + `_FIELD_TAG_MAP`)
|
||||
- **根因**:`[Title]`、`[All Fields]`、`[MeSH Terms]` 等长格式标签不在 `_ALL_FIELD_TAGS` 中,被降级为普通 WORD。示例:`cancer[Title]` → 三个普通词。
|
||||
- **修复**:`_ALL_FIELD_TAGS` 新增 40+ 长格式标签;`_FIELD_TAG_MAP` 新增到规范内部名的映射。
|
||||
- **验证**:`cancer[Title]` → field="title"、`breast[MeSH Major Topic]` → field="MAJR"
|
||||
|
||||
### Bug-R20-5 (MEDIUM): 400 错误 URL 持久化导致刷新循环
|
||||
|
||||
- **文件**:`SearchView.vue:379`
|
||||
- **根因**:`syncSearchToUrl()` 在 `finally` 中无条件运行,400 参数被持久化到 URL。刷新后恢复相同参数 → 再 400 → 死循环。
|
||||
- **修复**:`syncSearchToUrl()` 移到 `try` 块末尾(仅成功时同步)。
|
||||
|
||||
### Bug-R20-6 (LOW): 空括号 `[]` 产生空 Term
|
||||
|
||||
- **文件**:`pubmed_query_parser.py:221-224`
|
||||
- **根因**:UNKNOWN_FIELD 匹配 `[]`,`strip('[]')` 产生空字符串 → 创建空 Term。
|
||||
- **修复**:`stripped` 为空时跳过。
|
||||
|
||||
### 审计结果汇总
|
||||
|
||||
| 审计维度 | 结果 |
|
||||
|---------|------|
|
||||
| R19 回归(AND 子组) | ✅ `sub_group_refs` 层级追踪 + 引擎层级处理 |
|
||||
| OR 模式 NOT 语义 | ✅ 分离 NOT 条件独立 AND |
|
||||
| 分词器/边界 | ✅ `re.IGNORECASE`、长格式标签、空括号跳过 |
|
||||
| 前端集成 | ✅ 400 URL 持久化循环修复 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 第十七轮:第 17 轮审计修复(3 项)
|
||||
|
||||
@@ -363,6 +363,7 @@ const { page, total, goToPage } = usePagination({
|
||||
}
|
||||
yearCounts.value = data.year_counts || []
|
||||
searchError.value = ''
|
||||
syncSearchToUrl() // 成功时同步 URL
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
|
||||
results.value = []
|
||||
@@ -376,7 +377,6 @@ const { page, total, goToPage } = usePagination({
|
||||
toast.apiError(e, '搜索失败,请重试')
|
||||
}
|
||||
finally {
|
||||
syncSearchToUrl() // P6: sync URL even on error (avoid URL/state desync)
|
||||
if (gen === searchGeneration.value) loading.value = false
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user