chore: batch commit remaining changes
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
This commit is contained in:
34047007@qq.com
2026-07-27 08:35:12 +08:00
parent 35b0a5c565
commit 62ca8fa6b8
82 changed files with 314248 additions and 1519 deletions
+661
View File
@@ -0,0 +1,661 @@
"""高级搜索服务:布尔运算 + 字段限定 + 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 独立 subqAND 组合)
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}%")
# 只用 tsqueryGIN 索引),不掺 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),
)
# 只用 tsqueryGIN 索引),不掺 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()