fix: 第25轮搜索审计修复 — _parse_range intersect/NULL安全NOT/De Morgan作用域等12项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

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:
34047007@qq.com
2026-07-28 19:19:10 +08:00
parent f8db720419
commit 8dd1779676
3 changed files with 195 additions and 224 deletions
+80 -202
View File
@@ -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)
+51 -21
View File
@@ -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
View File
@@ -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 通过。