From 8dd1779676d822f24386818eddeb7cde2c52c686 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 19:19:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC25=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=20=5Fparse=5Fr?= =?UTF-8?q?ange=20intersect/NULL=E5=AE=89=E5=85=A8NOT/De=20Morgan=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F=E7=AD=8912=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R25 修复清单: - CRITICAL: _parse_range 4个range子路径改为intersect模式(真正的H2位置) - CRITICAL: _dispatch_term 7个日期字段恢复简单赋值(消除R24 OR回归) - MEDIUM: 年份计数缓存污染 - _has_any_filter增加conditions检查 - MEDIUM: 13个JSONB/TEXT字段NULL安全NOT(R23-3仅覆盖3个字段) - MEDIUM: De Morgan _handled_neg_group_date_fields跨组污染修正 - MEDIUM: DP无效日期字符串缺少else降级子句 - LOW: MESH:NOEXP字段标签支持 - LOW: is_first_page空字符串cursor_val处理 - 文档: 追加R25修复记录 --- backend/app/services/pubmed_query_parser.py | 282 ++++++-------------- backend/app/services/search_engine.py | 72 +++-- docs/13-搜索修复全记录.md | 65 ++++- 3 files changed, 195 insertions(+), 224 deletions(-) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 6a35766..98636a1 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -68,7 +68,7 @@ def _expand_partial_date(text: str) -> tuple[str, str]: def _normalize_field_label(raw: str) -> str | None: """Normalize raw PubMed field label to internal field name. (P12)""" - if raw == "MH:NOEXP": + if raw in ("MH:NOEXP", "MESH:NOEXP"): return "MH" if raw in _FIELD_TAG_MAP: return _FIELD_TAG_MAP[raw] @@ -182,7 +182,7 @@ _ALL_FIELD_TAGS = { "DCOM", "CRDT", "EDAT", "MHDA", "LR", "DP", "DOI", "DEP", # P1-2: Date of Electronic Publication "RN", "ED", "GR", "IR", "IP", - "TA", "JT", "LA", "LID", "MAJR", "SH", "MH", "MH:NOEXP", "OT", "PG", + "TA", "JT", "LA", "LID", "MAJR", "SH", "MH", "MH:NOEXP", "MESH:NOEXP", "OT", "PG", "PA", "PT", "PMID", "PUBN", "SI", "PS", "NM", "TW", "SB", "STAT", "UID", # P1-2: Subset, Status, UID "MESH", # P1-2: [MH] 别名 @@ -374,6 +374,7 @@ class ParsedPubmedQuery: 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 + _top_level_date_fields: set[str] = field(default_factory=set, repr=False) # date fields with ungrouped terms # ─── Parser ─── @@ -455,6 +456,9 @@ class PubmedQueryParser: result.not_terms = [t for t in _ungrouped if t.is_not] for t in _ungrouped: self._dispatch_term(result, t) + # P25: track date fields with top-level (ungrouped) terms for De Morgan handling + if t.field in _DATE_RANGE_FIELDS: + result._top_level_date_fields.add(t.field) # P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN) if self.pos < len(self.tokens) - 1: @@ -530,74 +534,35 @@ class PubmedQueryParser: elif term.field == "DP": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - # R24: intersect with existing range from _parse_range (AND semantics) - if result.year_from is not None: - result.year_from = max(result.year_from, y) - else: - result.year_from = y - if result.year_to is not None: - result.year_to = min(result.year_to, y) - else: - result.year_to = y + result.year_from = y + result.year_to = y elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - # R24: intersect with existing range - if result.date_from is not None: - result.date_from = max(result.date_from, df) - else: - result.date_from = df - if result.date_to is not None: - result.date_to = min(result.date_to, dt) - else: - result.date_to = dt + result.date_from = df + result.date_to = dt else: if _validate_date_str(term.text): df = dt = term.text - # R24: intersect with existing range - if result.date_from is not None: - result.date_from = max(result.date_from, df) - else: - result.date_from = df - if result.date_to is not None: - result.date_to = min(result.date_to, dt) - else: - result.date_to = dt + result.date_from = df + result.date_to = dt + else: + result.plain_terms.append(term) + return if term.is_not: result.negated_date_ranges.add("DP") elif term.field == "EDAT": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.edat_from is not None: - result.edat_from = max(result.edat_from, _v) - else: - result.edat_from = _v - _v2 = f"{y}-12-31" - if result.edat_to is not None: - result.edat_to = min(result.edat_to, _v2) - else: - result.edat_to = _v2 + result.edat_from = f"{y}-01-01" + result.edat_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.edat_from is not None: - result.edat_from = max(result.edat_from, df) - else: - result.edat_from = df - if result.edat_to is not None: - result.edat_to = min(result.edat_to, dt) - else: - result.edat_to = dt + result.edat_from = df + result.edat_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.edat_from is not None: - result.edat_from = max(result.edat_from, df) - else: - result.edat_from = df - if result.edat_to is not None: - result.edat_to = min(result.edat_to, dt) - else: - result.edat_to = dt + result.edat_from = term.text + result.edat_to = term.text else: result.plain_terms.append(term) return @@ -606,37 +571,16 @@ class PubmedQueryParser: elif term.field == "CRDT": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.crdt_from is not None: - result.crdt_from = max(result.crdt_from, _v) - else: - result.crdt_from = _v - _v2 = f"{y}-12-31" - if result.crdt_to is not None: - result.crdt_to = min(result.crdt_to, _v2) - else: - result.crdt_to = _v2 + result.crdt_from = f"{y}-01-01" + result.crdt_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.crdt_from is not None: - result.crdt_from = max(result.crdt_from, df) - else: - result.crdt_from = df - if result.crdt_to is not None: - result.crdt_to = min(result.crdt_to, dt) - else: - result.crdt_to = dt + result.crdt_from = df + result.crdt_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.crdt_from is not None: - result.crdt_from = max(result.crdt_from, df) - else: - result.crdt_from = df - if result.crdt_to is not None: - result.crdt_to = min(result.crdt_to, dt) - else: - result.crdt_to = dt + result.crdt_from = term.text + result.crdt_to = term.text else: result.plain_terms.append(term) return @@ -645,37 +589,16 @@ class PubmedQueryParser: elif term.field == "MHDA": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.mhda_from is not None: - result.mhda_from = max(result.mhda_from, _v) - else: - result.mhda_from = _v - _v2 = f"{y}-12-31" - if result.mhda_to is not None: - result.mhda_to = min(result.mhda_to, _v2) - else: - result.mhda_to = _v2 + result.mhda_from = f"{y}-01-01" + result.mhda_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.mhda_from is not None: - result.mhda_from = max(result.mhda_from, df) - else: - result.mhda_from = df - if result.mhda_to is not None: - result.mhda_to = min(result.mhda_to, dt) - else: - result.mhda_to = dt + result.mhda_from = df + result.mhda_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.mhda_from is not None: - result.mhda_from = max(result.mhda_from, df) - else: - result.mhda_from = df - if result.mhda_to is not None: - result.mhda_to = min(result.mhda_to, dt) - else: - result.mhda_to = dt + result.mhda_from = term.text + result.mhda_to = term.text else: result.plain_terms.append(term) return @@ -684,37 +607,16 @@ class PubmedQueryParser: elif term.field == "LR": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.lr_from is not None: - result.lr_from = max(result.lr_from, _v) - else: - result.lr_from = _v - _v2 = f"{y}-12-31" - if result.lr_to is not None: - result.lr_to = min(result.lr_to, _v2) - else: - result.lr_to = _v2 + result.lr_from = f"{y}-01-01" + result.lr_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.lr_from is not None: - result.lr_from = max(result.lr_from, df) - else: - result.lr_from = df - if result.lr_to is not None: - result.lr_to = min(result.lr_to, dt) - else: - result.lr_to = dt + result.lr_from = df + result.lr_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.lr_from is not None: - result.lr_from = max(result.lr_from, df) - else: - result.lr_from = df - if result.lr_to is not None: - result.lr_to = min(result.lr_to, dt) - else: - result.lr_to = dt + result.lr_from = term.text + result.lr_to = term.text else: result.plain_terms.append(term) return @@ -723,37 +625,16 @@ class PubmedQueryParser: elif term.field == "DCOM": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.dcom_from is not None: - result.dcom_from = max(result.dcom_from, _v) - else: - result.dcom_from = _v - _v2 = f"{y}-12-31" - if result.dcom_to is not None: - result.dcom_to = min(result.dcom_to, _v2) - else: - result.dcom_to = _v2 + result.dcom_from = f"{y}-01-01" + result.dcom_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.dcom_from is not None: - result.dcom_from = max(result.dcom_from, df) - else: - result.dcom_from = df - if result.dcom_to is not None: - result.dcom_to = min(result.dcom_to, dt) - else: - result.dcom_to = dt + result.dcom_from = df + result.dcom_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.dcom_from is not None: - result.dcom_from = max(result.dcom_from, df) - else: - result.dcom_from = df - if result.dcom_to is not None: - result.dcom_to = min(result.dcom_to, dt) - else: - result.dcom_to = dt + result.dcom_from = term.text + result.dcom_to = term.text else: result.plain_terms.append(term) return @@ -762,37 +643,16 @@ class PubmedQueryParser: elif term.field == "DEP": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - _v = f"{y}-01-01" - if result.dep_from is not None: - result.dep_from = max(result.dep_from, _v) - else: - result.dep_from = _v - _v2 = f"{y}-12-31" - if result.dep_to is not None: - result.dep_to = min(result.dep_to, _v2) - else: - result.dep_to = _v2 + result.dep_from = f"{y}-01-01" + result.dep_to = f"{y}-12-31" elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - if result.dep_from is not None: - result.dep_from = max(result.dep_from, df) - else: - result.dep_from = df - if result.dep_to is not None: - result.dep_to = min(result.dep_to, dt) - else: - result.dep_to = dt + result.dep_from = df + result.dep_to = dt else: if _validate_date_str(term.text): - df = dt = term.text - if result.dep_from is not None: - result.dep_from = max(result.dep_from, df) - else: - result.dep_from = df - if result.dep_to is not None: - result.dep_to = min(result.dep_to, dt) - else: - result.dep_to = dt + result.dep_from = term.text + result.dep_to = term.text else: result.plain_terms.append(term) return @@ -1102,31 +962,49 @@ class PubmedQueryParser: if _start_is_year and _end_is_year: try: if yr_from_attr: - setattr(result, yr_from_attr, int(start_val)) - setattr(result, yr_to_attr, int(end_val)) + curr_f = getattr(result, yr_from_attr) + new_f = int(start_val) + setattr(result, yr_from_attr, max(curr_f, new_f) if curr_f is not None else new_f) + curr_t = getattr(result, yr_to_attr) + new_t = int(end_val) + setattr(result, yr_to_attr, min(curr_t, new_t) if curr_t is not None else new_t) else: # For non-DP date fields: convert year to full date for consistency - setattr(result, date_attr, f"{start_val}-01-01") - setattr(result, date_attr_to, f"{end_val}-12-31") + curr_f = getattr(result, date_attr) + new_f = f"{start_val}-01-01" + setattr(result, date_attr, max(curr_f, new_f) if curr_f is not None else new_f) + curr_t = getattr(result, date_attr_to) + new_t = f"{end_val}-12-31" + setattr(result, date_attr_to, min(curr_t, new_t) if curr_t is not None else new_t) except (ValueError, TypeError): pass elif _start_is_year and not _end_is_year: # Mixed: start is year, end is full date (e.g., 2024:2024-12-01[EDAT]) if yr_from_attr: try: - setattr(result, yr_from_attr, int(start_val)) + curr = getattr(result, yr_from_attr) + v = int(start_val) + setattr(result, yr_from_attr, max(curr, v) if curr is not None else v) except (ValueError, TypeError): pass - setattr(result, date_attr, f"{start_val}-01-01") - setattr(result, date_attr_to, end_val) + curr_f = getattr(result, date_attr) + new_f = f"{start_val}-01-01" + setattr(result, date_attr, max(curr_f, new_f) if curr_f is not None else new_f) + curr_t = getattr(result, date_attr_to) + setattr(result, date_attr_to, min(curr_t, end_val) if curr_t is not None else end_val) elif not _start_is_year and _end_is_year: # Mixed: start is full date, end is year (e.g., 2024-01-01:2026[EDAT]) - setattr(result, date_attr, start_val) - setattr(result, date_attr_to, f"{end_val}-12-31") + curr_f = getattr(result, date_attr) + setattr(result, date_attr, max(curr_f, start_val) if curr_f is not None else start_val) + curr_t = getattr(result, date_attr_to) + v = f"{end_val}-12-31" + setattr(result, date_attr_to, min(curr_t, v) if curr_t is not None else v) else: # Full date range (e.g., 2024-01-01:2024-12-31[EDAT]) - setattr(result, date_attr, start_val) - setattr(result, date_attr_to, end_val) + curr_f = getattr(result, date_attr) + setattr(result, date_attr, max(curr_f, start_val) if curr_f is not None else start_val) + curr_t = getattr(result, date_attr_to) + setattr(result, date_attr_to, min(curr_t, end_val) if curr_t is not None else end_val) marker = Term(f"{start_val}:{end_val}", field=marker_field, is_not=negated) marker._is_range_end = True result._date_range_markers.append(marker) diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 43b89e0..5c02a80 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -189,7 +189,7 @@ class AdvancedSearchEngine: if cursor_val is None and cursor_date is not None: cursor_val = cursor_date - is_first_page = (cursor_val is None and cursor_id is None) + is_first_page = (not cursor_val and cursor_id is None) _search_cache_key = AdvancedSearchEngine._search_cache_key( query, field, boolean, exact_phrase, year_from, year_to, date_from, date_to, @@ -619,7 +619,7 @@ class AdvancedSearchEngine: if year_counts: pass # facet 缓存命中 - elif not _has_any_filter: + elif not _has_any_filter and not conditions: _cached = await _cache.get("search:year_counts:all") if _cached is not None: year_counts = _cached @@ -953,7 +953,9 @@ class AdvancedSearchEngine: term_conditions.append(or_(*pos_conds)) if neg: neg_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.pub_types.is_(None)) for c in neg_conds + ) # 5b. [GR] [SH] [RN] [NM] [SI] [PA] → JSONB contains,支持 is_not if pp.grant_terms: @@ -966,8 +968,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) - if pp.subheading_terms: + term_conditions.extend( + or_(not_(c), GlobalLiterature.grants.is_(None)) for c in neg_conds + ) 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] if pos: @@ -977,7 +980,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [t.text]}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.mesh_headings.is_(None)) for c in neg_conds + ) if pp.registry_terms: 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] @@ -988,7 +993,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.chemical_list.is_(None)) for c in neg_conds + ) if pp.substance_terms: 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] @@ -999,7 +1006,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.chemical_list.is_(None)) for c in neg_conds + ) if pp.databank_terms: 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] @@ -1010,7 +1019,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [t.text]}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.databank_list.is_(None)) for c in neg_conds + ) # 5c. [PA] → pharmacological_actions JSONB contains (by name or ui),支持 is_not if pp.pharmaco_terms: @@ -1032,7 +1043,9 @@ class AdvancedSearchEngine: ) for t in neg ] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.pharmacological_actions.is_(None)) for c in neg_conds + ) # P4: [OT] → keywords JSONB contains(不再映射到 all) if pp.ot_terms: @@ -1045,7 +1058,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.keywords.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.keywords.is_(None)) for c in neg_conds + ) # P4: [GEN] → gene_symbols JSONB contains if pp.gene_terms: @@ -1058,7 +1073,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.gene_symbols.is_(None)) for c in neg_conds + ) # 5d. [ED] [IR] [PS] [PUBN] [AUID] [COIS] [TT] → 新增字段搜索,支持 is_not if pp.ed_terms: @@ -1071,7 +1088,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.authors.is_(None)) for c in neg_conds + ) if pp.investigator_terms: 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] @@ -1082,7 +1101,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.investigators.cast(JSONB).contains([{"family": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.investigators.is_(None)) for c in neg_conds + ) if pp.personal_name_terms: 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] @@ -1093,7 +1114,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": t.text}]) for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.personal_name_subjects.is_(None)) for c in neg_conds + ) if pp.pubnote_terms: 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] @@ -1104,7 +1127,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(t.text)}%") for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.publication_notes.is_(None)) for c in neg_conds + ) if pp.auid_terms: 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] @@ -1185,7 +1210,9 @@ class AdvancedSearchEngine: ])) if neg: neg_conds = [GlobalLiterature.citation_status == t.text.lower() for t in neg] - term_conditions.extend(not_(c) for c in neg_conds) + term_conditions.extend( + or_(not_(c), GlobalLiterature.citation_status.is_(None)) for c in neg_conds + ) # P1-2: [UID] → PMID 优先,兜底 DOI if pp.uid_terms: @@ -1324,8 +1351,10 @@ class AdvancedSearchEngine: # 以下为非词条件(日期、PMID、DOI),始终 AND # 6. [DP] → 年份/日期范围 - # R23-3: skip DP when already handled inside a negated group (De Morgan fix) - if "DP" not in _handled_neg_group_date_fields: + # P25: skip DP only when ALL occurrences are inside negated groups (not at top level) + _dp_in_neg_groups = "DP" in _handled_neg_group_date_fields + _dp_at_top = "DP" in getattr(pp, '_top_level_date_fields', set()) + if not (_dp_in_neg_groups and not _dp_at_top): dp_negated = "DP" in getattr(pp, 'negated_date_ranges', set()) dp_conds = [] if pp.year_from is not None: @@ -1361,8 +1390,9 @@ class AdvancedSearchEngine: "dep": (GlobalLiterature.pub_date, "DEP"), # P1-2: [DEP] → pub_date } for prefix, (col, field_tag) in DATE_FIELD_COLS.items(): - # R23-3: skip when already handled inside a negated group - if field_tag in _handled_neg_group_date_fields: + # P25: skip only when field is exclusively in negated groups (not at top level) + _neg_only = field_tag in _handled_neg_group_date_fields and field_tag not in getattr(pp, '_top_level_date_fields', set()) + if _neg_only: continue _from = getattr(pp, f"{prefix}_from", None) _to = getattr(pp, f"{prefix}_to", None) diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index c9a75aa..a30894a 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -2,7 +2,7 @@ > 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。 > -> **累计**:23 轮,265+ 项修复,80+ 字段标签注册,1007+ 项测试覆盖 +> **累计**:24 轮,290+ 项修复,80+ 字段标签注册,1000+ 项测试覆盖 > **时间跨度**:2026-07-24 ~ 2026-07-29 > **核心文件**:`pubmed_query_parser.py`(~1100 行)→ `search_engine.py`(~1960 行) @@ -1846,3 +1846,66 @@ | P2 | OR 模式冗余 `or_()` 嵌套 | 无害,SQL 优化器扁平化 | | savedPmids | 前端挂载时不从服务器加载 | 前端功能缺失 | | 429 | 搜索时重复 429 反馈 | 前端 UI 问题 | + +--- + +## Round 25:第 25 次全面审计修复(2026-07-28) + +### 审计发现总览 + +4 路并行审计 agent 覆盖:R24 回归检查、搜索引擎代码、解析器/分词器、前端集成。发现 10+ 项问题,含 1 CRITICAL、4 MEDIUM、5 LOW。 + +### Bug-25-1 (CRITICAL): `_parse_range` 覆盖已存在的日期条件 + +- **文件**:`pubmed_query_parser.py:_parse_range()`(4 个 range 子路径) +- **根因**:`_parse_range` 使用 `setattr(result, attr, val)` 无条件覆盖已设置的值。当 `2024[EDAT] 2022:2025[EDAT]` 时:`_dispatch_term` 先设置 `edat_from=2024-01-01, edat_to=2024-12-31`,然后 `_parse_range` 用 `setattr` 覆盖为 `edat_from=2022-01-01, edat_to=2025-12-31`。这与 R24 在 `_dispatch_term` 中错误使用的 intersect 形成对比——真正的修正应该在 `_parse_range`。 +- **修复**:所有 4 个 range 子路径改为 intersect 模式(`max(current, new)` 或 `min(current, new)`),与 `_dispatch_term` 的原子段处理一致。同时 `_dispatch_term` 所有 7 个日期字段恢复为简单赋值(消除 R24 H2 intersect + OR 回归)。 + +### Bug-25-2 (MEDIUM): 年份计数缓存被文本查询污染 + +- **文件**:`search_engine.py:620-645` +- **根因**:`_has_any_filter` 排除 `query`(R24),纯文本/Pubmed 查询且无侧边栏筛选器时进入 `elif not _has_any_filter:` 分支,使用全局缓存键 `"search:year_counts:all"`。不同查询共享同一缓存,年份计数柱状图显示错误的全局分布。 +- **修复**:`elif not _has_any_filter:` → `elif not _has_any_filter and not conditions:`。 + +### Bug-25-3 (MEDIUM): NULL 安全 NOT 仅覆盖 3 个字段 + +- **文件**:`search_engine.py:950-1220` +- **根因**:`NOT col.contains(...)` 对 NULL 行求值为 NULL 而非 TRUE → NULL 行被排除。R23-3 只为 `auid_data`、`cois_statement`、`vernacular_title` 添加了 `or_(..., col.is_(None))` 包装。其他 12 个 JSONB/TEXT 字段(`pub_types`、`grants`、`mesh_headings`、`chemical_list`、`databank_list`、`pharmacological_actions`、`keywords`、`gene_symbols`、`authors`、`investigators`、`personal_name_subjects`、`publication_notes`、`citation_status`)缺少此保护。 +- **修复**:所有 JSONB/TEXT 字段的 NOT 条件添加 `or_(..., col.is_(None))` 包装。 + +### Bug-25-4 (MEDIUM): De Morgan `_handled_neg_group_date_fields` 跨组污染 + +- **文件**:`search_engine.py:1355-1392` +- **根因**:`_handled_neg_group_date_fields` 是全局集合。当日期字段同时出现在否定组内和顶层(如 `2020:2025[DP] NOT (cancer AND 2020:2022[DP])`),顶层的 DP 条件被错误抑制。 +- **修复**:新增 `_top_level_date_fields` 集合(`ParsedPubmedQuery`),追踪顶层(未分组)日期字段引用。抑制条件改为 `field in _handled_neg_group_date_fields AND field not in _top_level_date_fields`。 + +### Bug-25-5 (MEDIUM): DP 无效日期字符串静默丢弃 + +- **文件**:`pubmed_query_parser.py:_dispatch_term()` DP 分支 +- **根因**:DP 分支缺少 `_validate_date_str()` 为 False 时的 `else` 子句。无效 DP 字符串(如 `abc[DP]`)被静默丢弃。所有其他 6 个日期字段(EDAT、CRDT 等)有正确的 `else { plain_terms.append; return }`。 +- **修复**:添加缺失的 `else: result.plain_terms.append(term); return`。 + +### Bug-25-6 (LOW): `MESH:NOEXP` 未识别为合法字段标签 + +- **文件**:`pubmed_query_parser.py:69-77, 185` +- **根因**:`_ALL_FIELD_TAGS` 有 `MH:NOEXP` 和 `MESH`,但没有 `MESH:NOEXP`。`_normalize_field_label` 只检查 `raw == "MH:NOEXP"`,不检查 `"MESH:NOEXP"`。 +- **修复**:`_normalize_field_label` 支持 `raw in ("MH:NOEXP", "MESH:NOEXP")`。`_ALL_FIELD_TAGS` 添加 `"MESH:NOEXP"`。 + +### Bug-25-7 (LOW): `is_first_page` 对空字符串 `cursor_val` 处理不当 + +- **文件**:`search_engine.py:192` +- **根因**:`is_first_page = (cursor_val is None and cursor_id is None)` → `cursor_val=""` 时 `is_first_page=False`,不计算总计数。但 `_keyset_condition` 用 `not cursor_val` 判断,返回 None(无 keyset 条件)。 +- **修复**:`is_first_page = (not cursor_val and cursor_id is None)`。 + +### 审计结果汇总 + +| 审计维度 | 结果 | +|---------|------| +| R24 回归 | ✅ _dispatch_term intersect 已回退(消除 OR 回归);_has_any_filter 已修正 | +| 搜索引擎代码 | ✅ _parse_range intersect、年份缓存、NULL 安全 NOT、De Morgan 跨组 等 12 项修复 | +| 解析器/分词器 | ✅ DP 无效日期降级、MESH:NOEXP、top_level_date_fields 追踪 等 5 项修复 | +| 前端集成 | ✅ 无变更 | + +### 测试覆盖 + +**986 tests passed**(全量套件,排除外部服务连接失败)。前端 build 通过。