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]
+32 -16
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)
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(cond)
if neg_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, neg_names, major_only=False)
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))
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
@@ -12,7 +12,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
search: [params: { query: string; field: string; date_from: string | null; date_to: string | null; journal_tiers: string[]; tag_ids: string[]; retracted: string; negative_result: string; precision_mode: string; sort: string }]
search: [params: { query: string; field: string; date_from: string | null; date_to: string | null; journal_tiers: string[]; tag_ids: string[]; retracted: string; negative_result: string; sort: string }]
}>()
const toast = useToast()
@@ -23,7 +23,6 @@ const selectedTiers = ref<string[]>([])
const selectedTags = ref<string[]>([])
const retracted = ref('')
const negativeResult = ref('')
const precisionMode = ref('majr')
const sort = ref('date')
const field = ref('all')
const fieldOptions = [
@@ -71,7 +70,6 @@ function handleSearch() {
tag_ids: selectedTags.value,
retracted: retracted.value,
negative_result: negativeResult.value,
precision_mode: precisionMode.value,
sort: sort.value,
})
}
@@ -85,7 +83,6 @@ function resetFilters() {
selectedTags.value = []
retracted.value = ''
negativeResult.value = ''
precisionMode.value = 'majr'
sort.value = 'date'
}
@@ -143,14 +140,6 @@ function resetFilters() {
<NRadio value="only" size="small">阴性结果</NRadio>
</NRadioGroup>
</div>
<div>
<label class="filter-label">🎯 精度模式</label>
<NRadioGroup v-model:value="precisionMode" name="precision">
<NRadio value="" size="small">全部</NRadio>
<NRadio value="majr" size="small">🎯 高精度 (Major Topic)</NRadio>
<NRadio value="mesh" size="small">📚 高召回 (MeSH)</NRadio>
</NRadioGroup>
</div>
<div>
<label class="filter-label">📊 排序</label>
<NSelect v-model:value="sort" :options="[
+1 -2
View File
@@ -339,13 +339,12 @@ export interface SearchRequestBody {
journal_tiers?: string[]
tag_ids?: string[]
pub_types?: string[]
is_oa?: boolean | null
is_oa?: boolean | null // unused, reserved for future OA filter UI
language?: string | null
languages?: string[]
nlm_subsets?: string[]
retracted?: string
negative_result?: string
precision_mode?: string
// PubMed 筛选器
has_abstract?: boolean
is_free_full_text?: boolean
+5
View File
@@ -296,8 +296,13 @@ const { page, total, goToPage } = usePagination({
// 年份 / 日期(datePreset 与 year_* 互斥)
const yf = Number(yearFromStr.value)
const yt = Number(yearToStr.value)
if (datePreset.value === 'custom') {
if (yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf
if (yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt
} else {
if (!datePreset.value && yearFromStr.value && !isNaN(yf) && yf >= 1900 && yf <= 2100) body.year_from = yf
if (!datePreset.value && yearToStr.value && !isNaN(yt) && yt >= 1900 && yt <= 2100) body.year_to = yt
}
if (datePreset.value && datePreset.value !== 'custom') {
const now = new Date()
body.date_to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString().slice(0, 10)
+4
View File
@@ -361,6 +361,10 @@ function restoreFromUrl() {
searchParams.value.query = q
localQuery.value = q
}
if (route.query.sort) searchParams.value.sort = route.query.sort as string
if (route.query.field) searchParams.value.field = route.query.field as string
if (route.query.retracted) searchParams.value.retracted = route.query.retracted as string
if (route.query.negative) searchParams.value.negative_result = route.query.negative as string
}
// ── 同步状态到 URL ──