fix: 第二轮PubMed搜索合规审计 — 8项修复 + 6项新测试
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

F1: search_engine.py `_pubmed_conditions` 顶层 [MH:noexp] 丢失 _noexp 标志
    按 _noexp 分组 mesh_terms,分别调用 _expand_mesh_tag_ids(noexp=True/False)

F2: search_engine.py 组内 NOT 违反 De Morgan 律
    全组 is_not=True 时单 NOT 包裹组合条件而非 per-term not_()

F3: pubmed_query_parser.py `_parse_not_expr` 不支持重复 NOT
    改为递归调用,NOT NOT toggle is_not(三重 NOT 正确)

F4: SearchView.vue Custom Range datePreset 不发送日期参数
    增加 datePreset === 'custom' 分支发送 year_from/year_to

F5: HomeView.vue restoreFromUrl 恢复不全
    补全 sort/field/retracted/negative_result

F6: AdvancedSearchPanel.vue precision_mode 死控件移除(UI+emit+reset)
    types/index.ts precision_mode 死类型字段移除

F7: types/index.ts is_oa 标记为 unused, reserved

F8: search_engine.py `_expand_mesh_tag_ids` N+1 → 2 批量查询
    所有 entry_terms 和 name_en 分别合并为 OR 查询

测试: 新增 NOT NOT/triple NOT/NOT group/DM:noexp 顶层 共 7 项
This commit is contained in:
34047007@qq.com
2026-07-27 10:30:40 +08:00
parent 723c4fc5c9
commit e688241355
7 changed files with 106 additions and 35 deletions
+12 -1
View File
@@ -227,6 +227,7 @@ class ParsedPubmedQuery:
groups: list[list[Term]] = field(default_factory=list) # parenthesized sub-groups
group_operators: list[str] = field(default_factory=list) # "and"/"or" per group (P2-2)
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
# ─── Parser ───
@@ -301,6 +302,12 @@ class PubmedQueryParser:
text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value
result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED)))
# Recompute negated_date_ranges from marker terms (after NOT toggling from recursive _parse_not_expr)
result.negated_date_ranges = {
t.field.replace("__RANGE_", "").replace("__", "")
for t in result._date_range_markers if t.is_not
}
return result
def _dispatch_term(self, result: ParsedPubmedQuery, term: Term) -> None:
@@ -429,7 +436,10 @@ class PubmedQueryParser:
"""not_expr → NOT not_expr | primary"""
if self.peek().type == TokenType.NOT:
self.advance()
return self._parse_primary(result, negated=True)
inner = self._parse_not_expr(result)
for t in inner:
t.is_not = not t.is_not
return inner
return self._parse_primary(result, negated=False)
def _parse_primary(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]:
@@ -541,6 +551,7 @@ class PubmedQueryParser:
setattr(result, date_attr_to, 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)
if negated:
result.negated_date_ranges.add(field)
return [marker]
+34 -18
View File
@@ -636,18 +636,22 @@ class AdvancedSearchEngine:
else:
term_conditions.append(and_(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
# 3. [MH] → tree_number 展开,支持 is_not
# 3. [MH] → tree_number 展开,支持 is_not 和 _noexp
if pp.mesh_terms:
pos_names = [t.text for t in pp.mesh_terms if not t.is_not]
neg_names = [t.text for t in pp.mesh_terms if t.is_not]
if pos_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, pos_names, major_only=False)
if cond is not None:
term_conditions.append(cond)
if neg_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, neg_names, major_only=False)
if cond is not None:
term_conditions.append(not_(cond))
for is_neg in (False, True):
subset = [t for t in pp.mesh_terms if t.is_not == is_neg]
if not subset:
continue
noexp_names = [t.text for t in subset if t._noexp]
exp_names = [t.text for t in subset if not t._noexp]
if exp_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, exp_names, major_only=False)
if cond is not None:
term_conditions.append(not_(cond) if is_neg else cond)
if noexp_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, noexp_names, major_only=False, noexp=True)
if cond is not None:
term_conditions.append(not_(cond) if is_neg else cond)
# 4. [MAJR] → tree_number 展开 + is_major=True,支持 is_not
if pp.majr_terms:
@@ -862,19 +866,22 @@ class AdvancedSearchEngine:
if pp.groups:
for idx, group in enumerate(pp.groups):
group_conds = []
all_not = all(t.is_not for t in group)
for t in group:
cond = await AdvancedSearchEngine._single_term_condition(db, t)
if cond is not None:
if t.is_not:
if not all_not and t.is_not:
cond = not_(cond)
group_conds.append(cond)
if group_conds:
# P2-2: 使用组内保留的布尔运算符
gop = (pp.group_operators[idx]
if idx < len(pp.group_operators)
else "and")
combine_fn = or_ if gop == "or" else and_
term_conditions.append(combine_fn(*group_conds) if len(group_conds) > 1 else group_conds[0])
combined = combine_fn(*group_conds) if len(group_conds) > 1 else group_conds[0]
if all_not and len(group_conds) > 1:
combined = not_(combined)
term_conditions.append(combined)
# 将 term_conditions 加入 conditions
if term_conditions:
@@ -1116,27 +1123,36 @@ class AdvancedSearchEngine:
import uuid as _uuid
mesh_tag_ids: set[_uuid.UUID] = set()
# Batch all mesh name lookups — 2 queries instead of 2N
entry_conds = []
name_conds = []
for m in mesh_names:
q = m.strip().lower()
if not q:
continue
# 1a. 精确入口词匹配(P1-3
# 1a. 精确入口词匹配(P1-3— batch via OR
entry_conds.append(GlobalTag.entry_terms.contains([q]))
# 1b. name_en ILIKE 回退 — batch via OR
name_conds.append(GlobalTag.name_en.ilike(m))
if entry_conds:
rows = (await db.execute(
select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.mesh_ui.isnot(None),
GlobalTag.entry_terms.isnot(None),
GlobalTag.entry_terms.contains([q]),
or_(*entry_conds),
)
)).all()
for (tid,) in rows:
mesh_tag_ids.add(tid)
# 1b. name_en ILIKE 回退
if name_conds:
rows = (await db.execute(
select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.mesh_ui.isnot(None),
GlobalTag.name_en.ilike(m),
or_(*name_conds),
)
)).all()
for (tid,) in rows:
@@ -319,3 +319,50 @@ class TestNegatedTerms:
jones = [t for t in r.author_terms if t.text == "Jones"]
assert jones[0].is_not is True
assert r.has_not is True
def test_not_not_cancels(self):
"""NOT NOT cancer — double negation cancels out"""
r = parse_pubmed_query("NOT NOT cancer")
# cancer should be positive (NOT NOT X = X)
plain = [t for t in r.plain_terms if t.text == "cancer"]
assert len(plain) == 1
assert plain[0].is_not is False
# no literal "NOT" as search term
not_terms = [t for t in r.plain_terms if t.text.upper() == "NOT"]
assert len(not_terms) == 0, f"Second NOT should not be a literal term: {r.plain_terms}"
def test_not_not_date_range(self):
"""NOT NOT 2024:2025[DP] — double negation on date range"""
r = parse_pubmed_query("NOT NOT 2024:2025[DP]")
# After double NOT, the date range should NOT be negated
assert "DP" not in r.negated_date_ranges, f"negated_date_ranges={r.negated_date_ranges}"
def test_mh_noexp_top_level(self):
"""asthma[MH:noexp] at top level should preserve _noexp flag"""
r = parse_pubmed_query("asthma[MH:noexp]")
assert len(r.mesh_terms) == 1
assert r.mesh_terms[0]._noexp is True
assert r.mesh_terms[0].field == "MH"
def test_not_group_and(self):
"""NOT (cancer AND tumor) — group NOT should set all terms is_not"""
r = parse_pubmed_query("NOT (cancer AND tumor)")
assert len(r.groups) >= 1
for group in r.groups:
for term in group:
assert term.is_not is True, f"All terms in negated group should be NOT: {group}"
def test_not_group_or(self):
"""NOT (cancer OR tumor) — group NOT with OR"""
r = parse_pubmed_query("NOT (cancer OR tumor)")
assert len(r.groups) >= 1
for group in r.groups:
for term in group:
assert term.is_not is True
def test_triple_not(self):
"""NOT NOT NOT cancer = NOT cancer"""
r = parse_pubmed_query("NOT NOT NOT cancer")
plain = [t for t in r.plain_terms if t.text == "cancer"]
assert len(plain) == 1
assert plain[0].is_not is True # odd count = negated