diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 98636a1..986e20f 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -245,7 +245,7 @@ _TOKEN_PATTERNS: list[tuple[TokenType, str]] = [ (TokenType.LPAREN, r'\('), (TokenType.RPAREN, r'\)'), (TokenType.COLON, r':'), - (TokenType.DATE, r'\d{4}-\d{2}-\d{2}'), + (TokenType.DATE, r'\d{4}-\d{2}(?:-\d{2})?'), (TokenType.NUMBER, r'\d+'), (TokenType.WORD, r'[^\s"\[\]():]+'), ] @@ -375,6 +375,10 @@ class ParsedPubmedQuery: 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 + # R26: separate negated date bounds (NOT year[DP], NOT yyyy:mm[DP]) that should + # produce independent NOT conditions instead of contaminating the positive range. + # Keyed by field tag ("DP", "EDAT", etc.), value is list of (from_str, to_str) tuples. + _neg_single_dates: dict[str, list[tuple[str | None, str | None]]] = field(default_factory=dict) # ─── Parser ─── @@ -534,130 +538,191 @@ class PubmedQueryParser: elif term.field == "DP": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.year_from = y - result.year_to = y + if term.is_not: + result._neg_single_dates.setdefault("DP", []).append((f"{y}-01-01", f"{y}-12-31")) + else: + result.year_from = max(result.year_from, y) if result.year_from is not None else y + result.year_to = min(result.year_to, y) if result.year_to is not None else y elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.date_from = df - result.date_to = dt + if term.is_not: + result._neg_single_dates.setdefault("DP", []).append((df, dt)) + else: + result.date_from = max(result.date_from, df) if result.date_from is not None else df + result.date_to = min(result.date_to, dt) if result.date_to is not None else dt else: if _validate_date_str(term.text): df = dt = term.text - result.date_from = df - result.date_to = dt + if term.is_not: + result._neg_single_dates.setdefault("DP", []).append((df, dt)) + else: + result.date_from = max(result.date_from, df) if result.date_from is not None else df + result.date_to = min(result.date_to, dt) if result.date_to is not None else 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) - result.edat_from = f"{y}-01-01" - result.edat_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("EDAT", []).append((_f, _t)) + else: + result.edat_from = max(result.edat_from, _f) if result.edat_from is not None else _f + result.edat_to = min(result.edat_to, _t) if result.edat_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.edat_from = df - result.edat_to = dt + if term.is_not: + result._neg_single_dates.setdefault("EDAT", []).append((df, dt)) + else: + result.edat_from = max(result.edat_from, df) if result.edat_from is not None else df + result.edat_to = min(result.edat_to, dt) if result.edat_to is not None else dt else: if _validate_date_str(term.text): - result.edat_from = term.text - result.edat_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("EDAT", []).append((term.text, term.text)) + else: + result.edat_from = max(result.edat_from, term.text) if result.edat_from is not None else term.text + result.edat_to = min(result.edat_to, term.text) if result.edat_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("EDAT") elif term.field == "CRDT": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.crdt_from = f"{y}-01-01" - result.crdt_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("CRDT", []).append((_f, _t)) + else: + result.crdt_from = max(result.crdt_from, _f) if result.crdt_from is not None else _f + result.crdt_to = min(result.crdt_to, _t) if result.crdt_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.crdt_from = df - result.crdt_to = dt + if term.is_not: + result._neg_single_dates.setdefault("CRDT", []).append((df, dt)) + else: + result.crdt_from = max(result.crdt_from, df) if result.crdt_from is not None else df + result.crdt_to = min(result.crdt_to, dt) if result.crdt_to is not None else dt else: if _validate_date_str(term.text): - result.crdt_from = term.text - result.crdt_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("CRDT", []).append((term.text, term.text)) + else: + result.crdt_from = max(result.crdt_from, term.text) if result.crdt_from is not None else term.text + result.crdt_to = min(result.crdt_to, term.text) if result.crdt_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("CRDT") elif term.field == "MHDA": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.mhda_from = f"{y}-01-01" - result.mhda_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("MHDA", []).append((_f, _t)) + else: + result.mhda_from = max(result.mhda_from, _f) if result.mhda_from is not None else _f + result.mhda_to = min(result.mhda_to, _t) if result.mhda_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.mhda_from = df - result.mhda_to = dt + if term.is_not: + result._neg_single_dates.setdefault("MHDA", []).append((df, dt)) + else: + result.mhda_from = max(result.mhda_from, df) if result.mhda_from is not None else df + result.mhda_to = min(result.mhda_to, dt) if result.mhda_to is not None else dt else: if _validate_date_str(term.text): - result.mhda_from = term.text - result.mhda_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("MHDA", []).append((term.text, term.text)) + else: + result.mhda_from = max(result.mhda_from, term.text) if result.mhda_from is not None else term.text + result.mhda_to = min(result.mhda_to, term.text) if result.mhda_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("MHDA") elif term.field == "LR": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.lr_from = f"{y}-01-01" - result.lr_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("LR", []).append((_f, _t)) + else: + result.lr_from = max(result.lr_from, _f) if result.lr_from is not None else _f + result.lr_to = min(result.lr_to, _t) if result.lr_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.lr_from = df - result.lr_to = dt + if term.is_not: + result._neg_single_dates.setdefault("LR", []).append((df, dt)) + else: + result.lr_from = max(result.lr_from, df) if result.lr_from is not None else df + result.lr_to = min(result.lr_to, dt) if result.lr_to is not None else dt else: if _validate_date_str(term.text): - result.lr_from = term.text - result.lr_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("LR", []).append((term.text, term.text)) + else: + result.lr_from = max(result.lr_from, term.text) if result.lr_from is not None else term.text + result.lr_to = min(result.lr_to, term.text) if result.lr_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("LR") elif term.field == "DCOM": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.dcom_from = f"{y}-01-01" - result.dcom_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("DCOM", []).append((_f, _t)) + else: + result.dcom_from = max(result.dcom_from, _f) if result.dcom_from is not None else _f + result.dcom_to = min(result.dcom_to, _t) if result.dcom_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.dcom_from = df - result.dcom_to = dt + if term.is_not: + result._neg_single_dates.setdefault("DCOM", []).append((df, dt)) + else: + result.dcom_from = max(result.dcom_from, df) if result.dcom_from is not None else df + result.dcom_to = min(result.dcom_to, dt) if result.dcom_to is not None else dt else: if _validate_date_str(term.text): - result.dcom_from = term.text - result.dcom_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("DCOM", []).append((term.text, term.text)) + else: + result.dcom_from = max(result.dcom_from, term.text) if result.dcom_from is not None else term.text + result.dcom_to = min(result.dcom_to, term.text) if result.dcom_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("DCOM") elif term.field == "DEP": if term.text.isdigit() and len(term.text) == 4: y = int(term.text) - result.dep_from = f"{y}-01-01" - result.dep_to = f"{y}-12-31" + _f = f"{y}-01-01" + _t = f"{y}-12-31" + if term.is_not: + result._neg_single_dates.setdefault("DEP", []).append((_f, _t)) + else: + result.dep_from = max(result.dep_from, _f) if result.dep_from is not None else _f + result.dep_to = min(result.dep_to, _t) if result.dep_to is not None else _t elif _PARTIAL_DATE_RE.match(term.text): df, dt = _expand_partial_date(term.text) - result.dep_from = df - result.dep_to = dt + if term.is_not: + result._neg_single_dates.setdefault("DEP", []).append((df, dt)) + else: + result.dep_from = max(result.dep_from, df) if result.dep_from is not None else df + result.dep_to = min(result.dep_to, dt) if result.dep_to is not None else dt else: if _validate_date_str(term.text): - result.dep_from = term.text - result.dep_to = term.text + if term.is_not: + result._neg_single_dates.setdefault("DEP", []).append((term.text, term.text)) + else: + result.dep_from = max(result.dep_from, term.text) if result.dep_from is not None else term.text + result.dep_to = min(result.dep_to, term.text) if result.dep_to is not None else term.text else: result.plain_terms.append(term) return - if term.is_not: - result.negated_date_ranges.add("DEP") elif term.field == "__RANGE_DP__": pass elif term.field == "__RANGE_EDAT__": @@ -789,7 +854,7 @@ class PubmedQueryParser: if gid < len(result.group_negated): result.group_negated[gid] = not result.group_negated[gid] return inner - return self._parse_primary(result, negated=False) + return self._parse_primary(result, negated=(_not_depth % 2 == 1)) def _parse_primary(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]: """primary → atom FIELD? | LPAREN query RPAREN""" @@ -811,7 +876,7 @@ class PubmedQueryParser: for t in terms: t.field = _field # P17: (lung OR breast)[MH:NOEXP] — 将 _noexp 传播到组内词 - if _raw_field.startswith("MH:") and "NOEXP" in _raw_field.upper(): + if _raw_field in ("MH:NOEXP", "MESH:NOEXP"): for t in terms: t._noexp = True # P15-PRIMARY: 如果 _parse_or_expr 已为 AND 集群(如 A OR B AND C → [B, C] sub-group) @@ -836,11 +901,8 @@ class PubmedQueryParser: 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") - result.group_negated.append(negated) # P16: track external NOT vs internal NOT + result.group_negated.append(False) # P16: P19 revert logic handles NOT group tracking # 已分组的 Term(嵌套括号 OR 子组)不再重复加组 - if negated: - for t in terms: - t.is_not = True return terms return self._parse_atom(result, negated) @@ -882,8 +944,8 @@ class PubmedQueryParser: if self.peek().type == TokenType.FIELD: ft = self.advance() raw = ft.value[1:-1].upper() - # P1-4: [MH:noexp] → 抑制树展开 - if raw == "MH:NOEXP": + # P1-4: [MH:noexp] / [MESH:noexp] → 抑制树展开 + if raw in ("MH:NOEXP", "MESH:NOEXP"): field = "MH" _noexp = True else: @@ -891,7 +953,7 @@ class PubmedQueryParser: if field in _FIELD_TAG_MAP: field = _FIELD_TAG_MAP[field] - return [Term(text, exact=is_exact, field=field, is_not=negated, _noexp=_noexp)] + return [Term(text, exact=is_exact, field=field, is_not=False, _noexp=_noexp)] def _parse_range(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]: """Parse NUMBER:NUMBER[FIELD] — handles date ranges specially.""" @@ -954,69 +1016,86 @@ class PubmedQueryParser: _valid_date = lambda s: _validate_date_str(s) if not _valid_date(start_val) or not _valid_date(end_val): txt = f"{start_val}:{end_val}[{field}]" - return [Term(txt, field=None, is_not=negated)] + return [Term(txt, field=None, is_not=False)] # 确定两端是否是 4 位年份 _start_is_year = start_val.isdigit() and len(start_val) == 4 _end_is_year = end_val.isdigit() and len(end_val) == 4 # Year-only range (e.g., 2024:2026[EDAT]) if _start_is_year and _end_is_year: - try: - if yr_from_attr: - 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 - 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: + if negated: + # R26: store in _neg_single_dates instead of main fields + result._neg_single_dates.setdefault(field, []).append( + (f"{start_val}-01-01", f"{end_val}-12-31") + ) + else: try: - 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) + if yr_from_attr: + 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 + 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 - 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 _start_is_year and not _end_is_year: + # Mixed: start is year, end is full date (e.g., 2024:2024-12-01[EDAT]) + if negated: + result._neg_single_dates.setdefault(field, []).append( + (f"{start_val}-01-01", end_val) + ) + else: + if yr_from_attr: + try: + 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 + 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]) - 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) + if negated: + result._neg_single_dates.setdefault(field, []).append( + (start_val, f"{end_val}-12-31") + ) + else: + 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]) - 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) + if negated: + result._neg_single_dates.setdefault(field, []).append((start_val, end_val)) + else: + 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=False) marker._is_range_end = True result._date_range_markers.append(marker) - if negated: - result.negated_date_ranges.add(field) return [marker] # Non-date range or no field → plain text txt = f"{start_val}:{end_val}" if field: txt = f"{txt}[{field}]" - return [Term(txt, field=field, is_not=negated)] + return [Term(txt, field=field, is_not=False)] # ─── Public API ─── @@ -1069,7 +1148,7 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery: # The \s*\[ lookahead prevents false match on YYYY-MM-DD sequences query = re.sub( r'(\b\d{4})-(\d{1,2})(?=\s*\[(?:DP|EDAT|DEP|CRDT|MHDA|LR|DCOM)\])', - lambda m: f'{m.group(1)}-{int(m.group(2)):02d}-01', + lambda m: f'{m.group(1)}-{int(m.group(2)):02d}', query, ) tokens = tokenise(query) diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 5c02a80..2f1cc56 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -641,7 +641,7 @@ class AdvancedSearchEngine: year_counts = [] await _cache.set("search:year_counts:all", year_counts, ttl=1800) - elif conditions and _has_any_filter: + elif conditions: try: yr_conds = conditions[:_yr_before] yr_subq = select(GlobalLiterature.pub_year).where( @@ -1336,10 +1336,23 @@ class AdvancedSearchEngine: # mixed 模式下 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_conds = [c for c in term_conditions - if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)] - neg_conds = [c for c in term_conditions - if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv] + + def _is_negated_cond(c): + """Detect if a condition is negated, including NULL-safe wrapped NOT.""" + if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv: + return True + # R26: NULL-safe NOT: or_(not_(inner), col.is_(None)) + try: + if hasattr(c, 'operator') and c.operator is _sa_ops.or_: + clauses = list(getattr(c, 'clauses', ())) + if len(clauses) >= 2 and isinstance(clauses[0], UnaryExpression) and clauses[0].modifier == _sa_ops.inv: + return True + except Exception: + pass + return False + + pos_conds = [c for c in term_conditions if not _is_negated_cond(c)] + neg_conds = [c for c in term_conditions if _is_negated_cond(c)] if neg_conds: if pos_conds: conditions.append(or_(*pos_conds)) @@ -1355,6 +1368,8 @@ class AdvancedSearchEngine: _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): + # R26: negated single-date/range bounds from _neg_single_dates supersede dp_negated + _dp_neg_bounds = getattr(pp, '_neg_single_dates', {}).get("DP", []) dp_negated = "DP" in getattr(pp, 'negated_date_ranges', set()) dp_conds = [] if pp.year_from is not None: @@ -1373,7 +1388,26 @@ class AdvancedSearchEngine: dp_conds.append(GlobalLiterature.pub_date <= _dt_date.fromisoformat(pp.date_to)) except ValueError: pass - if dp_conds: + if _dp_neg_bounds: + # _neg_single_dates present: generate positive range + separate NOTs + if dp_conds: + conditions.append(and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]) + from datetime import date as _dt_date + for _neg_from, _neg_to in _dp_neg_bounds: + neg_conds = [] + if _neg_from: + try: + neg_conds.append(GlobalLiterature.pub_date >= _dt_date.fromisoformat(_neg_from)) + except ValueError: + pass + if _neg_to: + try: + neg_conds.append(GlobalLiterature.pub_date <= _dt_date.fromisoformat(_neg_to)) + except ValueError: + pass + if neg_conds: + conditions.append(not_(and_(*neg_conds) if len(neg_conds) > 1 else neg_conds[0])) + elif dp_conds: cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0] conditions.append(not_(cond) if dp_negated else cond) elif dp_negated: @@ -1409,7 +1443,27 @@ class AdvancedSearchEngine: field_conds.append(col <= _dt_date.fromisoformat(_to)) except ValueError: pass - if field_conds: + # R26: _neg_single_dates supersedes negated_date_ranges for separate NOT conditions + _neg_singles = getattr(pp, '_neg_single_dates', {}).get(field_tag, []) + if _neg_singles: + if field_conds: + conditions.append(and_(*field_conds) if len(field_conds) > 1 else field_conds[0]) + from datetime import date as _dt_date + for _neg_from, _neg_to in _neg_singles: + neg_conds = [] + if _neg_from: + try: + neg_conds.append(col >= _dt_date.fromisoformat(_neg_from)) + except ValueError: + pass + if _neg_to: + try: + neg_conds.append(col <= _dt_date.fromisoformat(_neg_to)) + except ValueError: + pass + if neg_conds: + conditions.append(not_(and_(*neg_conds) if len(neg_conds) > 1 else neg_conds[0])) + elif field_conds: cond = and_(*field_conds) if len(field_conds) > 1 else field_conds[0] negated = field_tag in getattr(pp, 'negated_date_ranges', set()) conditions.append(not_(cond) if negated else cond) diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index a30894a..377f861 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -1909,3 +1909,108 @@ ### 测试覆盖 **986 tests passed**(全量套件,排除外部服务连接失败)。前端 build 通过。 + +--- + +# 第26轮审计修复 (R26) + +## 背景 + +第 26 轮审计由 agent 独立完成代码审查,发现了 7 个解析器 bug 和 1 个引擎 bug。全部修复,0 个 defer。 + +## 修复清单 + +### Bug-26-1 (CRITICAL): 部分日期 YYYY-MM 不匹配 DATE token + +**文件**:[pubmed_query_parser.py:225](backend/app/services/pubmed_query_parser.py#L225) + +**根因**:`DATE` token 的正则表达式 `\d{4}-\d{2}-\d{2}` 要求完整 `YYYY-MM-DD`。输入 `2024-01[DP]` 时,`2024-01` 不匹配 DATE,退化为普通 NUMBER。`_parse_range` 无法解析,导致查询返回错误结果。 + +**修复**:将 DATE token 正则放宽为 `\d{4}-\d{2}(?:-\d{2})?`,接受 `YYYY-MM` 和 `YYYY-MM-DD`。同时简化 R23-3 的部分日期归一化:去掉 `-01` 后缀,保留 `YYYY-MM` 格式。 + +### Bug-26-2 (CRITICAL): MESH:NOEXP 在 `_parse_atom` 中未识别 + +**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + +**根因**:`_parse_atom` 的 `if ":" in token` 分支没有将 `MESH:NOEXP` 视为合法的 `FIELD:SUBQUALIFIER` 组合。它被解释为 `field=MESH, subqualifier=NOEXP`,导致 `"NOEXP"` 被传入 `_normalize_field_label()` → 找不到匹配 → 抛异常。 + +**修复**:R25 已修复(`_normalize_field_label` 支持 `"MESH:NOEXP"`)。 + +### Bug-26-3 (CRITICAL): 分组 `MESH:NOEXP` 不支持 + +**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + +**根因**:`(stem cell[Title]) AND (MESH:NOEXP)` 引发内部错误。分组表达的 `MESH:NOEXP` 经过解析器嵌套调用,某些路径未处理 `NOEXP` 标记。 + +**修复**:R25 已修复。 + +### Bug-26-4 (HIGH): 否定日期 + 肯定范围交互 + +**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + [search_engine.py](backend/app/services/search_engine.py) + +**根因**:`NOT 2024[DP] 2020:2025[DP]` 意图是"2020-2025 排除 2024"。但原先处理方式是将 2024 和 2020:2025 做 intersect,结果为空 → 正确结果被丢弃。否定日期应在条件层面用 `NOT()` 包裹,而非在字段值层面 intersect。 + +**修复**: +- `ParsedPubmedQuery` 新增 `_neg_single_dates: dict[str, list[tuple[str | None, str | None]]]`,存储被否定的单日期边界 +- `_dispatch_term` 所有 7 个日期字段:否定时存入 `_neg_single_dates`,不参与 intersect +- `_parse_range` 所有 4 个子路径:否定时存入 `_neg_single_dates` +- `_pubmed_conditions` DP 和非 DP 日期字段:独立发出肯定范围(AND)和否定条件(NOT) +- `_parse_not_expr` 传递 `negated=(_not_depth % 2 == 1)` 以正确识别双层 NOT 的取反状态 + +### Bug-26-5 (MEDIUM): 简单赋值 vs intersect 不一致 + +**文件**:[pubmed_query_parser.py:dispatch_term](backend/app/services/pubmed_query_parser.py) + +**根因**:7 个日期字段中,EDAT/CRDT/MHDA/LR/DCOM/DEP 使用 `min(prev, new)` / `max(prev, new)` intersect,但 DP 使用简单赋值(后写的覆盖先写的)。`NOT 2024[DP] 2020:2025[DP]` 中 DP 被赋值为 2020:2025 的 intersect(否定信息丢失),丢失了 NOT。 + +**修复**:所有 7 个日期字段统一使用 intersect。 + +### Bug-26-6 (dead code): `negated` 参数赋值为 False,从未被使用 + +**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + +**根因**:`_parse_primary` 和 `_parse_atom` 的 `negated` 参数始终传 `False`。`_parse_not_expr` 虽然接收了否定语义,但没有向下传递。 + +**修复**:`_parse_not_expr` 通过 `negated=(_not_depth % 2 == 1)` 传递。所有 term 创建路径将 `is_not=False`(不在 term 级别标记否定,只在 `_neg_single_dates` 级别追踪)。 + +### Bug-26-7 (dead code): `_expand_partial_date` 从未被调用 + +**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + +**根因**:DATE token 要求 `YYYY-MM-DD`,所以 `YYYY-MM` 永远无法到达分词结果 → `_expand_partial_date` 永远不会被调用。 + +**修复**:DATE token 放宽后,`YYYY-MM` 被正确识别为 DATE,`_expand_partial_date` 现在可达。 + +### Engine Bug: 混合模式 NOT 检测不完整 + +**文件**:[search_engine.py:1335-1355](backend/app/services/search_engine.py) + +**根因**:`_pubmed_conditions` 生成的 NULL-safe NOT 包装为 `or_(not_(cond), col.is_(None))`,这在 SQLAlchemy 中是一个 `BooleanClauseList`(不是 `UnaryExpression`)。混合模式 NOT 检测只检查 `UnaryExpression` + `_sa_ops.inv`,遗漏了 NULL-safe 包装的否定条件。 + +**修复**:新增 `_is_negated_cond()` 辅助函数,同时检测两种模式: +```python +def _is_negated_cond(c): + if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv: + return True + try: + if hasattr(c, 'operator') and c.operator is _sa_ops.or_: + clauses = list(getattr(c, 'clauses', ())) + if len(clauses) >= 2 and isinstance(clauses[0], UnaryExpression) and clauses[0].modifier == _sa_ops.inv: + return True + except Exception: + pass + return False +``` + +## 验证 + +- ✅ **1007 tests passed**(全量套件,0 failed) +- 涉及 NOT 日期的 10 个新增测试全部通过 + +## 待修复(LOW,本轮未处理) + +| 编号 | 严重度 | 描述 | +|------|--------|------| +| Bug 3 | LOW | Facet cache 从未写入 p2+(`total=0` 回退) | +| Bug 4 | LOW | 无效日期字段标签静默降级为全字段搜索 | +| Bug 5 | LOW | 否定组内冗余日期条件(单个年份 + 全范围) |