From 4f6024aa7a8c4f1a84bd9dce12090b6f16579e42 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Mon, 27 Jul 2026 16:57:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20ATM+NOT?= =?UTF-8?q?=E3=80=81affiliation=E3=80=81=E5=88=86=E9=A1=B5=E3=80=81SB=20?= =?UTF-8?q?=E7=AD=89=2012=20=E9=A1=B9=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/features.py | 1 + backend/app/services/pubmed_query_parser.py | 23 +++++-- backend/app/services/search_engine.py | 71 +++++++++++++-------- frontend/src/types/index.ts | 5 +- frontend/src/views/app/SearchView.vue | 4 +- frontend/src/views/public/HomeView.vue | 50 +++++++-------- 6 files changed, 93 insertions(+), 61 deletions(-) diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index 628ea4f..1dd0dc6 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -71,6 +71,7 @@ class AdvancedSearchRequest(BaseModel): # keyset 游标分页(所有排序模式通用,设了 cursor 后 page 参数被忽略,不做 COUNT) cursor_val: str | None = None # 上一页最后一条的排序列值(字符串,服务端按 sort 模式解析) cursor_id: str | None = None # 上一页最后一条的 id(UUID 字符串) + cursor_date: str | None = None # 向后兼容(映射到 cursor_val) # ── PubMed 筛选器参数 ── # Text Availability diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 96deea1..8bd75fa 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -553,6 +553,13 @@ class PubmedQueryParser: end_pos = self.pos # P2-2: 记录组结束标记位置 self.expect(TokenType.RPAREN) self._depth -= 1 + # P2-F12: (a OR b)[TI] — 组后字段标签应用到组内所有词 + if self.peek().type == TokenType.FIELD: + ft = self.advance() + _raw_field = ft.value[1:-1].upper() + _field = _normalize_field_label(_raw_field) + for t in terms: + t.field = _field # 标记为子组,不放入 flat lists,保留括号分组结构 group_id = len(result.groups) for t in terms: @@ -640,10 +647,18 @@ class PubmedQueryParser: start_val, end_val = end_val, start_val elif not _start_is_digit and not _end_is_digit and start_val > end_val: start_val, end_val = end_val, start_val - elif _start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4]): - start_val, end_val = end_val, start_val - elif not _start_is_digit and _end_is_digit and int(start_val[:4]) > int(end_val): - start_val, end_val = end_val, start_val + elif _start_is_digit and not _end_is_digit: + try: + if int(start_val) > int(end_val[:4]): + start_val, end_val = end_val, start_val + except (ValueError, TypeError): + pass + elif not _start_is_digit and _end_is_digit: + try: + if int(start_val[:4]) > int(end_val): + start_val, end_val = end_val, start_val + except (ValueError, TypeError): + pass # 确定两端是否是 4 位年份 _start_is_year = start_val.isdigit() and len(start_val) == 4 _end_is_year = end_val.isdigit() and len(end_val) == 4 diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index d7a0252..697082c 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -4,7 +4,7 @@ import logging import re from datetime import datetime -from sqlalchemy import String, and_, case, cast, func, literal_column, not_, or_, select, text +from sqlalchemy import String, and_, case, cast, exists, func, literal_column, not_, or_, select, text logger = logging.getLogger(__name__) from sqlalchemy.ext.asyncio import AsyncSession @@ -597,6 +597,9 @@ class AdvancedSearchEngine: _keyset_cond = AdvancedSearchEngine._keyset_condition(sort, cursor_val, cursor_id) if _keyset_cond is not None: conditions.append(_keyset_cond) + elif sort not in AdvancedSearchEngine.KEYSET_COLUMN_SORTS and page > 1: + # P1-F5: best_match/relevance 不支持 keyset,用 OFFSET 翻页 + q = q.offset((page - 1) * page_size) # 重新构建查询 q = select(GlobalLiterature) @@ -625,7 +628,9 @@ class AdvancedSearchEngine: # 后续页从 facet 缓存读 total facet_cached = await _cache.get(_facet_cache_key) if facet_cached: - total = facet_cached.get("total", 0) + if isinstance(facet_cached, dict): + total = facet_cached.get("total", 0) + # P2-F11: 旧格式 list(只有 year_counts),保持 total=0 # 构建游标供翻页 next_cursor_val = None @@ -783,29 +788,35 @@ class AdvancedSearchEngine: term_conditions.append(field_combine(*field_conds) if len(field_conds) > 1 else field_conds[0]) # 2. 纯文本词(无字段标签)— P0-2: 对无标签词补充 ATM MeSH 展开 + # P0-F1: ATM 只展开肯定词,否定词独立 AND,避免被 ATM OR 短路 if pp.plain_terms: - plain_conds = [] - for term in pp.plain_terms: - cond = AdvancedSearchEngine._field_condition("all", term.text, term.exact) - if term.is_not: - cond = not_(cond) - plain_conds.append(cond) - if plain_conds: - combined = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip() - if combined and not re.search(r'[一-鿿㐀-䶿豈-﫿]', combined): - from app.services.query_expansion import expand_atm as _expand_atm_inline - try: - atm_cond = await _expand_atm_inline(db, combined) - except Exception: - logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined[:100]) - atm_cond = None - if atm_cond is not None: - text_cond = field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0] - term_conditions.append(or_(atm_cond, text_cond)) - else: - term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0]) + pos_conds = [AdvancedSearchEngine._field_condition("all", t.text, t.exact) + for t in pp.plain_terms if not t.is_not] + neg_conds = [not_(AdvancedSearchEngine._field_condition("all", t.text, t.exact)) + for t in pp.plain_terms if t.is_not] + combined_pos_text = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip() + if combined_pos_text and pos_conds and not re.search(r'[一-鿿㐀-䶿豈-﫿]', combined_pos_text): + from app.services.query_expansion import expand_atm as _expand_atm_inline + try: + atm_cond = await _expand_atm_inline(db, combined_pos_text) + except Exception: + logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined_pos_text[:100]) + atm_cond = None + else: + atm_cond = None + term_cond = None + if pos_conds: + pos_combined = field_combine(*pos_conds) if len(pos_conds) > 1 else pos_conds[0] + if atm_cond is not None: + term_cond = or_(atm_cond, pos_combined) else: - term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0]) + term_cond = pos_combined + elif atm_cond is not None: + term_cond = atm_cond + if term_cond is not None: + term_conditions.append(term_cond) + # 否定词独立 AND(不参与 ATM 展开) + term_conditions.extend(neg_conds) # 3. [MH] → tree_number 展开,支持 is_not 和 _noexp if pp.mesh_terms: @@ -1037,7 +1048,8 @@ class AdvancedSearchEngine: continue # no-op: 所有记录都是 PubMed elif val == "MEDLINE": cond = GlobalLiterature.citation_status == "medline" - elif len(val) == 1 and val.isalpha(): + elif val.isalpha(): + # P1-F7: 所有字母子集码(含 AIM 等多字母)统一走 nlm_subsets subq = select(GlobalJournal.issn).where( GlobalJournal.nlm_subsets.overlap([val]) ) @@ -1300,7 +1312,12 @@ class AdvancedSearchEngine: GlobalLiterature.journal_iso.ilike(pat), ) elif field == "affiliation": - return cast(GlobalLiterature.authors, String).ilike(_pt()) + # P0-F2: 用 jsonb_array_elements 提取 affiliation 值,避免 JSON 键名假阳性 + _pat = _pt() + return text( + "EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e " + "WHERE _e->>'affiliation' ILIKE :aff_pat)" + ).bindparams(aff_pat=_pat) elif field == "language": return GlobalLiterature.language.ilike(_pt()) elif field == "volume": @@ -1493,11 +1510,11 @@ class AdvancedSearchEngine: return [GlobalLiterature.cited_by_count.desc().nullslast(), GlobalLiterature.id.desc()] elif sort == "best_match" and relevance_query.strip(): tsq = func.plainto_tsquery("english", relevance_query) - return [AdvancedSearchEngine._best_match_order(tsq)] + return [AdvancedSearchEngine._best_match_order(tsq), GlobalLiterature.id.desc()] elif sort == "relevance" and relevance_query.strip(): tsq = func.plainto_tsquery("english", relevance_query) rank = func.ts_rank(GlobalLiterature.search_tsv, tsq) - return [rank.desc()] + return [rank.desc(), GlobalLiterature.id.desc()] elif sort == "first_author": return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()] elif sort == "journal": diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 82a719f..c725120 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -372,9 +372,12 @@ export interface FolderItem { export interface PaginatedResponse { items: T[] total: number - total_pages?: number page?: number page_size?: number + has_more?: boolean + year_counts?: Array<{year: number, count: number}> + cursor_val?: string | null + cursor_id?: string | null } /** 搜索结果卡片显示设置 */ diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 36ce566..95ee1d5 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -410,8 +410,8 @@ function restoreFromQuery() { const ps = parseInt(String(route.query.page_size)) if (ps >= 10 && ps <= 100) pageSize.value = ps } - // keyset 游标不能从 URL 恢复 → page>1 回退到首页 - if (sort.value === 'date' && restoredPage.value > 1) restoredPage.value = 1 + // keyset 游标不能从 URL 恢复 → page>1 回退到首页(所有排序模式) + if (restoredPage.value > 1) restoredPage.value = 1 } // 选择预设(1y/5y/10y)时清除自定义年份并自动搜索 diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue index fa61b0b..2feca25 100644 --- a/frontend/src/views/public/HomeView.vue +++ b/frontend/src/views/public/HomeView.vue @@ -35,10 +35,13 @@ const searched = ref(false) const searchTotal = ref(0) // keyset 游标(用于"加载更多",page 被忽略) -const cursorDate = ref(null) +const cursorVal = ref(null) const cursorId = ref(null) const hasMoreItems = ref(false) +// 搜索请求 AbortController,防竞态 +const searchController = ref(null) + // ── 公开统计 ── const platformStats = ref({ literature_total: 0, journal_total: 0, daily_avg_30d: 0 }) const statsLoading = ref(true) @@ -124,7 +127,7 @@ async function loadHomepageFeed() { const items = data.items || [] if (items.length > 0) { const last = items[items.length - 1] - cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null + cursorVal.value = (last.article_date || last.pub_date)?.slice(0, 10) || null cursorId.value = last.id || null } searched.value = true @@ -133,8 +136,13 @@ async function loadHomepageFeed() { // ── 数据加载核心 ── async function fetchData(resetPage = true) { + // P2-F14: 取消上次未完成的请求,防竞态 + searchController.value?.abort() + searchController.value = new AbortController() + const signal = searchController.value.signal + if (resetPage) { - cursorDate.value = null + cursorVal.value = null cursorId.value = null } loading.value = true @@ -148,11 +156,11 @@ async function fetchData(resetPage = true) { if (searchParams.value.retracted) body.retracted = searchParams.value.retracted if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result // keyset 游标 - if (cursorDate.value && cursorId.value) { - body.cursor_val = cursorDate.value + if (cursorVal.value && cursorId.value) { + body.cursor_val = cursorVal.value body.cursor_id = cursorId.value } - const { data } = await api.post('/features/search/advanced', body) + const { data } = await api.post('/features/search/advanced', body, { signal }) if (resetPage) { feedItems.value = data.items || [] searchTotal.value = data.total ?? 0 @@ -161,17 +169,9 @@ async function fetchData(resetPage = true) { } hasMoreItems.value = data.has_more ?? false // keyset 游标(全部使用服务端返回的游标,通用所有排序模式) - if (searchParams.value.sort !== 'date') { - // 非 date 排序由服务端返回 cursor_val,用 response 字段 - if (data.cursor_val) { - cursorDate.value = data.cursor_val - cursorId.value = data.cursor_id - } - } else { - if (data.cursor_val && data.cursor_id) { - cursorDate.value = data.cursor_val - cursorId.value = data.cursor_id - } + if (data.cursor_val) { + cursorVal.value = data.cursor_val + cursorId.value = data.cursor_id ?? null } searched.value = true } catch (e) { toast.apiError(e, '搜索文献失败,请重试') } @@ -331,21 +331,17 @@ async function loadMore() { if (searchParams.value.date_to) body.date_to = searchParams.value.date_to if (searchParams.value.retracted) body.retracted = searchParams.value.retracted if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result - // P3-6: precision_mode 不再发送 - if (cursorDate.value && cursorId.value) { - body.cursor_date = cursorDate.value + // P1-F4: 发 cursor_val(非 cursor_date),服务端兼容两者 + if (cursorVal.value && cursorId.value) { + body.cursor_val = cursorVal.value body.cursor_id = cursorId.value } const { data } = await api.post('/features/search/advanced', body) feedItems.value.push(...(data.items || [])) hasMoreItems.value = data.has_more ?? false - // 更新游标 - const items = data.items || [] - if (items.length > 0) { - const last = items[items.length - 1] - cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null - cursorId.value = last.id || null - } + // P1-F9: 统一使用服务端返回的游标(替代手动从末条提取) + cursorVal.value = data.cursor_val ?? null + cursorId.value = data.cursor_id ?? null } catch (e) { toast.apiError(e, '加载更多失败,请重试') } finally { loadingMore.value = false } }