fix: 第26轮搜索审计修复 — 部分日期/否定日期分离/混合模式NOT/7项bug
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

This commit is contained in:
34047007@qq.com
2026-07-28 20:14:05 +08:00
parent 8dd1779676
commit af764237fe
3 changed files with 355 additions and 117 deletions
+189 -110
View File
@@ -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)
+61 -7
View File
@@ -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 项应独立 ANDPubMed: 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)
+105
View File
@@ -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 | 否定组内冗余日期条件(单个年份 + 全范围) |