From e688241355a172056db2ee0f161d32422ed222a8 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Mon, 27 Jul 2026 10:30:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC=E4=BA=8C=E8=BD=AEPubMed?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=90=88=E8=A7=84=E5=AE=A1=E8=AE=A1=20?= =?UTF-8?q?=E2=80=94=208=E9=A1=B9=E4=BF=AE=E5=A4=8D=20+=206=E9=A1=B9?= =?UTF-8?q?=E6=96=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 项 --- backend/app/services/pubmed_query_parser.py | 13 ++++- backend/app/services/search_engine.py | 52 ++++++++++++------- backend/tests/test_comprehensive_verify.py | 47 +++++++++++++++++ .../components/search/AdvancedSearchPanel.vue | 13 +---- frontend/src/types/index.ts | 3 +- frontend/src/views/app/SearchView.vue | 9 +++- frontend/src/views/public/HomeView.vue | 4 ++ 7 files changed, 106 insertions(+), 35 deletions(-) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index ca62b0b..bf85b3c 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -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] diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 390ea8b..156d6b0 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -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: diff --git a/backend/tests/test_comprehensive_verify.py b/backend/tests/test_comprehensive_verify.py index e2222c8..e6a147b 100644 --- a/backend/tests/test_comprehensive_verify.py +++ b/backend/tests/test_comprehensive_verify.py @@ -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 diff --git a/frontend/src/components/search/AdvancedSearchPanel.vue b/frontend/src/components/search/AdvancedSearchPanel.vue index 1989fe8..a5f216c 100644 --- a/frontend/src/components/search/AdvancedSearchPanel.vue +++ b/frontend/src/components/search/AdvancedSearchPanel.vue @@ -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([]) const selectedTags = ref([]) 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() { 阴性结果 -
- - - 全部 - 🎯 高精度 (Major Topic) - 📚 高召回 (MeSH) - -