fix: 第26轮搜索审计修复 — 部分日期/否定日期分离/混合模式NOT/7项bug
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 | 否定组内冗余日期条件(单个年份 + 全范围) |
|
||||
|
||||
Reference in New Issue
Block a user