From a985d077241ccb1ffacb893bb4e3e2c17358cd70 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 11:41:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC10=E8=BD=AE=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E9=94=AEPubMed=E6=A0=87=E5=BF=97=E3=80=81NOT=20OR?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E3=80=81affiliation=20ILIKE=E3=80=81?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=B8=83=E5=B0=94OR=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=92=8C=E7=B2=BE=E7=A1=AE=E7=9F=AD=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: _search_cache_key和_facet_cache_key加入"pm": _is_pm(query)防止PubMed/纯文本路径缓存碰撞 - 后端: OR模式下neg_conds不再AND到所有条件,改用or_(*term_conditions)直接组合 - 后端: _field_condition("all")所有5条分支添加affiliation ILIKE通过jsonb_array_elements(authors)子查询 - 后端: AdvancedSearchRequest.query添加max_length=2000 - 前端: SearchView搜索栏添加AND/OR切换器和精确短语复选框 - 前端: exact_phrase参数从SearchView传递到API请求 - 前端: booleanOp和exactPhrase同步到URL并可从URL恢复 --- backend/app/api/v1/features.py | 2 +- backend/app/services/search_engine.py | 27 ++++++++++++++++++++++++--- frontend/src/views/app/SearchView.vue | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index c740ef7..d205c1b 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -49,7 +49,7 @@ async def test_rule(req: TestRuleRequest, pmid: int = Query(...), db: AsyncSessi # ─── 高级搜索 ─── class AdvancedSearchRequest(BaseModel): - query: str = "" + query: str = Field("", max_length=2000) field: str = "all" boolean: str = "and" exact_phrase: bool = False diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 5f30905..202af46 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -55,8 +55,10 @@ class AdvancedSearchEngine: ) -> str: """归一化查询参数 → 确定性缓存 key(所有 list 排序后参与哈希)""" import hashlib, json + from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm norm = { "q": query.strip().lower(), + "pm": _is_pm(query), "f": field, "b": boolean, "ep": exact_phrase, "yf": year_from, "yt": year_to, "df": date_from, "dt": date_to, @@ -109,8 +111,10 @@ class AdvancedSearchEngine: 与 _search_cache_key 的区别:不含 p/ps/s/cd/ci。 """ import hashlib, json + from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm norm = { "q": query.strip().lower(), + "pm": _is_pm(query), "f": field, "b": boolean, "ep": exact_phrase, "yf": year_from, "yt": year_to, "df": date_from, "dt": date_to, @@ -1144,10 +1148,13 @@ class AdvancedSearchEngine: # 将 term_conditions 加入 conditions if term_conditions: - if pp.boolean_operator in ("or", "mixed"): + if pp.boolean_operator == "or": + # OR 模式:所有条件(含 NOT)OR 在一起 + conditions.append(or_(*term_conditions)) + elif pp.boolean_operator == "mixed": + # mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C) from sqlalchemy.sql.elements import UnaryExpression from sqlalchemy.sql import operators as _sa_ops - # OR/mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C) pos_conds = [c for c in term_conditions if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)] neg_conds = [c for c in term_conditions @@ -1382,7 +1389,11 @@ class AdvancedSearchEngine: pat = _pt() if exact and not _wildcard: # P4: 精确短语 → phraseto_tsquery(利用 GIN 索引,保留词序) - return GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term)) + return or_( + GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term)), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), + ) if _wildcard: # wildcard → ILIKE 右截断(tsvector 不支持 *),多字段覆盖 return or_( @@ -1393,6 +1404,8 @@ class AdvancedSearchEngine: GlobalLiterature.journal_iso.ilike(pat), cast(GlobalLiterature.pmid, String).ilike(pat), GlobalLiterature.doi.ilike(pat), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), ) like_val = f"%{_escaped}%" if "/" in term: @@ -1400,6 +1413,8 @@ class AdvancedSearchEngine: return or_( GlobalLiterature.doi.ilike(_escape_ilike(term)), GlobalLiterature.doi.ilike(like_val), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), ) return or_( GlobalLiterature.title.ilike(like_val), @@ -1408,6 +1423,8 @@ class AdvancedSearchEngine: GlobalLiterature.abstract.ilike(like_val), GlobalLiterature.author_names_text.ilike(like_val), GlobalLiterature.journal.ilike(like_val), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), ) # P7-D2: Chinese → ILIKE fallback (tsvector is English-only) if re.search(r'[一-鿿㐀-䶿豈-﫿]', term): @@ -1416,6 +1433,8 @@ class AdvancedSearchEngine: GlobalLiterature.abstract.ilike(like_val), GlobalLiterature.author_names_text.ilike(like_val), GlobalLiterature.journal.ilike(like_val), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), ) # tsvector 索引主覆盖 title/abstract/author_names/chemicals/genes/mesh/keywords # journal/journal_iso/affiliation 不在 tsvector 中,以 ILIKE 兜底 @@ -1423,6 +1442,8 @@ class AdvancedSearchEngine: GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)), GlobalLiterature.journal.ilike(like_val), GlobalLiterature.journal_iso.ilike(like_val), + text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat), ) @staticmethod diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index c1b7f81..5170eb7 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -44,6 +44,8 @@ const negativeResult = ref('') // '' = all, 'yes', 'no', 'only' const selectedSpecies = ref([]) const selectedSex = ref([]) const selectedAge = ref([]) +const booleanOp = ref('and') // "and" | "or" +const exactPhrase = ref(false) // ── PubMed 筛选器状态 ── const hasAbstract = ref(false) @@ -277,8 +279,9 @@ const { page, total, goToPage } = usePagination({ } const body: SearchRequestBody = { query: query.value, page: p, page_size: pageSize.value, sort: sort.value, - boolean: 'and', + boolean: booleanOp.value, } + if (exactPhrase.value) body.exact_phrase = true if (field.value !== 'all') body.field = field.value // P3-6: precision_mode 不再发送(后端已忽略) // ── Keyset 游标分页(所有排序模式通用,跳过 COUNT + OFFSET) ── @@ -401,6 +404,8 @@ function restoreFromQuery() { if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',') if (route.query.pub_type) pubTypes.value = String(route.query.pub_type).split(',') if (route.query.lang) languages.value = String(route.query.lang).split(',') + if (route.query.boolean === 'or') booleanOp.value = 'or' + if (route.query.exact_phrase === 'true') exactPhrase.value = true if (route.query.subset) nlmSubsets.value = String(route.query.subset).split(',') if (route.query.retracted) retracted.value = String(route.query.retracted) if (route.query.negative) negativeResult.value = String(route.query.negative) @@ -514,6 +519,8 @@ function syncSearchToUrl() { if (hasAssociatedData.value) q.associated_data = 'true' if (medlineOnly.value) q.medline_only = 'true' if (excludePreprints.value) q.exclude_preprints = 'true' + if (booleanOp.value !== 'and') q.boolean = booleanOp.value + if (exactPhrase.value) q.exact_phrase = 'true' if (pageSize.value !== 20) q.page_size = String(pageSize.value) // 页码持久化到 URL(Keyset 排序使用 keyset 页码,offset 排序使用 offset 页码) const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value @@ -544,6 +551,8 @@ function resetAllFilters() { query.value = '' field.value = 'all' sort.value = 'date' + booleanOp.value = 'and' + exactPhrase.value = false goToPage(1) } @@ -829,7 +838,9 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})