fix: 第七轮深度搜索审计修复 — 20项
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

- HIGH: isdecimal() → re.match() for PMID (non-ASCII digit crash)
- HIGH: boolean_operator 应用到字段分组和无标签词(AND/OR 错误)
- HIGH: Cursor 分页 pub_date 优先(article_date 导致数据错位)
- Parser: parse 异常处理器清理字段标签/布尔符/引号
- Parser: _parse_range date:year 反向交换(第4分支)
- Parser: MAX_TERMS 保护、TITLE/ABSTRACT 大写键补充
- Engine: best_match 排序剥离字段标签
- Engine: year_from/year_to is not None(年份=0 被 falsy 跳过)
- Engine: _single_term_condition SB 字段全量分发
- Engine: journal_tiers/nlm_subsets 空条件预警(0结果保障)
- Engine: ATM 展开和 _expand_mesh_tag_ids pass → logger.exception
- Engine: 降级 regex [\w/: -] 兼容 [MH:noexp] 冒号
- Frontend: syncSearchToUrl 移到 finally 块
- Frontend: 年份滑块/resetAllFilters 清除 URL 日期
- Frontend: Null cursor 日期安全守卫
- Docs: 新增 P7-1~P7-20 记录,测试数更新至 276
This commit is contained in:
34047007@qq.com
2026-07-27 13:01:52 +08:00
parent 4495ef9e0a
commit 62be0efb59
5 changed files with 200 additions and 29 deletions
+10 -3
View File
@@ -621,6 +621,8 @@ class PubmedQueryParser:
start_val, end_val = end_val, start_val
elif _start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4]):
start_val, end_val = end_val, start_val
elif not _start_is_digit and _end_is_digit and int(start_val[:4]) > int(end_val):
start_val, end_val = end_val, start_val
# 确定两端是否是 4 位年份
_start_is_year = start_val.isdigit() and len(start_val) == 4
_end_is_year = end_val.isdigit() and len(end_val) == 4
@@ -717,10 +719,15 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery:
parser = PubmedQueryParser(tokens)
return parser.parse()
except (ParseError, IndexError, ValueError):
# P0-1: 降级时返回原始查询作为 plain_terms,不丢失用户输入
# P0-1: 降级时清理查询中的 [field] 标签、布尔符、引号和括号
degraded = ParsedPubmedQuery()
for t in query.strip().split():
degraded.plain_terms.append(Term(text=t))
import re as _degrade_re
_clean = _degrade_re.sub(r'\[[\w/: -]+\]', '', query)
_clean = _degrade_re.sub(r'\b(AND|OR|NOT)\b', '', _clean)
_clean = _clean.replace('"', '').replace('(', '').replace(')', '')
for t in _clean.split():
if t.strip():
degraded.plain_terms.append(Term(text=t.strip()))
return degraded
+1 -1
View File
@@ -99,7 +99,7 @@ async def _find_mesh_tags(db: AsyncSession, query: str) -> list[UUID]:
stmt = select(GlobalTag.id).where(
GlobalTag.source.in_(["mesh", "manual"]),
GlobalTag.name_zh.ilike(like_pattern),
)
).limit(100)
rows = await db.execute(stmt)
for (tid,) in rows:
if tid not in seen:
+33 -20
View File
@@ -219,7 +219,7 @@ class AdvancedSearchEngine:
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
]):
# 解析失败但检测到 PubMed 语法 — 擦除 [field] 标签、布尔符、引号
query = re.sub(r'\[[\w/-]+\]', '', query) # P5: [\w/-] 覆盖 [Title/Abstract]
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())
@@ -245,8 +245,8 @@ class AdvancedSearchEngine:
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 t.isdecimal() and len(t) <= 15]
text_terms = [t for t in terms if not (t.isdecimal() and len(t) <= 15)]
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:
@@ -287,6 +287,7 @@ class AdvancedSearchEngine:
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)
@@ -335,8 +336,9 @@ class AdvancedSearchEngine:
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 not issns:
logger.warning("journal_tiers filter matched zero journals: %s", journal_tiers)
conditions.append(GlobalLiterature.journal_issn.in_(issns))
# 标签筛选(含子标签递归)
if tag_ids:
@@ -386,8 +388,9 @@ class AdvancedSearchEngine:
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))
if not issns:
logger.warning("nlm_subsets filter matched zero journals: %s", nlm_subsets)
conditions.append(GlobalLiterature.journal_issn.in_(issns))
# ── PubMed 筛选器 ──
@@ -494,7 +497,7 @@ class AdvancedSearchEngine:
# 排序(PubMed 查询时跳过 ts_rank,避免语法标签噪音)
_relevance_query = query
if _pubmed_parsed and sort == "relevance":
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]
@@ -609,6 +612,7 @@ class AdvancedSearchEngine:
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,
@@ -631,7 +635,7 @@ class AdvancedSearchEngine:
if term.is_not:
cond = not_(cond)
field_conds.append(cond)
term_conditions.append(and_(*field_conds) if len(field_conds) > 1 else field_conds[0])
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:
@@ -648,14 +652,15 @@ class AdvancedSearchEngine:
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 = and_(*plain_conds) if len(plain_conds) > 1 else plain_conds[0]
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(and_(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
else:
term_conditions.append(and_(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
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:
@@ -964,9 +969,9 @@ class AdvancedSearchEngine:
# 6. [DP] → 年份/日期范围
dp_negated = "DP" in getattr(pp, 'negated_date_ranges', set())
dp_conds = []
if pp.year_from:
if pp.year_from is not None:
dp_conds.append(GlobalLiterature.pub_year >= pp.year_from)
if pp.year_to:
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
@@ -1095,10 +1100,18 @@ class AdvancedSearchEngine:
if field == "TT":
return GlobalLiterature.vernacular_title.ilike(f"%{_escape_ilike(term.text)}%")
if field == "SB":
subq = select(GlobalJournal.issn).where(
GlobalJournal.nlm_subsets.overlap([term.text.upper()])
)
return GlobalLiterature.journal_issn.in_(subq)
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":
@@ -1239,7 +1252,7 @@ class AdvancedSearchEngine:
for (tid,) in rows:
mesh_tag_ids.add(tid)
except Exception:
pass
logger.exception("MeSH tag lookup failed for mesh_names=%s", mesh_names[:5])
if not mesh_tag_ids:
return None
@@ -1263,7 +1276,7 @@ class AdvancedSearchEngine:
)).scalars().all()
mesh_tag_ids.update(children)
except Exception:
pass
logger.exception("Tree number expansion failed for mesh_names=%s", mesh_names[:5])
uids = list(mesh_tag_ids)
if major_only: