Files
backend/backend/app/services/search_engine.py
T
34047007@qq.com 8a4826220a
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
fix: D2 中文搜索 + D3 混合布尔语义 — PubMed 搜索路径修复
D2: PubMed 路径搜索中文(不带字段标签)时,plainto_tsquery("english")
    对中文返回空,导致零结果。新增 ILIKE fallback 检测中文字符。
D3: A OR B AND C 被解析器拉平为 A OR B OR C。修改 _parse_or_expr
    保留 AND cluster 分组,引擎正确产生产出 A OR (B AND C)。
2026-07-27 14:23:10 +08:00

1343 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""高级搜索服务:布尔运算 + 字段限定 + PubMed 查询语法"""
import logging
import re
from datetime import datetime
from sqlalchemy import String, and_, case, cast, func, literal_column, not_, or_, select, text
logger = logging.getLogger(__name__)
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache as _cache
from app.models.literature import GlobalJournal, GlobalLiterature, GlobalLiteratureTag, GlobalTag, GlobalTagTreeNumber
from app.schemas.literature import cap_pub_date
from app.services.pubmed_query_parser import is_pubmed_syntax, parse_pubmed_query
from app.services.query_expansion import expand_atm as _expand_atm
from app.services.tag_loader import load_tags_for_literature
def _escape_ilike(s: str) -> str:
"""转义 ILIKE 模式中的通配符 _ 和 %,防止用户输入 'EGFR_mutation' 误匹配 'EGFR mutation'"""
if not s:
return s
return s.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
class AdvancedSearchEngine:
"""PG 高级搜索(ES 就绪后切换 search_service.py"""
SEARCH_CACHE_TTL = 300 # 秒(夜间流水线更新,5分钟缓存很安全)
@staticmethod
def _search_cache_key(
query: str, field: str, boolean: str, exact_phrase: bool,
year_from: int | None, year_to: int | None,
date_from: str | None, date_to: str | None,
journal_tiers: list[str] | None, pub_types: list[str] | None,
tag_ids: list[str] | None,
retracted: str, negative_result: str,
is_oa: bool | None, language: str | None, languages: list[str] | None, nlm_subsets: list[str] | None,
page: int, page_size: int, sort: str,
# PubMed filter params
has_abstract: bool | None = None,
is_free_full_text: bool | None = None,
has_full_text: bool | None = None,
has_associated_data: bool | None = None,
species: list[str] | None = None,
sex: list[str] | None = None,
age: list[str] | None = None,
medline_only: bool = False,
exclude_preprints: bool = False,
# Keyset cursor (sort=date 时分页)
cursor_date: str | None = None,
cursor_id: str | None = None,
) -> str:
"""归一化查询参数 → 确定性缓存 key(所有 list 排序后参与哈希)"""
import hashlib, json
norm = {
"q": query.strip().lower(),
"f": field, "b": boolean, "ep": exact_phrase,
"yf": year_from, "yt": year_to,
"df": date_from, "dt": date_to,
"jt": sorted(journal_tiers) if journal_tiers else [],
"pt": sorted(pub_types) if pub_types else [],
"tid": sorted(tag_ids) if tag_ids else [],
"r": retracted, "nr": negative_result,
"oa": is_oa, "lang": language, "lgs": sorted(languages) if languages else [],
"ns": sorted(nlm_subsets) if nlm_subsets else [],
"p": page, "ps": page_size, "s": sort,
"ha": has_abstract,
"fft": is_free_full_text,
"hft": has_full_text,
"had": has_associated_data,
"sp": sorted(species) if species else [],
"sx": sorted(sex) if sex else [],
"ag": sorted(age) if age else [],
"mo": medline_only,
"epr": exclude_preprints,
# keyset cursor 唯一标识翻页位置
"cd": cursor_date,
"ci": cursor_id,
}
raw = json.dumps(norm, sort_keys=True, ensure_ascii=False, default=str)
return f"search:advanced:{hashlib.md5(raw.encode()).hexdigest()}"
@staticmethod
async def search(
db: AsyncSession,
query: str = "",
field: str = "all",
boolean: str = "and",
exact_phrase: bool = False,
year_from: int | None = None,
year_to: int | None = None,
date_from: str | None = None, # YYYY-MM-DD
date_to: str | None = None, # YYYY-MM-DD
journal_tiers: list[str] | None = None,
pub_types: list[str] | None = None,
tag_ids: list[str] | None = None,
retracted: str = "", # "no", "only"
negative_result: str = "", # "yes", "no", "only"
is_oa: bool | None = None, # True = 仅开放获取
language: str | None = None, # 语言代码(en/zh/fr 等,向后兼容)
languages: list[str] | None = None, # 语言代码列表(多选)
nlm_subsets: list[str] | None = None, # NLM 期刊子集(AIM/M/S 等)
page: int = 1,
page_size: int = 20,
sort: str = "date",
cursor_date: str | None = None, # keyset 游标:上一页最后一条的 pub_date
cursor_id: str | None = None, # keyset 游标:上一页最后一条的 id(UUID)
# ── PubMed 筛选器参数 ──
has_abstract: bool | None = None,
is_free_full_text: bool | None = None,
has_full_text: bool | None = None,
has_associated_data: bool | None = None,
species: list[str] | None = None,
sex: list[str] | None = None,
age: list[str] | None = None,
medline_only: bool = False,
exclude_preprints: bool = False,
) -> dict:
"""执行高级搜索"""
from sqlalchemy.dialects.postgresql import JSONB
conditions = []
# 60s 缓存(keyset cursor 页也参与缓存,cursor_date+cursor_id 组合唯一标识翻页位置)
use_cursor = (cursor_date is not None and cursor_id is not None and sort == "date")
_search_cache_key = AdvancedSearchEngine._search_cache_key(
query, field, boolean, exact_phrase,
year_from, year_to, date_from, date_to,
journal_tiers, pub_types, tag_ids,
retracted, negative_result,
is_oa, language, languages, nlm_subsets,
page, page_size, sort,
has_abstract=has_abstract,
is_free_full_text=is_free_full_text,
has_full_text=has_full_text,
has_associated_data=has_associated_data,
species=species, sex=sex, age=age,
medline_only=medline_only,
exclude_preprints=exclude_preprints,
cursor_date=cursor_date,
cursor_id=cursor_id,
)
cached = await _cache.get(_search_cache_key)
if cached is not None:
return cached
# 30 秒查询超时(放在缓存检查之后,缓存命中不执行)
await db.execute(text("SET LOCAL statement_timeout = '30s'"))
# ─── PubMed 语法检测与解析 ───
_pubmed_parsed = None
_is_flat_text = True # 是否走传统 tsvector + ILIKE 路径
if query.strip():
if is_pubmed_syntax(query):
pp = parse_pubmed_query(query)
_pubmed_parsed = pp
# 只要解析器产出了有效结构,就走 PubMed 路径
has_pubmed_terms = bool(
pp.title_terms or pp.abstract_terms or pp.tiab_terms
or pp.author_terms or pp.journal_terms
or pp.mesh_terms or pp.majr_terms
or pp.pub_types or pp.doi_terms or pp.pmid_terms
or pp.affiliation_terms
or pp.language_terms or pp.volume_terms or pp.issue_terms
or pp.pages_terms or pp.lid_terms
or pp.grant_terms or pp.subheading_terms
or pp.registry_terms or pp.substance_terms
or pp.databank_terms or pp.pharmaco_terms
or pp.ed_terms or pp.investigator_terms or pp.personal_name_terms
or pp.pubnote_terms or pp.auid_terms or pp.cois_terms or pp.tt_terms
or pp.sb_terms or pp.stat_terms or pp.uid_terms
or pp.ot_terms or pp.gene_terms or pp.pmc_terms
or pp.edat_from or pp.crdt_from or pp.mhda_from
or pp.lr_from or pp.dcom_from or pp.dep_from
or pp.edat_to or pp.crdt_to or pp.mhda_to
or pp.lr_to or pp.dcom_to or pp.dep_to
or pp.date_from or pp.date_to
or pp.negated_date_ranges
or pp.groups
or pp.plain_terms or pp.has_not
or pp.year_from or pp.year_to
)
if has_pubmed_terms:
_is_flat_text = False
conditions = await AdvancedSearchEngine._pubmed_conditions(
db, pp, conditions,
)
# ─── 传统搜索路径(纯文本 / PubMed 退化) ───
if _is_flat_text and query.strip():
# 如果 PubMed 语法检测成功但解析后无有效词(如语法错误),剥离括号和操作符再搜索
if _pubmed_parsed is not None and not any([
bool(_pubmed_parsed.title_terms or _pubmed_parsed.abstract_terms or _pubmed_parsed.tiab_terms
or _pubmed_parsed.author_terms or _pubmed_parsed.journal_terms
or _pubmed_parsed.mesh_terms or _pubmed_parsed.majr_terms
or _pubmed_parsed.pub_types or _pubmed_parsed.doi_terms or _pubmed_parsed.pmid_terms
or _pubmed_parsed.affiliation_terms
or _pubmed_parsed.language_terms or _pubmed_parsed.volume_terms or _pubmed_parsed.issue_terms
or _pubmed_parsed.pages_terms or _pubmed_parsed.lid_terms
or _pubmed_parsed.grant_terms or _pubmed_parsed.subheading_terms
or _pubmed_parsed.registry_terms or _pubmed_parsed.substance_terms
or _pubmed_parsed.databank_terms or _pubmed_parsed.pharmaco_terms
or _pubmed_parsed.ed_terms or _pubmed_parsed.investigator_terms or _pubmed_parsed.personal_name_terms
or _pubmed_parsed.pubnote_terms or _pubmed_parsed.auid_terms or _pubmed_parsed.cois_terms or _pubmed_parsed.tt_terms
or _pubmed_parsed.sb_terms or _pubmed_parsed.stat_terms or _pubmed_parsed.uid_terms
or _pubmed_parsed.ot_terms or _pubmed_parsed.gene_terms or _pubmed_parsed.pmc_terms
or _pubmed_parsed.edat_from or _pubmed_parsed.crdt_from or _pubmed_parsed.mhda_from
or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from or _pubmed_parsed.dep_from
or _pubmed_parsed.edat_to or _pubmed_parsed.crdt_to or _pubmed_parsed.mhda_to
or _pubmed_parsed.lr_to or _pubmed_parsed.dcom_to or _pubmed_parsed.dep_to
or _pubmed_parsed.date_from or _pubmed_parsed.date_to
or _pubmed_parsed.negated_date_ranges
or _pubmed_parsed.groups
or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
]):
# 解析失败但检测到 PubMed 语法 — 擦除 [field] 标签、布尔符、引号
query = re.sub(r'\[[\w/: -]+\]', '', query) # P5: [\w/: -] 覆盖 [Title/Abstract] 和 [MH:noexp]
query = re.sub(r'\b(AND|OR|NOT)\b', '', query)
query = query.replace('"', '').replace('(', '').replace(')', '')
query = ' '.join(query.split())
# 中文搜索:自动匹配 GlobalTag.name_zh → 注入 tag_ids,跳过 ILIKE
import re as _re
_CHINESE_RE = _re.compile(r'[一-鿿㐀-䶿豈-﫿]')
if _CHINESE_RE.search(query):
tag_matches = (await db.execute(
select(GlobalTag.id).where(GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip())}%'))
.limit(100) # P5: limit to prevent oversized subquery
)).scalars().all()
if tag_matches:
existing = set(tag_ids or [])
tag_ids = list(existing | {str(t) for t in tag_matches})
# 不置空 query,保留 ILIKE 对中文标题/摘要的搜索能力
if query.strip():
# 提取引号短语作为完整词,避免 "lung cancer" 被拆散
import re as _phrase_re
_phrase_pat = _phrase_re.compile(r'"([^"]*)"')
_phrases = _phrase_pat.findall(query)
_query_no_quotes = _phrase_pat.sub(' ', query)
_rest = [t.strip().strip('"').strip("'") for t in _query_no_quotes.split() if t.strip()]
terms = [p for p in _phrases if p.strip()] + [t for t in _rest if t not in _phrases]
# 单数字词:优先 PMID 精确匹配(unique index 5ms 返回)
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
numeric_terms = [t for t in terms if re.match(r'^\d{1,15}$', t)]
text_terms = [t for t in terms if not re.match(r'^\d{1,15}$', t)]
if numeric_terms:
num_conds = []
if exact_phrase:
# 精确短语模式:跳过 PMID 快速路径,走 ILIKE
for t in numeric_terms:
num_conds.append(or_(
GlobalLiterature.title.ilike(t),
GlobalLiterature.doi.ilike(t),
))
else:
# P1-1: 批量检查所有 PMID(单次 IN 查询替代 N+1 次 SELECT
numeric_ids = [int(t) for t in numeric_terms]
db_pmids: set[int] = set()
rows = await db.execute(
select(GlobalLiterature.pmid).where(GlobalLiterature.pmid.in_(numeric_ids))
)
for (pmid,) in rows:
db_pmids.add(pmid)
for t in numeric_terms:
p = int(t)
if p in db_pmids:
num_conds.append(GlobalLiterature.pmid == p)
else:
p_like = f"%{_escape_ilike(t)}%"
num_conds.append(or_(
GlobalLiterature.title.ilike(p_like),
GlobalLiterature.doi.ilike(p_like),
))
if boolean == "or" and len(num_conds) > 1:
conditions.append(or_(*num_conds))
else:
conditions.extend(num_conds)
if text_terms:
# ATM 展开(仅 field="all" 时,字段搜索不应自动扩到 MeSH)
_atm_cond = None
_atm_query = query.replace('"', '').replace("'", '').replace('(', '').replace(')', '').strip()
if _atm_query and field == "all":
try:
_atm_cond = await _expand_atm(db, _atm_query)
except Exception:
logger.exception("ATM expansion failed (flat text): %s", _atm_query[:100])
_atm_cond = None
_cond_before = len(conditions)
if boolean == "and":
for term in text_terms:
conditions.append(AdvancedSearchEngine._field_condition(field, term, exact_phrase))
else:
or_conds = [AdvancedSearchEngine._field_condition(field, t, exact_phrase) for t in text_terms]
conditions.append(or_(*or_conds))
# 将文本条件与 ATM 条件 OR 组合
if _atm_cond is not None:
_text_conds = conditions[_cond_before:]
del conditions[_cond_before:]
if _text_conds:
if len(_text_conds) == 1:
conditions.append(or_(_atm_cond, _text_conds[0]))
else:
conditions.append(or_(_atm_cond, and_(*_text_conds)))
else:
conditions.append(_atm_cond)
# 年份范围
if year_from is not None:
conditions.append(GlobalLiterature.pub_year >= year_from)
if year_to is not None:
conditions.append(GlobalLiterature.pub_year <= year_to)
# 具体日期范围(按天搜索)
from datetime import date as dt_date
if date_from:
try:
df = dt_date.fromisoformat(date_from)
conditions.append(GlobalLiterature.pub_date >= df)
except ValueError:
raise ValueError(f"Invalid date_from format: {date_from}")
if date_to:
try:
dt = dt_date.fromisoformat(date_to)
conditions.append(GlobalLiterature.pub_date <= dt)
except ValueError:
raise ValueError(f"Invalid date_to format: {date_to}")
# 期刊等级
if journal_tiers:
subq = select(GlobalJournal.issn).where(GlobalJournal.tier.in_(journal_tiers))
result = await db.execute(subq)
issns = [r for (r,) in result.all()]
if not issns:
logger.warning("journal_tiers filter matched zero journals: %s", journal_tiers)
conditions.append(GlobalLiterature.journal_issn.in_(issns))
# 标签筛选(含子标签递归)
if tag_ids:
import uuid as _uuid
tag_uuids = [_uuid.UUID(tid) if isinstance(tid, str) else tid for tid in tag_ids]
all_tags = (await db.execute(select(GlobalTag).where(GlobalTag.id.in_(tag_uuids)))).scalars().all()
all_tag_ids = set(t.id for t in all_tags if t)
# Batch child tag lookup (1 query instead of N)
paths = [t.path + "::" for t in all_tags if t]
if paths:
child_conds = [GlobalTag.path.like(f"{p}%") for p in paths]
children = (await db.execute(select(GlobalTag.id).where(or_(*child_conds)))).scalars().all()
all_tag_ids.update(children)
uids = list(all_tag_ids)
subq = select(func.distinct(GlobalLiteratureTag.literature_id)).where(GlobalLiteratureTag.tag_id.in_(uids))
conditions.append(GlobalLiterature.id.in_(subq))
# 发表类型(PG JSONB contains
if pub_types:
type_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([pt]) for pt in pub_types]
conditions.append(or_(*type_conds))
# 撤稿过滤
if retracted == "no":
conditions.append(GlobalLiterature.retracted == False)
elif retracted in ("only", "yes"):
conditions.append(GlobalLiterature.retracted == True)
# 阴性结果过滤
if negative_result == "no":
conditions.append(GlobalLiterature.is_negative_result == False)
elif negative_result in ("only", "yes"):
conditions.append(GlobalLiterature.is_negative_result == True)
# 开放获取
if is_oa is not None:
conditions.append(GlobalLiterature.is_oa == is_oa)
# 语言(多选优先,向后兼容单语言)
if languages:
conditions.append(GlobalLiterature.language.in_(languages))
elif language:
conditions.append(GlobalLiterature.language == language)
# NLM 期刊子集(如 AIM / Core Clinical Journals
if nlm_subsets:
subq = select(GlobalJournal.issn).where(GlobalJournal.nlm_subsets.overlap(nlm_subsets))
result = await db.execute(subq)
issns = [r for (r,) in result.all()]
if not issns:
logger.warning("nlm_subsets filter matched zero journals: %s", nlm_subsets)
conditions.append(GlobalLiterature.journal_issn.in_(issns))
# ── PubMed 筛选器 ──
# Text Availability
if has_abstract:
conditions.append(and_(
GlobalLiterature.abstract.isnot(None),
GlobalLiterature.abstract != '',
))
if is_free_full_text:
conditions.append(GlobalLiterature.is_oa == True)
if has_full_text:
conditions.append(GlobalLiterature.pmc_id.isnot(None))
# Article Attribute: Associated data
if has_associated_data:
conditions.append(GlobalLiterature.databank_list.cast(JSONB) != '[]')
# Species / Sex / Age — mesh_headings JSONB @> UI match
if species:
conditions.append(or_(*[
GlobalLiterature.mesh_headings.contains([{"ui": ui}])
for ui in species
]))
if sex:
conditions.append(or_(*[
GlobalLiterature.mesh_headings.contains([{"ui": ui}])
for ui in sex
]))
if age:
from app.api.v1.features import AGE_GROUP_UI_MAP
age_uis = []
for val in age:
if val in AGE_GROUP_UI_MAP:
age_uis.extend(AGE_GROUP_UI_MAP[val])
else:
age_uis.append(val) # 直接传 UI 的情况(向后兼容)
conditions.append(or_(*[
GlobalLiterature.mesh_headings.contains([{"ui": ui}])
for ui in set(age_uis)
]))
# MEDLINE only
if medline_only:
conditions.append(GlobalLiterature.citation_status == 'MEDLINE')
# Exclude Preprints
if exclude_preprints:
conditions.append(GlobalLiterature.is_preprint == False)
# ── 按年份统计(Results by year,使用完整筛选条件) ──
_yr_before = len(conditions)
year_counts = []
# 判断是否有任何筛选器/文本查询活跃(与旧 gate 逻辑一致)
_has_any_filter = (query.strip() or year_from or year_to or date_from or date_to
or journal_tiers or pub_types or tag_ids
or retracted or negative_result or is_oa is not None
or language or languages or nlm_subsets
or has_abstract or is_free_full_text or has_full_text
or has_associated_data or species or sex or age
or medline_only or exclude_preprints)
# ── 无文本无筛选 → 走 Redis 预计算缓存(每天 pipeline 后 cron 刷新) ──
if not _has_any_filter:
_cached = await _cache.get("search:year_counts:all")
if _cached is not None:
year_counts = _cached
else:
# 缓存未命中 → GROUP BY 兜底(首次部署、Redis 不可用时,conditions 可能为空)
try:
yr_conds = conditions[:_yr_before]
yr_subq = select(GlobalLiterature.pub_year).where(
and_(*yr_conds) if yr_conds else text("TRUE")
).subquery()
year_count_q = select(
yr_subq.c.pub_year, func.count().label("cnt")
).group_by(yr_subq.c.pub_year).order_by(yr_subq.c.pub_year.desc())
year_rows = await db.execute(year_count_q)
year_counts = [
{"year": y, "count": c} for y, c in year_rows if y is not None
]
except Exception:
logger.exception("Year counts query failed")
year_counts = []
# ── 有筛选条件 → 精确 GROUP BY(已移除旧 30 年限制,pub_year 低基数因此 GROUP BY 高效) ──
elif conditions and _has_any_filter:
try:
yr_conds = conditions[:_yr_before]
yr_subq = select(GlobalLiterature.pub_year).where(
and_(*yr_conds)
).subquery()
year_count_q = select(
yr_subq.c.pub_year, func.count().label("cnt")
).group_by(yr_subq.c.pub_year).order_by(yr_subq.c.pub_year.desc())
year_rows = await db.execute(year_count_q)
year_counts = [
{"year": y, "count": c} for y, c in year_rows if y is not None
]
except Exception:
logger.exception("Year counts query failed")
year_counts = []
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
_relevance_query = query
if _pubmed_parsed and sort in ("relevance", "best_match"):
# 用纯文本词做相关性排序,去掉 [field] 标签
plain_parts = [t.text for t in _pubmed_parsed.plain_terms]
plain_parts += [t.text for t in _pubmed_parsed.title_terms]
plain_parts += [t.text for t in _pubmed_parsed.tiab_terms]
_relevance_query = " ".join(plain_parts) if plain_parts else ""
use_keyset = (cursor_date is not None and cursor_id is not None and sort == "date")
if use_keyset:
from datetime import date as dt_date
try:
cursor_dt = dt_date.fromisoformat(cursor_date)
except (ValueError, TypeError):
use_keyset = False
if use_keyset:
import uuid as _cuuid
try:
cursor_uuid = _cuuid.UUID(cursor_id)
conditions.append(
or_(
GlobalLiterature.pub_date < cursor_dt,
and_(GlobalLiterature.pub_date == cursor_dt, GlobalLiterature.id < cursor_uuid),
)
)
except (ValueError, AttributeError):
use_keyset = False
# 重新构建查询(keyset 条件可能已追加)
q = select(GlobalLiterature)
if conditions:
q = q.where(and_(*conditions))
q = q.order_by(*AdvancedSearchEngine._apply_order_by(sort, _relevance_query))
# 分页
has_more = False
if use_keyset:
# cursor 模式:取 page_size+1 条判断是否有下一页,不做 COUNT
result = await db.execute(q.limit(page_size + 1))
items = result.scalars().all()
has_more = len(items) > page_size
items = items[:page_size]
total = 0
else:
# 总数
count_q = select(func.count()).select_from(
select(literal_column("1"))
.select_from(GlobalLiterature)
.where(and_(*conditions) if conditions else True)
.subquery()
)
total = (await db.execute(count_q)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(q.offset(offset).limit(page_size))
items = result.scalars().all()
has_more = (offset + page_size) < total and len(items) == page_size
tm = await load_tags_for_literature(db, [str(lit.id) for lit in items])
# Batch load journal tiers + canonical names
tier_map = {}
name_map = {}
if items:
issns = list(set(lit.journal_issn for lit in items if lit.journal_issn))
if issns:
jr = await db.execute(
select(GlobalJournal.issn, GlobalJournal.tier, GlobalJournal.name).where(GlobalJournal.issn.in_(issns))
)
for issn_, tier_, name_ in jr.all():
tier_map[issn_] = tier_
name_map[issn_] = name_
results = []
for lit in items:
authors = lit.authors or []
results.append({
"id": str(lit.id), "pmid": lit.pmid, "title": lit.title,
"first_author": authors[0].get("family", "") if authors else "",
"journal": name_map.get(lit.journal_issn) or lit.journal, "pub_date": cap_pub_date(lit.pub_date),
"article_date": lit.article_date.isoformat() if lit.article_date else None,
"pub_year": lit.pub_year, "tags": tm.get(str(lit.id), []),
"abstract": lit.abstract[:300] if lit.abstract else None,
"doi": lit.doi,
"pmc_id": lit.pmc_id,
"is_oa": lit.is_oa,
"cited_by_count": lit.cited_by_count,
"created_at": lit.created_at.isoformat() if lit.created_at else None,
"updated_at": lit.updated_at.isoformat() if lit.updated_at else None,
"journal_issn": lit.journal_issn,
"journal_tier": tier_map.get(lit.journal_issn),
"pub_types": lit.pub_types,
"affiliation": authors[0].get("affiliation", "") if authors else "",
})
result = {"items": results, "total": total, "page": page, "page_size": page_size, "has_more": has_more, "year_counts": year_counts}
if _search_cache_key is not None:
await _cache.set(_search_cache_key, result, ttl=AdvancedSearchEngine.SEARCH_CACHE_TTL)
return result
@staticmethod
async def _pubmed_conditions(
db: AsyncSession,
pp,
existing_conditions: list,
) -> list:
"""将解析后的 PubMed 查询转换为 SQLAlchemy 条件列表。"""
from sqlalchemy.dialects.postgresql import JSONB
# 创建副本避免就地修改传入的列表(调用方会复用 conditions 做其他过滤)
conditions = list(existing_conditions)
# term_conditions 收集与查询词相关的条件,用于 OR 模式下统一包裹
term_conditions: list = []
# 1. 字段级搜索 [TI] [AB] [TIAB] [AU] [TA] [LA] [VI] [IP] [PG] [LID]
field_combine = or_ if pp.boolean_operator == "or" else and_
field_map = {
"title": pp.title_terms,
"abstract": pp.abstract_terms,
"all": pp.tiab_terms,
"author": pp.author_terms,
"journal": pp.journal_terms,
"affiliation": pp.affiliation_terms,
"language": pp.language_terms,
"volume": pp.volume_terms,
"issue": pp.issue_terms,
"pages": pp.pages_terms,
"lid": pp.lid_terms,
}
for fld, terms in field_map.items():
if not terms:
continue
field_conds = []
for term in terms:
cond = AdvancedSearchEngine._field_condition(fld, term.text, term.exact)
if term.is_not:
cond = not_(cond)
field_conds.append(cond)
term_conditions.append(field_combine(*field_conds) if len(field_conds) > 1 else field_conds[0])
# 2. 纯文本词(无字段标签)— P0-2: 对无标签词补充 ATM MeSH 展开
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])
else:
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
# 3. [MH] → tree_number 展开,支持 is_not 和 _noexp
if pp.mesh_terms:
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:
pos_names = [t.text for t in pp.majr_terms if not t.is_not]
neg_names = [t.text for t in pp.majr_terms if t.is_not]
if pos_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, pos_names, major_only=True)
if cond is not None:
term_conditions.append(cond)
if neg_names:
cond = await AdvancedSearchEngine._expand_mesh_tag_ids(db, neg_names, major_only=True)
if cond is not None:
term_conditions.append(not_(cond))
# 5. [PT] → pub_types JSONB contains,支持 is_not
if pp.pub_types:
pos = [t for t in pp.pub_types if not t.is_not]
neg = [t for t in pp.pub_types if t.is_not]
if pos:
pos_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([t.text]) for t in pos]
term_conditions.append(or_(*pos_conds))
if neg:
neg_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([t.text]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# 5b. [GR] [SH] [RN] [NM] [SI] [PA] → JSONB contains,支持 is_not
if pp.grant_terms:
pos = [t for t in pp.grant_terms if not t.is_not]
neg = [t for t in pp.grant_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.subheading_terms:
pos = [t for t in pp.subheading_terms if not t.is_not]
neg = [t for t in pp.subheading_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [t.text]}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [t.text]}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.registry_terms:
pos = [t for t in pp.registry_terms if not t.is_not]
neg = [t for t in pp.registry_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.substance_terms:
pos = [t for t in pp.substance_terms if not t.is_not]
neg = [t for t in pp.substance_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.databank_terms:
pos = [t for t in pp.databank_terms if not t.is_not]
neg = [t for t in pp.databank_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [t.text]}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [t.text]}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# 5c. [PA] → pharmacological_actions JSONB contains (by name or ui),支持 is_not
if pp.pharmaco_terms:
pos = [t for t in pp.pharmaco_terms if not t.is_not]
neg = [t for t in pp.pharmaco_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.pharmacological_actions.cast(JSONB).contains([{"name": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.pharmacological_actions.cast(JSONB).contains([{"name": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# P4: [OT] → keywords JSONB contains(不再映射到 all
if pp.ot_terms:
pos = [t for t in pp.ot_terms if not t.is_not]
neg = [t for t in pp.ot_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.keywords.cast(JSONB).contains([t.text])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.keywords.cast(JSONB).contains([t.text]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# P4: [GEN] → gene_symbols JSONB contains
if pp.gene_terms:
pos = [t for t in pp.gene_terms if not t.is_not]
neg = [t for t in pp.gene_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# 5d. [ED] [IR] [PS] [PUBN] [AUID] [COIS] [TT] → 新增字段搜索,支持 is_not
if pp.ed_terms:
pos = [t for t in pp.ed_terms if not t.is_not]
neg = [t for t in pp.ed_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.investigator_terms:
pos = [t for t in pp.investigator_terms if not t.is_not]
neg = [t for t in pp.investigator_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.investigators.cast(JSONB).contains([{"family": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.investigators.cast(JSONB).contains([{"family": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.personal_name_terms:
pos = [t for t in pp.personal_name_terms if not t.is_not]
neg = [t for t in pp.personal_name_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.pubnote_terms:
pos = [t for t in pp.pubnote_terms if not t.is_not]
neg = [t for t in pp.pubnote_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(t.text)}%")
for t in pos
]))
if neg:
neg_conds = [cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(t.text)}%") for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.auid_terms:
pos = [t for t in pp.auid_terms if not t.is_not]
neg = [t for t in pp.auid_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.auid_data.cast(JSONB).contains([{"value": t.text}])
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.auid_data.cast(JSONB).contains([{"value": t.text}]) for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.cois_terms:
pos = [t for t in pp.cois_terms if not t.is_not]
neg = [t for t in pp.cois_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.cois_statement.ilike(f"%{_escape_ilike(t.text)}%")
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.cois_statement.ilike(f"%{_escape_ilike(t.text)}%") for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
if pp.tt_terms:
pos = [t for t in pp.tt_terms if not t.is_not]
neg = [t for t in pp.tt_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(t.text)}%")
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(t.text)}%") for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# P1-2: [SB] Subset
# medline[SB] → citation_status='medline'(记录级)
# pubmed[SB] → no-op(全部记录都在 PubMed 中)
# 单字母代码(AIM/M/S/D/N/Q/T/X)→ nlm_subsets(期刊级)
if pp.sb_terms:
pos = [t for t in pp.sb_terms if not t.is_not]
neg = [t for t in pp.sb_terms if t.is_not]
for subl, is_neg in [(pos, False), (neg, True)]:
for t in subl:
val = t.text.upper()
if val == "PUBMED":
continue # no-op: 所有记录都是 PubMed
elif val == "MEDLINE":
cond = GlobalLiterature.citation_status == "medline"
elif len(val) == 1 and val.isalpha():
subq = select(GlobalJournal.issn).where(
GlobalJournal.nlm_subsets.overlap([val])
)
cond = GlobalLiterature.journal_issn.in_(subq)
else:
cond = GlobalLiterature.citation_status == val.lower()
term_conditions.append(not_(cond) if is_neg else cond)
# P1-2: [STAT] Status → citation_status
if pp.stat_terms:
pos = [t for t in pp.stat_terms if not t.is_not]
neg = [t for t in pp.stat_terms if t.is_not]
if pos:
term_conditions.append(or_(*[
GlobalLiterature.citation_status == t.text.lower()
for t in pos
]))
if neg:
neg_conds = [GlobalLiterature.citation_status == t.text.lower() for t in neg]
term_conditions.append(not_(or_(*neg_conds)))
# P1-2: [UID] → PMID 优先,兜底 DOI
if pp.uid_terms:
for t in pp.uid_terms:
cond = None
try:
cond = GlobalLiterature.pmid == int(t.text)
except ValueError:
cond = GlobalLiterature.doi.ilike(f"%{_escape_ilike(t.text)}%")
if cond is not None:
term_conditions.append(not_(cond) if t.is_not else cond)
# 处理括号分组的词(保留 OR/AND 嵌套结构,P2-2
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 not all_not and t.is_not:
cond = not_(cond)
group_conds.append(cond)
if group_conds:
gop = (pp.group_operators[idx]
if idx < len(pp.group_operators)
else "and")
combine_fn = or_ if gop == "or" else and_
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:
if pp.boolean_operator == "or":
from sqlalchemy.sql.elements import UnaryExpression
from sqlalchemy.sql import operators as _sa_ops
# OR 模式下 NOT 项应独立 ANDPubMed: 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
if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv]
if neg_conds:
if pos_conds:
conditions.append(or_(*pos_conds))
conditions.extend(neg_conds)
else:
conditions.append(or_(*term_conditions))
else:
conditions.extend(term_conditions)
# 以下为非词条件(日期、PMID、DOI),始终 AND
# 6. [DP] → 年份/日期范围
dp_negated = "DP" in getattr(pp, 'negated_date_ranges', set())
dp_conds = []
if pp.year_from is not None:
dp_conds.append(GlobalLiterature.pub_year >= pp.year_from)
if pp.year_to is not None:
dp_conds.append(GlobalLiterature.pub_year <= pp.year_to)
if pp.date_from:
from datetime import date as _dt_date
try:
dp_conds.append(GlobalLiterature.pub_date >= _dt_date.fromisoformat(pp.date_from))
except ValueError:
pass
if pp.date_to:
from datetime import date as _dt_date
try:
dp_conds.append(GlobalLiterature.pub_date <= _dt_date.fromisoformat(pp.date_to))
except ValueError:
pass
if dp_conds:
cond = and_(*dp_conds) if len(dp_conds) > 1 else dp_conds[0]
conditions.append(not_(cond) if dp_negated else cond)
elif dp_negated:
# negated_date_ranges includes DP but no conditions built — edge case guard
pass
# 6b. [EDAT] [CRDT] [MHDA] [LR] [DCOM] [DEP] → 日期字段范围
DATE_FIELD_COLS = {
"edat": (GlobalLiterature.entrez_date, "EDAT"),
"crdt": (GlobalLiterature.create_date, "CRDT"),
"mhda": (GlobalLiterature.meshed_date, "MHDA"),
"lr": (GlobalLiterature.pubmed_revised, "LR"),
"dcom": (GlobalLiterature.date_completed, "DCOM"),
"dep": (GlobalLiterature.pub_date, "DEP"), # P1-2: [DEP] → pub_date
}
for prefix, (col, field_tag) in DATE_FIELD_COLS.items():
_from = getattr(pp, f"{prefix}_from", None)
_to = getattr(pp, f"{prefix}_to", None)
field_conds = []
if _from:
from datetime import date as _dt_date
try:
field_conds.append(col >= _dt_date.fromisoformat(_from))
except ValueError:
pass
if _to:
from datetime import date as _dt_date
try:
field_conds.append(col <= _dt_date.fromisoformat(_to))
except ValueError:
pass
if field_conds:
cond = and_(*field_conds) if len(field_conds) > 1 else field_conds[0]
negated = field_tag in getattr(pp, 'negated_date_ranges', set())
conditions.append(not_(cond) if negated else cond)
# 7. [PMID] → 精确匹配,支持 is_not
for term in pp.pmid_terms:
cond = GlobalLiterature.pmid == int(term.text)
if term.is_not:
cond = not_(cond)
conditions.append(cond)
# 8. [DOI] → ILIKE,支持 is_not
for term in pp.doi_terms:
cond = GlobalLiterature.doi.ilike(f"%{_escape_ilike(term.text)}%")
if term.is_not:
cond = not_(cond)
conditions.append(cond)
# P4: [PMC] → pmc_id 精确匹配,支持 is_not
for term in pp.pmc_terms:
cond = GlobalLiterature.pmc_id == term.text
if term.is_not:
cond = not_(cond)
conditions.append(cond)
return conditions
@staticmethod
async def _single_term_condition(db: AsyncSession, term) -> object | None:
"""将单个解析后的 term 转为 SQLAlchemy condition,处理所有字段类型。
P0-3: 确保括号组内的特殊字段(MH/PT/GR/RN 等)不被降级到 "all",走正确的条件生成。
"""
from sqlalchemy.dialects.postgresql import JSONB
field = term.field
# 简单 ILIKE 字段
if field in ("title", "abstract", "all", "author", "journal", "affiliation",
"language", "volume", "issue", "pages", "lid") or field is None:
return AdvancedSearchEngine._field_condition(field or "all", term.text, term.exact)
# 特殊字段 — 与 _pubmed_conditions 中 top-level dispatch 一致
if field == "MH":
return await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=False, noexp=term._noexp)
if field == "MAJR":
return await AdvancedSearchEngine._expand_mesh_tag_ids(db, [term.text], major_only=True)
if field == "PT":
return GlobalLiterature.pub_types.cast(JSONB).contains([term.text])
if field == "PMID":
try:
return GlobalLiterature.pmid == int(term.text)
except ValueError:
return None
if field == "DOI":
return GlobalLiterature.doi.ilike(f"%{_escape_ilike(term.text)}%")
if field == "GR":
return GlobalLiterature.grants.cast(JSONB).contains([{"grant_id": term.text}])
if field == "SH":
return GlobalLiterature.mesh_headings.cast(JSONB).contains([{"qualifiers": [term.text]}])
if field == "RN":
return GlobalLiterature.chemical_list.cast(JSONB).contains([{"registry_number": term.text}])
if field == "NM":
return GlobalLiterature.chemical_list.cast(JSONB).contains([{"name": term.text}])
if field == "SI":
return GlobalLiterature.databank_list.cast(JSONB).contains([{"accession_numbers": [term.text]}])
if field == "PA":
return GlobalLiterature.pharmacological_actions.cast(JSONB).contains([{"name": term.text}])
if field == "ED":
return GlobalLiterature.authors.cast(JSONB).contains([{"type": "editor", "family": term.text}])
if field == "IR":
return GlobalLiterature.investigators.cast(JSONB).contains([{"family": term.text}])
if field == "PS":
return GlobalLiterature.personal_name_subjects.cast(JSONB).contains([{"family": term.text}])
if field == "PUBN":
return cast(GlobalLiterature.publication_notes, String).ilike(f"%{_escape_ilike(term.text)}%")
if field == "AUID":
return GlobalLiterature.auid_data.cast(JSONB).contains([{"value": term.text}])
if field == "COIS":
return GlobalLiterature.cois_statement.ilike(f"%{_escape_ilike(term.text)}%")
if field == "TT":
return GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(term.text)}%")
if field == "SB":
val = term.text.upper()
if val == "PUBMED":
return text("TRUE") # no-op: 所有记录都是 PubMed
elif val == "MEDLINE":
return GlobalLiterature.citation_status == "medline"
elif len(val) == 1 and val.isalpha():
subq = select(GlobalJournal.issn).where(
GlobalJournal.nlm_subsets.overlap([val])
)
return GlobalLiterature.journal_issn.in_(subq)
else:
return GlobalLiterature.citation_status == val.lower()
if field == "STAT":
return GlobalLiterature.citation_status == term.text.lower()
if field == "UID":
try:
return GlobalLiterature.pmid == int(term.text)
except ValueError:
return GlobalLiterature.doi.ilike(f"%{_escape_ilike(term.text)}%")
# P4: [OT] → keywords JSONB contains
if field == "OT":
return GlobalLiterature.keywords.cast(JSONB).contains([term.text])
# P4: [GEN] → gene_symbols JSONB contains
if field == "GEN":
return GlobalLiterature.gene_symbols.cast(JSONB).contains([term.text])
# P4: [PMC] → pmc_id 精确匹配
if field == "PMC":
return GlobalLiterature.pmc_id == term.text
# 回退
return AdvancedSearchEngine._field_condition("all", term.text, term.exact)
@staticmethod
def _field_condition(field: str, term: str, exact: bool) -> callable:
_wildcard = term.endswith('*') and not exact
_stem = term[:-1] if _wildcard else term
_escaped = _escape_ilike(_stem)
def _pt() -> str:
"""P2-1: wildcard → 右截断 ILIKE;其他 → 双百搭"""
return f"{_escaped}%" if _wildcard else f"%{_escaped}%"
if field == "title":
return GlobalLiterature.title.ilike(_pt())
elif field == "abstract":
return GlobalLiterature.abstract.ilike(_pt())
elif field == "author":
return GlobalLiterature.author_names_text.ilike(_pt())
elif field == "journal":
pat = _pt()
return or_(
GlobalLiterature.journal.ilike(pat),
GlobalLiterature.journal_iso.ilike(pat),
)
elif field == "affiliation":
return cast(GlobalLiterature.authors, String).ilike(_pt())
elif field == "language":
return GlobalLiterature.language.ilike(_pt())
elif field == "volume":
return GlobalLiterature.volume.ilike(_pt())
elif field == "issue":
return GlobalLiterature.issue.ilike(_pt())
elif field == "pages":
return GlobalLiterature.pages.ilike(_pt())
elif field == "lid":
pat = _pt()
return or_(
GlobalLiterature.doi.ilike(pat),
GlobalLiterature.pmc_id.ilike(pat),
)
else: # "all" default
pat = _pt()
if exact and not _wildcard:
# P4: 精确短语 → phraseto_tsquery(利用 GIN 索引,保留词序)
return GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term))
if _wildcard:
# wildcard → ILIKE 右截断(tsvector 不支持 *),多字段覆盖
return or_(
GlobalLiterature.title.ilike(pat),
GlobalLiterature.abstract.ilike(pat),
cast(GlobalLiterature.pmid, String).ilike(pat),
GlobalLiterature.doi.ilike(pat),
)
like_val = f"%{_escaped}%"
if "/" in term:
if term.startswith("10."):
return or_(
GlobalLiterature.doi.ilike(_escape_ilike(term)),
GlobalLiterature.doi.ilike(like_val),
)
return or_(
GlobalLiterature.title.ilike(like_val),
cast(GlobalLiterature.pmid, String).ilike(like_val),
GlobalLiterature.doi.ilike(like_val),
)
# P7-D2: Chinese → ILIKE fallback (tsvector is English-only)
if re.search(r'[一-鿿㐀-䶿豈-﫿]', term):
return or_(
GlobalLiterature.title.ilike(like_val),
GlobalLiterature.abstract.ilike(like_val),
)
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
@staticmethod
async def _expand_mesh_tag_ids(
db: AsyncSession,
mesh_names: list[str],
major_only: bool = False,
noexp: bool = False, # P1-4: [MH:noexp] 抑制树展开
) -> object | None:
"""用 GlobalTagTreeNumber 展开 [MH]/[MAJR]
1. 先精确入口词匹配(entry_terms JSONB @>),再退到 name_en ILIKE
2. 用 tree_number 前缀展开子节点(C04.588 → 所有子树号)
3. [MAJR] 额外 AND is_major=True
返回 SQLAlchemy condition 或 None(无匹配时)。
noexp=True 时跳过第 2 步(树展开),只搜精确词。
"""
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)— 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(_escape_ilike(m)))
try:
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),
or_(*entry_conds),
)
)).all()
for (tid,) in rows:
mesh_tag_ids.add(tid)
if name_conds:
rows = (await db.execute(
select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.mesh_ui.isnot(None),
or_(*name_conds),
)
)).all()
for (tid,) in rows:
mesh_tag_ids.add(tid)
except Exception:
logger.exception("MeSH tag lookup failed for mesh_names=%s", mesh_names[:5])
if not mesh_tag_ids:
return None
# tree_number 前缀展开:取匹配 tag 的所有 tree_number,查子节点([MH:noexp] 时跳过)
if not noexp:
try:
tns = (await db.execute(
select(GlobalTagTreeNumber.tree_number).where(
GlobalTagTreeNumber.tag_id.in_(list(mesh_tag_ids))
).distinct()
)).scalars().all()
if tns:
child_conds = [or_(
GlobalTagTreeNumber.tree_number == tn,
GlobalTagTreeNumber.tree_number.like(f"{tn}.%"),
) for tn in tns]
children = (await db.execute(
select(GlobalTagTreeNumber.tag_id).where(or_(*child_conds))
)).scalars().all()
mesh_tag_ids.update(children)
except Exception:
logger.exception("Tree number expansion failed for mesh_names=%s", mesh_names[:5])
uids = list(mesh_tag_ids)
if major_only:
subq = select(func.distinct(GlobalLiteratureTag.literature_id)).where(
GlobalLiteratureTag.tag_id.in_(uids),
GlobalLiteratureTag.is_major == True,
)
else:
subq = select(func.distinct(GlobalLiteratureTag.literature_id)).where(
GlobalLiteratureTag.tag_id.in_(uids),
)
return GlobalLiterature.id.in_(subq)
@staticmethod
def _best_match_order(tsq):
"""构建 best_match 排序表达式:ts_rank + 引用数对数 + 近期度梯度加分
近期度加分通过 EXTRACT(YEAR FROM NOW()) 动态计算,分三档:
- 当年: +10
- 去年: +7
- 前年: +3
"""
_cy = func.extract("year", func.now())
score = (
func.ts_rank(GlobalLiterature.search_tsv, tsq) * 0.3
+ func.ln(func.coalesce(GlobalLiterature.cited_by_count, 0) + 1) * 2
+ case(
(GlobalLiterature.pub_year >= _cy, 10),
(GlobalLiterature.pub_year >= _cy - 1, 7),
(GlobalLiterature.pub_year >= _cy - 2, 3),
else_=0,
)
)
return score.desc()
@staticmethod
def _apply_order_by(sort: str, relevance_query: str):
"""Return a list of order_by expressions for the given sort mode."""
if sort == "date":
return [GlobalLiterature.pub_date.desc().nullslast(), GlobalLiterature.id.desc()]
elif sort == "cited":
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)]
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()]
elif sort == "first_author":
return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()]
elif sort == "journal":
return [GlobalLiterature.journal.asc().nullslast()]
elif sort == "title":
return [GlobalLiterature.title.asc().nullslast()]
else:
return [GlobalLiterature.pub_date.desc().nullslast(), GlobalLiterature.id.desc()]