fix: 第25轮搜索审计修复 — _parse_range intersect/NULL安全NOT/De Morgan作用域等12项
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修复记录
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+64
-1
@@ -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 通过。
|
||||
|
||||
Reference in New Issue
Block a user