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: