diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 478c5b8..934da65 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -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 diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 98d8d93..a6840b8 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -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 diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 9672856..da6bb8b 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -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 项) diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 2c02323..a57ab87 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -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 } },