662 lines
30 KiB
Python
662 lines
30 KiB
Python
"""高级搜索服务:布尔运算 + 字段限定 + PubMed 查询语法"""
|
||||
|
|
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from sqlalchemy import String, and_, case, cast, func, literal_column, not_, or_, select
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
|
|||
|
|
from app.models.literature import GlobalJournal, GlobalLiterature, GlobalLiteratureTag, GlobalTag, GlobalTagTreeNumber
|
|||
|
|
from app.schemas.literature import cap_pub_date
|
|||
|
|
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
study_design: str | None, tag_ids: list[str] | None,
|
|||
|
|
retracted: str, negative_result: str,
|
|||
|
|
is_oa: bool | None, language: str | None, nlm_subsets: list[str] | None,
|
|||
|
|
page: int, page_size: int, sort: str,
|
|||
|
|
) -> 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 [],
|
|||
|
|
"sd": study_design,
|
|||
|
|
"tid": sorted(tag_ids) if tag_ids else [],
|
|||
|
|
"r": retracted, "nr": negative_result,
|
|||
|
|
"oa": is_oa, "lang": language,
|
|||
|
|
"ns": sorted(nlm_subsets) if nlm_subsets else [],
|
|||
|
|
"p": page, "ps": page_size, "s": sort,
|
|||
|
|
}
|
|||
|
|
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,
|
|||
|
|
study_design: str | None = None, # primary category: interventional / observational / synthesis / etc
|
|||
|
|
tag_ids: list[str] | None = None,
|
|||
|
|
retracted: str = "", # "yes", "no", "only"
|
|||
|
|
negative_result: str = "", # "yes", "no", "only"
|
|||
|
|
is_oa: bool | None = None, # True = 仅开放获取
|
|||
|
|
language: str | None = None, # 语言代码(en/zh/fr 等)
|
|||
|
|
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)
|
|||
|
|
) -> dict:
|
|||
|
|
"""执行高级搜索"""
|
|||
|
|
conditions = []
|
|||
|
|
|
|||
|
|
# 60s 缓存(仅 page 模式,cursor 游标不重复)
|
|||
|
|
use_cursor = (cursor_date is not None and cursor_id is not None and sort == "date")
|
|||
|
|
_cache = None
|
|||
|
|
_search_cache_key = None
|
|||
|
|
if not use_cursor:
|
|||
|
|
from app.core.cache import cache as _cache
|
|||
|
|
_search_cache_key = AdvancedSearchEngine._search_cache_key(
|
|||
|
|
query, field, boolean, exact_phrase,
|
|||
|
|
year_from, year_to, date_from, date_to,
|
|||
|
|
journal_tiers, pub_types, study_design, tag_ids,
|
|||
|
|
retracted, negative_result,
|
|||
|
|
is_oa, language, nlm_subsets,
|
|||
|
|
page, page_size, sort,
|
|||
|
|
)
|
|||
|
|
cached = await _cache.get(_search_cache_key)
|
|||
|
|
if cached is not None:
|
|||
|
|
return cached
|
|||
|
|
|
|||
|
|
# ─── PubMed 语法检测与解析 ───
|
|||
|
|
_pubmed_parsed = None
|
|||
|
|
_is_flat_text = True # 是否走传统 tsvector + ILIKE 路径
|
|||
|
|
if query.strip():
|
|||
|
|
from app.services.pubmed_query_parser import is_pubmed_syntax, parse_pubmed_query
|
|||
|
|
|
|||
|
|
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.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():
|
|||
|
|
# 中文搜索:自动匹配 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'%{query.strip()}%'))
|
|||
|
|
)).scalars().all()
|
|||
|
|
if tag_matches:
|
|||
|
|
existing = set(tag_ids or [])
|
|||
|
|
tag_ids = list(existing | {str(t.id) for t in tag_matches})
|
|||
|
|
query = "" # ILIKE 对中文标题/摘要无效,跳过
|
|||
|
|
if query.strip():
|
|||
|
|
terms = [t.strip() for t in query.split() if t.strip()]
|
|||
|
|
# 单数字词:优先 PMID 精确匹配(unique index 5ms 返回)
|
|||
|
|
# 不是 PMID 时才回退到 ILIKE 兜底(DOI 片段等),不做 tsquery 避免 seq scan
|
|||
|
|
numeric_terms = [t for t in terms if t.isdigit() and len(t) <= 15]
|
|||
|
|
text_terms = [t for t in terms if not (t.isdigit() and len(t) <= 15)]
|
|||
|
|
for t in numeric_terms:
|
|||
|
|
p = int(t)
|
|||
|
|
exists = (await db.execute(
|
|||
|
|
select(GlobalLiterature.id).where(GlobalLiterature.pmid == p).limit(1)
|
|||
|
|
)).scalar_one_or_none()
|
|||
|
|
if exists:
|
|||
|
|
conditions.append(GlobalLiterature.pmid == p)
|
|||
|
|
else:
|
|||
|
|
# 不是 PMID → ILIKE 搜索(DOI 等),不做 tsquery 避免 seq scan
|
|||
|
|
p_like = t if exact_phrase else f"%{t}%"
|
|||
|
|
conditions.append(or_(
|
|||
|
|
GlobalLiterature.title.ilike(p_like),
|
|||
|
|
GlobalLiterature.doi.ilike(p_like),
|
|||
|
|
))
|
|||
|
|
if text_terms:
|
|||
|
|
# ATM 展开(仅非中文查询)
|
|||
|
|
_atm_cond = None
|
|||
|
|
if not _CHINESE_RE.search(query):
|
|||
|
|
from app.services.query_expansion import expand_atm as _expand_atm
|
|||
|
|
_atm_cond = await _expand_atm(db, query.strip())
|
|||
|
|
|
|||
|
|
_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:
|
|||
|
|
conditions.append(GlobalLiterature.pub_year >= year_from)
|
|||
|
|
if year_to:
|
|||
|
|
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 issns:
|
|||
|
|
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:
|
|||
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|||
|
|
type_conds = [GlobalLiterature.pub_types.cast(JSONB).contains([pt]) for pt in pub_types]
|
|||
|
|
conditions.append(or_(*type_conds))
|
|||
|
|
|
|||
|
|
# 研究设计分类过滤(PG JSON path access)
|
|||
|
|
if study_design:
|
|||
|
|
conditions.append(
|
|||
|
|
GlobalLiterature.study_design['primary'].astext == study_design
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 撤稿过滤
|
|||
|
|
if retracted == "yes":
|
|||
|
|
conditions.append(GlobalLiterature.retracted == True)
|
|||
|
|
elif retracted == "no":
|
|||
|
|
conditions.append(GlobalLiterature.retracted == False)
|
|||
|
|
elif retracted == "only":
|
|||
|
|
conditions.append(GlobalLiterature.retracted == True)
|
|||
|
|
|
|||
|
|
# 阴性结果过滤
|
|||
|
|
if negative_result == "yes":
|
|||
|
|
conditions.append(GlobalLiterature.is_negative_result == True)
|
|||
|
|
elif negative_result == "no":
|
|||
|
|
conditions.append(GlobalLiterature.is_negative_result == False)
|
|||
|
|
elif negative_result == "only":
|
|||
|
|
conditions.append(GlobalLiterature.is_negative_result == True)
|
|||
|
|
|
|||
|
|
# 开放获取
|
|||
|
|
if is_oa is not None:
|
|||
|
|
conditions.append(GlobalLiterature.is_oa == is_oa)
|
|||
|
|
|
|||
|
|
# 语言
|
|||
|
|
if 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 issns:
|
|||
|
|
conditions.append(GlobalLiterature.journal_issn.in_(issns))
|
|||
|
|
|
|||
|
|
# ── 按年份统计(Results by year,与筛选条件一致) ──
|
|||
|
|
year_counts = []
|
|||
|
|
if conditions and (query.strip() or year_from or year_to or date_from or date_to
|
|||
|
|
or journal_tiers or pub_types or study_design or tag_ids
|
|||
|
|
or retracted or negative_result or is_oa is not None
|
|||
|
|
or language or nlm_subsets):
|
|||
|
|
try:
|
|||
|
|
yr_subq = select(GlobalLiterature.pub_year).where(
|
|||
|
|
and_(*conditions)
|
|||
|
|
).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:
|
|||
|
|
year_counts = [] # 构建查询
|
|||
|
|
q = select(GlobalLiterature)
|
|||
|
|
if conditions:
|
|||
|
|
q = q.where(and_(*conditions))
|
|||
|
|
|
|||
|
|
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
|
|||
|
|
_relevance_query = query
|
|||
|
|
if _pubmed_parsed and sort == "relevance":
|
|||
|
|
# 用纯文本词做相关性排序,去掉 [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 ""
|
|||
|
|
if sort == "date":
|
|||
|
|
q = q.order_by(GlobalLiterature.pub_date.desc().nullslast())
|
|||
|
|
elif sort == "cited":
|
|||
|
|
q = q.order_by(GlobalLiterature.cited_by_count.desc().nullslast())
|
|||
|
|
elif sort == "best_match" and _relevance_query.strip():
|
|||
|
|
tsq = func.plainto_tsquery("english", _relevance_query)
|
|||
|
|
q = q.order_by(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)
|
|||
|
|
q = q.order_by(rank.desc())
|
|||
|
|
else:
|
|||
|
|
q = q.order_by(GlobalLiterature.pub_date.desc().nullslast())
|
|||
|
|
|
|||
|
|
# keyset 游标分页(仅 date 排序支持,page 参数被忽略,不做 COUNT)
|
|||
|
|
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))
|
|||
|
|
if sort == "date":
|
|||
|
|
q = q.order_by(GlobalLiterature.pub_date.desc().nullslast())
|
|||
|
|
elif sort == "cited":
|
|||
|
|
q = q.order_by(GlobalLiterature.cited_by_count.desc().nullslast())
|
|||
|
|
elif sort == "best_match" and _relevance_query.strip():
|
|||
|
|
tsq = func.plainto_tsquery("english", _relevance_query)
|
|||
|
|
q = q.order_by(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)
|
|||
|
|
q = q.order_by(rank.desc())
|
|||
|
|
else:
|
|||
|
|
q = q.order_by(GlobalLiterature.pub_date.desc().nullslast())
|
|||
|
|
|
|||
|
|
# 分页
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# ── date 排序优化 ──
|
|||
|
|
# PG 对常见词(如 "cancer" >80% 匹配率)会选 Parallel Seq Scan(~22s)。
|
|||
|
|
# 嵌入子查询先取最近 N 条 ID(走 ix_gl_pub_date 索引),迫使 PG
|
|||
|
|
# 使用索引而非全表扫描。
|
|||
|
|
# 条件:仅当无可搜索查询且非历史日期筛选时启用,避免丢失早期文献
|
|||
|
|
_has_meaningful_query = bool(query.strip())
|
|||
|
|
_has_historical_filter = year_from is not None and year_from < datetime.now().year - 5
|
|||
|
|
if sort == "date" and conditions and not _has_meaningful_query and not _has_historical_filter:
|
|||
|
|
recent_n = max(offset + page_size * 5, 2000)
|
|||
|
|
recent_subq = select(GlobalLiterature.id).where(
|
|||
|
|
GlobalLiterature.pub_date.isnot(None)
|
|||
|
|
).order_by(GlobalLiterature.pub_date.desc()).limit(recent_n)
|
|||
|
|
data_q = select(GlobalLiterature).where(
|
|||
|
|
GlobalLiterature.id.in_(recent_subq),
|
|||
|
|
and_(*conditions)
|
|||
|
|
).order_by(GlobalLiterature.pub_date.desc().nullslast())
|
|||
|
|
else:
|
|||
|
|
data_q = q
|
|||
|
|
|
|||
|
|
result = await db.execute(data_q.offset(offset).limit(page_size))
|
|||
|
|
items = result.scalars().all()
|
|||
|
|
has_more = (offset + page_size) < total and len(items) == page_size
|
|||
|
|
|
|||
|
|
from app.services.tag_loader import load_tags_for_literature
|
|||
|
|
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 = list(existing_conditions)
|
|||
|
|
|
|||
|
|
# 1. 字段级搜索 [TI] [AB] [TIAB] [AU] [TA]
|
|||
|
|
field_map = {
|
|||
|
|
"title": pp.title_terms,
|
|||
|
|
"abstract": pp.abstract_terms,
|
|||
|
|
"all": pp.tiab_terms,
|
|||
|
|
"author": pp.author_terms,
|
|||
|
|
"journal": pp.journal_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)
|
|||
|
|
# 同 field 内多个 term 按 boolean_operator 组合
|
|||
|
|
if pp.boolean_operator == "or":
|
|||
|
|
conditions.append(or_(*field_conds))
|
|||
|
|
else:
|
|||
|
|
conditions.extend(field_conds) # and_() 由外部统一组合
|
|||
|
|
|
|||
|
|
# 2. 纯文本词(无字段标签)
|
|||
|
|
if pp.plain_terms:
|
|||
|
|
if pp.boolean_operator == "or":
|
|||
|
|
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:
|
|||
|
|
conditions.append(or_(*plain_conds))
|
|||
|
|
else:
|
|||
|
|
for term in pp.plain_terms:
|
|||
|
|
cond = AdvancedSearchEngine._field_condition("all", term.text, term.exact)
|
|||
|
|
if term.is_not:
|
|||
|
|
cond = not_(cond)
|
|||
|
|
conditions.append(cond)
|
|||
|
|
|
|||
|
|
# 3. [MH] → tree_number 展开(逐 term 独立 subq,AND 组合)
|
|||
|
|
if pp.mesh_terms:
|
|||
|
|
if pp.boolean_operator == "or":
|
|||
|
|
conditions = await AdvancedSearchEngine._expand_mesh_tag_ids(
|
|||
|
|
db, pp.mesh_terms, conditions)
|
|||
|
|
else:
|
|||
|
|
for m in pp.mesh_terms:
|
|||
|
|
conditions = await AdvancedSearchEngine._expand_mesh_tag_ids(
|
|||
|
|
db, [m], conditions)
|
|||
|
|
|
|||
|
|
# 4. [MAJR] → tree_number 展开 + is_major=True(逐 term 独立 subq)
|
|||
|
|
if pp.majr_terms:
|
|||
|
|
if pp.boolean_operator == "or":
|
|||
|
|
conditions = await AdvancedSearchEngine._expand_mesh_tag_ids(
|
|||
|
|
db, pp.majr_terms, conditions, major_only=True)
|
|||
|
|
else:
|
|||
|
|
for m in pp.majr_terms:
|
|||
|
|
conditions = await AdvancedSearchEngine._expand_mesh_tag_ids(
|
|||
|
|
db, [m], conditions, major_only=True)
|
|||
|
|
|
|||
|
|
# 5. [PT] → pub_types JSONB contains
|
|||
|
|
if pp.pub_types:
|
|||
|
|
type_conds = [
|
|||
|
|
GlobalLiterature.pub_types.cast(JSONB).contains([pt])
|
|||
|
|
for pt in pp.pub_types
|
|||
|
|
]
|
|||
|
|
conditions.append(or_(*type_conds))
|
|||
|
|
|
|||
|
|
# 5. [DP] → 年份/日期范围
|
|||
|
|
if pp.year_from:
|
|||
|
|
conditions.append(GlobalLiterature.pub_year >= pp.year_from)
|
|||
|
|
if pp.year_to:
|
|||
|
|
conditions.append(GlobalLiterature.pub_year <= pp.year_to)
|
|||
|
|
if pp.date_from:
|
|||
|
|
from datetime import date as _dt_date
|
|||
|
|
try:
|
|||
|
|
conditions.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:
|
|||
|
|
conditions.append(GlobalLiterature.pub_date <= _dt_date.fromisoformat(pp.date_to))
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 6. [PMID] → 精确匹配
|
|||
|
|
for pmid_val in pp.pmid_terms:
|
|||
|
|
conditions.append(GlobalLiterature.pmid == pmid_val)
|
|||
|
|
|
|||
|
|
# 7. [DOI] → ILIKE
|
|||
|
|
for doi_term in pp.doi_terms:
|
|||
|
|
conditions.append(GlobalLiterature.doi.ilike(f"%{doi_term}%"))
|
|||
|
|
|
|||
|
|
return conditions
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _field_condition(field: str, term: str, exact: bool) -> callable:
|
|||
|
|
if field == "title":
|
|||
|
|
if exact:
|
|||
|
|
return GlobalLiterature.title.ilike(term if exact else f"%{term}%")
|
|||
|
|
# 只用 tsquery(GIN 索引),不掺 ILIKE——OR 会让 PG 放弃 GIN 索引走 seq scan
|
|||
|
|
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
|
|||
|
|
elif field == "abstract":
|
|||
|
|
if exact:
|
|||
|
|
return GlobalLiterature.abstract.ilike(term if exact else f"%{term}%")
|
|||
|
|
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
|
|||
|
|
elif field == "author":
|
|||
|
|
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
|
|||
|
|
elif field == "journal":
|
|||
|
|
return or_(
|
|||
|
|
GlobalLiterature.journal.ilike(f"%{term}%"),
|
|||
|
|
GlobalLiterature.journal_iso.ilike(f"%{term}%"),
|
|||
|
|
)
|
|||
|
|
elif field == "affiliation":
|
|||
|
|
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
|
|||
|
|
else: # "all" default
|
|||
|
|
like_val = term if exact else f"%{term}%"
|
|||
|
|
if exact:
|
|||
|
|
return or_(
|
|||
|
|
GlobalLiterature.title.ilike(like_val),
|
|||
|
|
cast(GlobalLiterature.pmid, String).ilike(f"%{term}%"),
|
|||
|
|
GlobalLiterature.doi.ilike(like_val),
|
|||
|
|
)
|
|||
|
|
# 含 "/" → 跳过 tsquery
|
|||
|
|
if "/" in term:
|
|||
|
|
if term.startswith("10."):
|
|||
|
|
return or_(
|
|||
|
|
GlobalLiterature.doi == term,
|
|||
|
|
GlobalLiterature.doi.ilike(like_val),
|
|||
|
|
)
|
|||
|
|
return or_(
|
|||
|
|
GlobalLiterature.title.ilike(like_val),
|
|||
|
|
cast(GlobalLiterature.pmid, String).ilike(f"%{term}%"),
|
|||
|
|
GlobalLiterature.doi.ilike(like_val),
|
|||
|
|
)
|
|||
|
|
# 只用 tsquery(GIN 索引),不掺 ILIKE——OR 会让 PG 放弃 GIN 索引走 seq scan
|
|||
|
|
return GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term))
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
async def _expand_mesh_tag_ids(
|
|||
|
|
db: AsyncSession,
|
|||
|
|
mesh_names: list[str],
|
|||
|
|
conditions: list,
|
|||
|
|
major_only: bool = False,
|
|||
|
|
) -> list:
|
|||
|
|
"""用 GlobalTagTreeNumber 展开 [MH]/[MAJR]
|
|||
|
|
|
|||
|
|
1. ILIKE 匹配 mesh_names → tag_ids
|
|||
|
|
2. 用 tree_number 前缀展开子节点(C04.588 → 所有子树号)
|
|||
|
|
3. [MAJR] 额外 AND is_major=True
|
|||
|
|
"""
|
|||
|
|
import uuid as _uuid
|
|||
|
|
mesh_conds = [GlobalTag.name_en.ilike(f"%{m}%") for m in mesh_names]
|
|||
|
|
tag_rows = (await db.execute(
|
|||
|
|
select(GlobalTag.id).where(or_(*mesh_conds))
|
|||
|
|
)).all()
|
|||
|
|
if not tag_rows:
|
|||
|
|
return conditions
|
|||
|
|
|
|||
|
|
mesh_tag_ids: set[_uuid.UUID] = set(tid for (tid,) in tag_rows)
|
|||
|
|
|
|||
|
|
# tree_number 前缀展开:取匹配 tag 的所有 tree_number,查子节点
|
|||
|
|
tns = (await db.execute(
|
|||
|
|
select(GlobalTagTreeNumber.tree_number).where(
|
|||
|
|
GlobalTagTreeNumber.tag_id.in_(list(mesh_tag_ids))
|
|||
|
|
).distinct()
|
|||
|
|
)).scalars().all()
|
|||
|
|
|
|||
|
|
if tns:
|
|||
|
|
child_conds = [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)
|
|||
|
|
|
|||
|
|
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),
|
|||
|
|
)
|
|||
|
|
conditions.append(GlobalLiterature.id.in_(subq))
|
|||
|
|
return conditions
|
|||
|
|
|
|||
|
|
@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()
|