fix: 第六轮全面搜索审计修复 — 12项
Parser: Title/Abstract大小写匹配、末尾OR空Term保护、tokeniser gap字符恢复、 混合日期范围反转支持 Engine: field标签正则含/、ATM括号剥离、name_zh LIMIT 100、 year_from/ year_to is not null检查 Docs: 更新修复全记录至第六轮
This commit is contained in:
@@ -99,7 +99,7 @@ class AdvancedSearchRequest(BaseModel):
|
||||
@field_validator('sort')
|
||||
@classmethod
|
||||
def check_sort(cls, v: str) -> str:
|
||||
if v not in ('date', 'cited', 'relevance', 'first_author', 'journal', 'title'):
|
||||
if v not in ('date', 'cited', 'best_match', 'relevance', 'first_author', 'journal', 'title'):
|
||||
raise ValueError(f'无效排序方式: {v}')
|
||||
return v
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ _FIELD_TAG_MAP: dict[str, str] = {
|
||||
"LID": "lid",
|
||||
# P4 新增字段标签
|
||||
"Title/Abstract": "all", # [Title/Abstract] 长标签 → all
|
||||
"TITLE/ABSTRACT": "all", # 解析器 .upper() 后的大写版本
|
||||
"OAB": "all", # [OAB] Other Abstract → all
|
||||
"WORD": "all", # [WORD] Word in text → all
|
||||
"FI": "GR", # [FI] Funder Identifier → 同 GR(grant_id)
|
||||
@@ -150,7 +151,16 @@ _TOKEN_RE = re.compile(
|
||||
def tokenise(query: str) -> list[Token]:
|
||||
"""将 PubMed 查询字符串分片为 Token 列表。"""
|
||||
tokens: list[Token] = []
|
||||
last_end = 0
|
||||
for m in _TOKEN_RE.finditer(query):
|
||||
# P5: 检测未匹配的字符(不在任何 token 模式中的字符被静默丢弃)
|
||||
if m.start() > last_end:
|
||||
gap = query[last_end:m.start()]
|
||||
if gap.strip():
|
||||
tokens.append(Token(TokenType.WORD, gap.strip()))
|
||||
if len(tokens) > MAX_TERMS:
|
||||
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
|
||||
last_end = m.end()
|
||||
for name, value in m.groupdict().items():
|
||||
if value is not None:
|
||||
ttype = TokenType[name]
|
||||
@@ -467,6 +477,8 @@ class PubmedQueryParser:
|
||||
left = self._parse_and_expr(result)
|
||||
while self.peek().type == TokenType.OR:
|
||||
self.advance()
|
||||
if self.peek().type == TokenType.EOF:
|
||||
break # trailing OR, ignore silently
|
||||
right = self._parse_and_expr(result)
|
||||
left.extend(right)
|
||||
return left
|
||||
@@ -600,9 +612,14 @@ class PubmedQueryParser:
|
||||
}
|
||||
date_attr, date_attr_to, yr_from_attr, yr_to_attr, marker_field = attr_map[field]
|
||||
# 反向范围自动交换(如 2026:2024[DP] → 2024:2026[DP])
|
||||
if start_val.isdigit() and end_val.isdigit() and int(start_val) > int(end_val):
|
||||
# P5: also handle mixed types (e.g. 2026:2024-01-01 → 2024-01-01:2026)
|
||||
_start_is_digit = start_val.isdigit()
|
||||
_end_is_digit = end_val.isdigit()
|
||||
if _start_is_digit and _end_is_digit and int(start_val) > int(end_val):
|
||||
start_val, end_val = end_val, start_val
|
||||
elif not start_val.isdigit() and not end_val.isdigit() and start_val > end_val:
|
||||
elif not _start_is_digit and not _end_is_digit and start_val > end_val:
|
||||
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
|
||||
# 确定两端是否是 4 位年份
|
||||
_start_is_year = start_val.isdigit() and len(start_val) == 4
|
||||
@@ -682,12 +699,18 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery:
|
||||
# P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits
|
||||
query = unicodedata.normalize('NFKC', query)
|
||||
import re as _re
|
||||
# 将 YYYY/MM/DD 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE
|
||||
query = _re.sub(r'(\d{4})/(\d{2})/(\d{2})', r'\1-\2-\3', query)
|
||||
# P5: Normalize single-digit month/day (2024-1-1 → 2024-01-01) to match DATE token pattern
|
||||
# 将 YYYY/MM/DD 或 YYYY/M/D 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE
|
||||
# Anchored with context boundaries to avoid over-matching inside URLs/paths
|
||||
query = _re.sub(
|
||||
r'(\d{4})-(\d{1,2})-(\d{1,2})',
|
||||
lambda m: f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}',
|
||||
r'(^|[\[\s":])(\d{4})/(\d{1,2})/(\d{1,2})(?=\s|$|[\[\]":])',
|
||||
lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}',
|
||||
query,
|
||||
)
|
||||
# P5: Normalize single-digit month/day (2024-1-1 → 2024-01-01) to match DATE token pattern
|
||||
# Anchored with context boundaries to avoid over-matching inside non-date text
|
||||
query = _re.sub(
|
||||
r'(^|[\[\s":])(\d{4})-(\d{1,2})-(\d{1,2})(?=\s|$|[\[\]":])',
|
||||
lambda m: f'{m.group(1)}{m.group(2)}-{int(m.group(3)):02d}-{int(m.group(4)):02d}',
|
||||
query,
|
||||
)
|
||||
tokens = tokenise(query)
|
||||
|
||||
@@ -219,7 +219,7 @@ class AdvancedSearchEngine:
|
||||
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
|
||||
]):
|
||||
# 解析失败但检测到 PubMed 语法 — 擦除 [field] 标签、布尔符、引号
|
||||
query = re.sub(r'\[[\w-]+\]', '', query)
|
||||
query = re.sub(r'\[[\w/-]+\]', '', query) # P5: [\w/-] 覆盖 [Title/Abstract]
|
||||
query = re.sub(r'\b(AND|OR|NOT)\b', '', query)
|
||||
query = query.replace('"', '').replace('(', '').replace(')', '')
|
||||
query = ' '.join(query.split())
|
||||
@@ -229,6 +229,7 @@ class AdvancedSearchEngine:
|
||||
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 [])
|
||||
@@ -281,7 +282,7 @@ class AdvancedSearchEngine:
|
||||
if text_terms:
|
||||
# ATM 展开(仅 field="all" 时,字段搜索不应自动扩到 MeSH)
|
||||
_atm_cond = None
|
||||
_atm_query = query.replace('"', '').replace("'", '').strip()
|
||||
_atm_query = query.replace('"', '').replace("'", '').replace('(', '').replace(')', '').strip()
|
||||
if _atm_query and field == "all":
|
||||
try:
|
||||
_atm_cond = await _expand_atm(db, _atm_query)
|
||||
@@ -309,9 +310,9 @@ class AdvancedSearchEngine:
|
||||
conditions.append(_atm_cond)
|
||||
|
||||
# 年份范围
|
||||
if year_from:
|
||||
if year_from is not None:
|
||||
conditions.append(GlobalLiterature.pub_year >= year_from)
|
||||
if year_to:
|
||||
if year_to is not None:
|
||||
conditions.append(GlobalLiterature.pub_year <= year_to)
|
||||
|
||||
# 具体日期范围(按天搜索)
|
||||
@@ -982,6 +983,9 @@ class AdvancedSearchEngine:
|
||||
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 = {
|
||||
@@ -1170,7 +1174,7 @@ class AdvancedSearchEngine:
|
||||
if "/" in term:
|
||||
if term.startswith("10."):
|
||||
return or_(
|
||||
GlobalLiterature.doi.ilike(term),
|
||||
GlobalLiterature.doi.ilike(_escape_ilike(term)),
|
||||
GlobalLiterature.doi.ilike(like_val),
|
||||
)
|
||||
return or_(
|
||||
|
||||
Reference in New Issue
Block a user