Files
backend/backend/app/services/query_expansion.py
T
34047007@qq.com 275a9f6511 fix: 第五轮PubMed搜索审计 — 4项边缘案例修复 + 第5轮审计文档
Parser边缘案例修复:
- P5-1: 尾部NOT导致IndexError降级(_parse_not_expr添加EOF守卫)
- P5-2: 单数字日期格式不被识别(YYYY-M-D→YYYY-MM-DD归一化)
- P5-3: is_pubmed_syntax不处理全角字符(增加NFKC归一化)
- P5-4: extract_pubmed_query_for_prisma不能处理[Title/Article]

同时将在前几轮修复的F1-F8([MH:noexp]顶层支持、NOT De Morgan、
NOT NOT递归、Custom Range日期、HomeView恢复、precision_mode清理、
is_oa注释、N+1批量优化)补充文档核对验证。
2026-07-27 11:55:03 +08:00

202 lines
6.8 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.
"""ATM 自动术语映射引擎 — 让普通搜索框实现 PubMed 级别的 MeSH 展开
流程:
1. MeSH Translation: exact entry term match (@>) → name_en ILIKE → per-word fallback
2. Tree Expansion: tree_number LIKE 前缀展开到所有子节点
3. SQL Condition: GlobalLiterature.id IN (tag_ids) — OR 合并 tsvector
设计原则:
- 与 PubMed ATM 一致:先精确入口词匹配,再逐词拆解
- entry_terms 已全部小写标准化,用 @> 做精确 JSONB 元素匹配
杜绝 "breast cancer" 误匹配 "Breast Cancer Anti-Estrogen Resistance 1 Protein" 等问题
用法:
cond = await expand_atm(db, "lung cancer")
if cond is not None:
conditions.append(sa.or_(cond, tsvector_cond))
"""
import logging
import re
from uuid import UUID
from sqlalchemy import or_ as _or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.literature import GlobalTag, GlobalTagTreeNumber, GlobalLiteratureTag, GlobalLiterature
logger = logging.getLogger(__name__)
MIN_QUERY_LENGTH = 2
def _escape_ilike(s: str) -> str:
"""转义 ILIKE 模式中的通配符 _ 和 %"""
if not s:
return s
return s.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
async def expand_atm(db: AsyncSession, query: str):
"""ATM 展开:输入纯文本查询,返回 SQLAlchemy condition 或 None"""
q = query.strip().replace('"', '').replace("'", '')
if not q or len(q) < MIN_QUERY_LENGTH:
return None
tag_ids = await _find_mesh_tags(db, q)
if not tag_ids:
tag_ids = await _find_partial_mesh_tags(db, q)
if not tag_ids:
return None
expanded_ids = await _expand_tree_numbers(db, tag_ids)
if not expanded_ids:
return None
return GlobalLiterature.id.in_(
select(GlobalLiteratureTag.literature_id)
.where(GlobalLiteratureTag.tag_id.in_(expanded_ids))
.distinct()
)
async def _find_mesh_tags(db: AsyncSession, query: str) -> list[UUID]:
"""通过精确入口词匹配 + name_en ILIKE 找 MeSH 标签"""
q = query.strip().lower()
seen: set[UUID] = set()
tag_ids: list[UUID] = []
# 方法 A: JSONB @> 精确元素匹配
# entry_terms 已全小写存储,可做 case-insensitive 精确匹配
# 只有 query 是 entry_terms 数组中的完整元素时才匹配
# 如 "breast cancer" 只匹配元素 "breast cancer",不匹配 "breast cancer anti-estrogen resistance 1 protein"
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.mesh_ui.isnot(None),
GlobalTag.entry_terms.isnot(None),
GlobalTag.entry_terms.contains([q]),
)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
tag_ids.append(tid)
seen.add(tid)
# 方法 B: name_en ILIKE 匹配(P1-3: 加 LIMIT 100 防止常见词匹配过多)
like_pattern = f"%{_escape_ilike(query)}%"
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.name_en.ilike(like_pattern),
).limit(100)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
tag_ids.append(tid)
seen.add(tid)
# 方法 C: name_zh ILIKE 匹配(P0-4: 中文查询降级)
if not tag_ids and re.search(r'[一-鿿㐀-䶿豈-﫿]', q):
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.name_zh.ilike(like_pattern),
)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
tag_ids.append(tid)
seen.add(tid)
return tag_ids
async def _find_partial_mesh_tags(db: AsyncSession, query: str) -> list[UUID]:
"""降级匹配:多词查询按单词分别匹配,支持跨标签组合(如 "egfr mutation" → EGFR + Mutation
PubMed 行为:当短语无法匹配 MeSH 入口词时,逐词尝试 MeSH 翻译。
"""
words = [w.strip().lower() for w in query.strip().split() if len(w.strip()) >= MIN_QUERY_LENGTH][:10]
if len(words) < 2:
# P0-4: 中文查询无法按空格分词,尝试整体 name_zh ILIKE 匹配
if re.search(r'[一-鿿㐀-䶿豈-﫿]', query):
tag_ids = []
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.name_zh.ilike(f'%{_escape_ilike(query.strip().lower())}%'),
)
rows = await db.execute(stmt)
for (tid,) in rows:
tag_ids.append(tid)
return tag_ids
return [] # non-Chinese single word: no partial match possible
seen: set[UUID] = set()
tag_ids: list[UUID] = []
# 批量精确入口词匹配(一次查询替代 N 次)
entry_terms_conds = []
name_ilike_conds = []
for word in words:
entry_terms_conds.append(
GlobalTag.entry_terms.contains([word])
)
name_ilike_conds.append(GlobalTag.name_en.ilike(f"%{_escape_ilike(word)}%"))
# 一次查询所有词的精确入口词匹配
if entry_terms_conds:
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.mesh_ui.isnot(None),
GlobalTag.entry_terms.isnot(None),
_or_(*entry_terms_conds),
)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
tag_ids.append(tid)
seen.add(tid)
# 一次查询所有词的 name_en ILIKE
if name_ilike_conds:
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
_or_(*name_ilike_conds),
)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
tag_ids.append(tid)
seen.add(tid)
return tag_ids
async def _expand_tree_numbers(db: AsyncSession, tag_ids: list[UUID]) -> list[UUID]:
"""通过树号展开到所有子节点"""
rows = await db.execute(
select(GlobalTagTreeNumber.tree_number, GlobalTagTreeNumber.tag_id)
.where(GlobalTagTreeNumber.tag_id.in_(tag_ids))
)
tree_map: dict[str, list[UUID]] = {}
for tn, tid in rows:
tree_map.setdefault(tn, []).append(tid)
if not tree_map:
return tag_ids
expanded = set(tag_ids)
# 一次查询所有 tree_number 的子节点(用 OR 合并)
tn_conds = [_or_(
GlobalTagTreeNumber.tree_number == tn,
GlobalTagTreeNumber.tree_number.like(f"{tn}.%"),
) for tn in tree_map]
rows = await db.execute(
select(GlobalTagTreeNumber.tag_id)
.where(_or_(*tn_conds))
.distinct()
)
for (tid,) in rows:
expanded.add(tid)
return list(expanded)