Files
backend/backend/app/services/pubmed_query_parser.py
T
34047007@qq.com de1f4a4bb8
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
fix: 第16轮搜索审计修复 — all_not/group_negated分离 + _parse_primary重复组 + 错误显示
- Bug 2 (MEDIUM): all_not 混淆外部NOT与内部NOT,新增 group_negated
  字段区分,_pubmed_conditions 改用 pp.group_negated[idx] 替代 all_not
- Bug 1 (MEDIUM): _parse_primary 括号组内重复group赋值,只处理
  group_id < 0 的未分配terms,_parse_or_expr 增加 depth>0 守卫
- Bug 3 (MEDIUM): SearchView.vue 搜索错误显示为"no results",
  新增 searchError ref + NResult 错误面板
- Bug 4 (LOW): literature.py UUID类型转换优化
2026-07-28 14:08:46 +08:00

959 lines
41 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.
"""PubMed 查询语法解析器
将用户输入的 PubMed 风格查询(如 `"lung cancer"[TI] AND "pembrolizumab"[TIAB]`
解析为结构化数据,供 AdvancedSearchEngine.search() 消费。
支持的语法:
- 字段标签 (case-insensitive): [TI] [AB] [TIAB] [AU] [TA] [MH] [MAJR] [PT] [DP] [PMID] [DOI]
- 布尔运算符: AND / OR / NOT(大小写不敏感,前后需词边界)
- 引号短语: "exact phrase"
- 括号分组: (a OR b) AND c
- 日期范围: 2024:2026[DP]
设计原则:
- 向后兼容:不含特殊语法的纯文本查询原样返回为 plain_terms
- 容错:语法错误时退化到 plain_terms,不抛异常
- 组合:解析结果映射到 AdvancedSearchEngine.search() 的已有参数
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from enum import Enum, auto
# P11: partial date YYYY-MM pattern, expanded to full month range
_PARTIAL_DATE_RE = re.compile(r'^\d{4}-\d{2}$')
_LAST_DAY = {1:31, 2:29, 3:31, 4:30, 5:31, 6:30, 7:31, 8:31, 9:30, 10:31, 11:30, 12:31}
def _is_leap_year(year: int) -> bool:
"""Check if year is a leap year."""
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
def _validate_date_str(s: str) -> bool:
"""Validate that a string is a valid calendar date: YYYY or YYYY-MM-DD."""
if s.isdigit() and len(s) == 4:
return True
if len(s) == 10 and s[4] == '-' and s[7] == '-' and s.replace('-', '').isdigit():
try:
y, m, d = int(s[:4]), int(s[5:7]), int(s[8:10])
if m < 1 or m > 12 or d < 1:
return False
last = _LAST_DAY.get(m, 31)
if m == 2 and last == 29 and not _is_leap_year(y):
last = 28
return d <= last
except (ValueError, IndexError):
return False
return False
def _expand_partial_date(text: str) -> tuple[str, str]:
"""Expand YYYY-MM to full month range (YYYY-MM-01 to YYYY-MM-last_day)."""
date_from = f"{text}-01"
y, m = text.split("-")
month = int(m)
if month < 1 or month > 12:
# P12: invalid month → return year-only range
return f"{y}-01-01", f"{y}-12-31"
last_day = _LAST_DAY.get(month, 31)
if month == 2 and last_day == 29 and not _is_leap_year(int(y)):
last_day = 28
date_to = f"{y}-{m}-{last_day}"
return date_from, date_to
def _normalize_field_label(raw: str) -> str | None:
"""Normalize raw PubMed field label to internal field name. (P12)"""
if raw == "MH:NOEXP":
return "MH"
if raw in _FIELD_TAG_MAP:
return _FIELD_TAG_MAP[raw]
if raw in _SPECIAL_FIELDS:
return raw
return None
# ─── 查询复杂度限制 ───
MAX_TERMS = 100 # P4-1: 放宽到 100 词(原 50 词)
MAX_PAREN_DEPTH = 10 # 括号嵌套最深层数
# ─── 字段标签映射 ───
_FIELD_TAG_MAP: dict[str, str] = {
"TI": "title",
"AB": "abstract",
"TIAB": "all",
"AU": "author",
"TA": "journal",
"JT": "journal", # P1-2: Journal Title (同 TA)
"AD": "affiliation",
"CN": "author",
"FAU": "author",
"LAU": "author",
"TW": "all",
"ALL": "all", # P3-7: [ALL] = 全部字段
"MESH": "MH", # P1-2: [MESH] 是 [MH] 的别名
# 新增标量字段
"LA": "language",
"VI": "volume",
"IP": "issue",
"PG": "pages",
"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 → 同 GRgrant_id
"SO": "journal", # [SO] Source → journal(近似)
"PL": "journal", # [PL] Place of Publication → journal(近似)
}
# 需要特殊处理的字段(不直接映射到 field 参数)
_SPECIAL_FIELDS = {
"MH", "MAJR", "PT", "DP", "PMID", "DOI",
# 日期字段
"EDAT", "CRDT", "MHDA", "LR", "DCOM",
# P1-2: 电子出版日期
"DEP",
# JSONB 字段
"GR", "SH", "RN", "NM", "SI", "PA",
# 待补充抽取的字段(标记为特殊以便未来实现)
"AUID", "COIS", "ED", "IR", "PS", "PUBN", "TT",
# P1-2: 新字段
"SB", "STAT", "UID",
# P4 新增:独立语义字段
"OT", # [OT] → keywords JSONB(不再映射到 all
"GEN", # [GEN] → gene_symbols JSONB
"PMC", # [PMC] → pmc_id
}
# 支持日期范围语法的字段
_DATE_RANGE_FIELDS = {"DP", "EDAT", "CRDT", "MHDA", "LR", "DCOM", "DEP"}
# 所有合法字段标签(PubMed 全量字段)
# P1-1: 移除了 BOOK/FILTER/ISBN(未实现,降级为 plain text 不如报错透明)
_ALL_FIELD_TAGS = {
"AB", "AD", "ALL", "AU", "CN", "FAU", "AUID", "LAU", "COIS",
"DCOM", "CRDT", "EDAT", "MHDA", "LR", "DP", "DOI",
"DEP", # P1-2: Date of Electronic Publication
"RN", "ED", "GR", "IR", "IP",
"TA", "JT", "LA", "LID", "MAJR", "SH", "MH", "MH:NOEXP", "OT", "PG",
"PA", "PT", "PMID", "PUBN", "SI", "PS", "NM", "TW",
"SB", "STAT", "UID", # P1-2: Subset, Status, UID
"MESH", # P1-2: [MH] 别名
"TI", "TIAB", "TT", "VI",
# P4 新增字段标签
"Title/Abstract", # [Title/Abstract] 长标签
"OAB", "WORD", # [OAB] Other Abstract, [WORD] Word in text
"FI", "GEN", "PMC", "SO", "PL", # [FI] Funder, [GEN] Gene, [PMC] PMCID, [SO] Source, [PL] Place
}
# ─── Token Types ───
class TokenType(Enum):
QUOTED = auto() # "text in quotes"
FIELD = auto() # [TI], [MH], etc.
AND = auto() # AND operator
OR = auto() # OR operator
NOT = auto() # NOT operator
LPAREN = auto() # (
RPAREN = auto() # )
COLON = auto() # :
WORD = auto() # unquoted text
NUMBER = auto() # digits (for PMID, year)
DATE = auto() # YYYY-MM-DD (normalized from YYYY/MM/DD)
UNKNOWN_FIELD = auto() # [...] 但内容不是已知字段标签
EOF = auto() # end of input
@dataclass
class Token:
type: TokenType
value: str = ""
# ─── Tokeniser ───
_TOKEN_PATTERNS: list[tuple[TokenType, str]] = [
(TokenType.QUOTED, r'"(?:[^"\\]|\\.)*"'),
(TokenType.FIELD, r'\[(?:' + '|'.join(_ALL_FIELD_TAGS) + r')\]'),
(TokenType.UNKNOWN_FIELD, r'\[[^\]]*\]'), # 未识别的字段标签 → 触发降级
(TokenType.AND, r'\bAND\b'),
(TokenType.OR, r'\bOR\b'),
(TokenType.NOT, r'\bNOT\b'),
(TokenType.LPAREN, r'\('),
(TokenType.RPAREN, r'\)'),
(TokenType.COLON, r':'),
(TokenType.DATE, r'\d{4}-\d{2}-\d{2}'),
(TokenType.NUMBER, r'\d+'),
(TokenType.WORD, r'[^\s"\[\]():]+'),
]
_TOKEN_RE = re.compile(
'|'.join(f'(?P<{t.name}>{p})' for t, p in _TOKEN_PATTERNS),
re.IGNORECASE | re.ASCII,
)
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]
if ttype == TokenType.UNKNOWN_FIELD:
fname = value.strip('[]').upper()
# P0-1: 不认识字段标签时降级为 WORD,不终止解析
tokens.append(Token(TokenType.WORD, value.strip('[]')))
if len(tokens) > MAX_TERMS:
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
continue
tokens.append(Token(ttype, value))
if len(tokens) > MAX_TERMS:
raise ParseError(f"查询词过多(超过 {MAX_TERMS} 个),降级为简单文本搜索")
tokens.append(Token(TokenType.EOF))
return tokens
# ─── AST Nodes ───
@dataclass
class Term:
"""单个搜索词项。"""
text: str
exact: bool = False # True if quoted phrase
field: str | None = None # None = plain (no field tag); "title"/"abstract"/etc for mapped; "MH"/"PT"/etc for special
is_not: bool = False # True if preceded by NOT
group_id: int = -1 # -1 = top-level, >= 0 = parenthesized group index
_noexp: bool = False # P1-4: [MH:noexp] 抑制树展开
@dataclass
class ParsedPubmedQuery:
"""PubMed 查询解析后的结构化表示。"""
title_terms: list[Term] = field(default_factory=list) # [TI]
abstract_terms: list[Term] = field(default_factory=list) # [AB]
tiab_terms: list[Term] = field(default_factory=list) # [TIAB]
author_terms: list[Term] = field(default_factory=list) # [AU]
journal_terms: list[Term] = field(default_factory=list) # [TA]
affiliation_terms: list[Term] = field(default_factory=list) # [AD]
language_terms: list[Term] = field(default_factory=list) # [LA]
volume_terms: list[Term] = field(default_factory=list) # [VI]
issue_terms: list[Term] = field(default_factory=list) # [IP]
pages_terms: list[Term] = field(default_factory=list) # [PG]
lid_terms: list[Term] = field(default_factory=list) # [LID]
date_from: str | None = None # [DP] lower bound (YYYY-MM-DD)
date_to: str | None = None # [DP] upper bound (YYYY-MM-DD)
year_from: int | None = None # [DP] year lower
year_to: int | None = None # [DP] year upper
# 更多日期字段
edat_from: str | None = None # [EDAT]
edat_to: str | None = None
crdt_from: str | None = None # [CRDT]
crdt_to: str | None = None
mhda_from: str | None = None # [MHDA]
mhda_to: str | None = None
lr_from: str | None = None # [LR]
lr_to: str | None = None
dcom_from: str | None = None # [DCOM]
dcom_to: str | None = None
dep_from: str | None = None # P1-2: [DEP]
dep_to: str | None = None
# 特殊字段(存储 Term 以保留 is_not 标志)
mesh_terms: list[Term] = field(default_factory=list) # [MH] (was list[str])
majr_terms: list[Term] = field(default_factory=list) # [MAJR] (was list[str])
pub_types: list[Term] = field(default_factory=list) # [PT] (was list[str])
doi_terms: list[Term] = field(default_factory=list) # [DOI] (was list[str])
pmid_terms: list[Term] = field(default_factory=list) # [PMID] (was list[int])
# JSONB 字段
grant_terms: list[Term] = field(default_factory=list) # [GR]
subheading_terms: list[Term] = field(default_factory=list) # [SH]
registry_terms: list[Term] = field(default_factory=list) # [RN]
substance_terms: list[Term] = field(default_factory=list) # [NM]
databank_terms: list[Term] = field(default_factory=list) # [SI]
pharmaco_terms: list[Term] = field(default_factory=list) # [PA]
ed_terms: list[Term] = field(default_factory=list) # [ED]
investigator_terms: list[Term] = field(default_factory=list) # [IR]
personal_name_terms: list[Term] = field(default_factory=list) # [PS]
pubnote_terms: list[Term] = field(default_factory=list) # [PUBN]
auid_terms: list[Term] = field(default_factory=list) # [AUID]
cois_terms: list[Term] = field(default_factory=list) # [COIS]
tt_terms: list[Term] = field(default_factory=list) # [TT]
sb_terms: list[Term] = field(default_factory=list) # P1-2: [SB] Subset
stat_terms: list[Term] = field(default_factory=list) # P1-2: [STAT] Status
uid_terms: list[Term] = field(default_factory=list) # P1-2: [UID] PMID/DOI 统一
# P4 新增独立语义字段
ot_terms: list[Term] = field(default_factory=list) # [OT] → keywords JSONB
gene_terms: list[Term] = field(default_factory=list) # [GEN] → gene_symbols JSONB
pmc_terms: list[Term] = field(default_factory=list) # [PMC] → pmc_id
plain_terms: list[Term] = field(default_factory=list) # no field tag
boolean_operator: str = "and" # "and" | "or" | "mixed"
has_not: bool = False # contains NOT
not_terms: list[Term] = field(default_factory=list) # terms under NOT
groups: list[list[Term]] = field(default_factory=list) # parenthesized sub-groups
group_operators: list[str] = field(default_factory=list) # "and"/"or" per group (P2-2)
group_negated: list[bool] = field(default_factory=list) # P16: True if group was wrapped by NOT (external negation)
negated_date_ranges: set[str] = field(default_factory=set) # date fields negated by NOT
_date_range_markers: list[Term] = field(default_factory=list, repr=False) # internal: date range Term collectors
# ─── Parser ───
class ParseError(ValueError):
"""查询语法解析错误(调用方应退化到 plain text 搜索)。"""
class PubmedQueryParser:
"""Recursive descent parser for PubMed query syntax.
Grammar:
query → or_expr
or_expr → and_expr (OR and_expr)*
and_expr → not_expr (AND not_expr)*
not_expr → NOT not_expr | primary
primary → atom FIELD? | LPAREN query RPAREN
atom → QUOTED | WORD | number (COLON number)?
"""
def __init__(self, tokens: list[Token]):
self.tokens = tokens
self.pos = 0
def peek(self) -> Token:
return self.tokens[self.pos]
def peek_n(self, n: int) -> Token | None:
idx = self.pos + n
return self.tokens[idx] if idx < len(self.tokens) else None
def advance(self) -> Token:
t = self.tokens[self.pos]
self.pos += 1
return t
def expect(self, *types: TokenType) -> Token:
t = self.peek()
if t.type not in types:
raise ParseError(
f"Expected one of {[tt.name for tt in types]}, got {t.type.name}({t.value!r})"
)
return self.advance()
def parse(self) -> ParsedPubmedQuery:
"""入口:解析完整的查询字符串。"""
result = ParsedPubmedQuery()
self._depth = 0 # 括号嵌套深度计数器
terms = self._parse_or_expr(result)
# 解析错误由 parse_pubmed_query 统一降级处理
# Detect boolean operator from top-level (depth=0) token stream only
# P11: 括号内的 AND/OR 不应影响顶层布尔操作符判定
depth = 0
has_and = has_or = False
for t in self.tokens:
if t.type == TokenType.LPAREN:
depth += 1
elif t.type == TokenType.RPAREN:
depth -= 1
elif depth == 0:
if t.type == TokenType.AND:
has_and = True
elif t.type == TokenType.OR:
has_or = True
if has_and and has_or:
result.boolean_operator = "mixed"
elif has_or and not has_and:
result.boolean_operator = "or"
# 分组词不从 flat lists 走,避免括号内外的词被一起 AND/OR
# 同时 has_not/not_terms 也只考虑非分组词
_ungrouped = [t for t in terms if not getattr(t, '_is_range_end', False) and t.group_id < 0]
# P12: has_not 同时检查分组内 NOT(如 NOT (a OR b)
result.has_not = any(t.is_not for t in _ungrouped) or any(
t.is_not for g in result.groups for t in g
)
result.not_terms = [t for t in _ungrouped if t.is_not]
for t in _ungrouped:
self._dispatch_term(result, t)
# P2-1: Handle unconsumed tokens (e.g., orphan text after RPAREN)
if self.pos < len(self.tokens) - 1:
for t in self.tokens[self.pos:-1]: # exclude EOF token
if t.type in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER):
text = t.value.strip('"') if t.type == TokenType.QUOTED else t.value
result.plain_terms.append(Term(text=text, exact=(t.type == TokenType.QUOTED)))
# Recompute negated_date_ranges from marker terms (after NOT toggling from recursive _parse_not_expr)
result.negated_date_ranges = {
t.field.replace("__RANGE_", "").replace("__", "")
for t in result._date_range_markers if t.is_not
}
return result
def _dispatch_term(self, result: ParsedPubmedQuery, term: Term) -> None:
if term.field == "title":
result.title_terms.append(term)
elif term.field == "abstract":
result.abstract_terms.append(term)
elif term.field == "all": # TIAB
result.tiab_terms.append(term)
elif term.field == "author":
result.author_terms.append(term)
elif term.field == "affiliation":
result.affiliation_terms.append(term)
elif term.field == "journal":
result.journal_terms.append(term)
elif term.field == "language":
result.language_terms.append(term)
elif term.field == "volume":
result.volume_terms.append(term)
elif term.field == "issue":
result.issue_terms.append(term)
elif term.field == "pages":
result.pages_terms.append(term)
elif term.field == "lid":
result.lid_terms.append(term)
elif term.field == "MH":
result.mesh_terms.append(term)
elif term.field == "MAJR":
result.majr_terms.append(term)
elif term.field == "PT":
result.pub_types.append(term)
elif term.field == "PMID":
try:
int(term.text) # validate
except ValueError:
result.plain_terms.append(term)
return
result.pmid_terms.append(term)
elif term.field == "DOI":
result.doi_terms.append(term)
elif term.field == "GR":
result.grant_terms.append(term)
elif term.field == "SH":
result.subheading_terms.append(term)
elif term.field == "RN":
result.registry_terms.append(term)
elif term.field == "NM":
result.substance_terms.append(term)
elif term.field == "SI":
result.databank_terms.append(term)
elif term.field == "PA":
result.pharmaco_terms.append(term)
elif term.field is None:
result.plain_terms.append(term)
# ── 独立日期字段(非范围语法):"2024-01-01"[DP] → from=to=该日期 ──
# 纯 4 位年份 "2024"[DP] 用 year_from/year_to,避免 fromisoformat 问题
# is_not 时加入 negated_date_ranges,引擎据此 NOT 条件
elif term.field == "DP":
if term.text.isdigit() and len(term.text) == 4:
result.year_from = int(term.text)
result.year_to = int(term.text)
elif _PARTIAL_DATE_RE.match(term.text):
result.date_from, result.date_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.date_from = term.text
result.date_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("DP")
elif term.field == "EDAT":
if term.text.isdigit() and len(term.text) == 4:
result.edat_from = f"{term.text}-01-01"
result.edat_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.edat_from, result.edat_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.edat_from = term.text
result.edat_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("EDAT")
elif term.field == "CRDT":
if term.text.isdigit() and len(term.text) == 4:
result.crdt_from = f"{term.text}-01-01"
result.crdt_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.crdt_from, result.crdt_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.crdt_from = term.text
result.crdt_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("CRDT")
elif term.field == "MHDA":
if term.text.isdigit() and len(term.text) == 4:
result.mhda_from = f"{term.text}-01-01"
result.mhda_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.mhda_from, result.mhda_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.mhda_from = term.text
result.mhda_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("MHDA")
elif term.field == "LR":
if term.text.isdigit() and len(term.text) == 4:
result.lr_from = f"{term.text}-01-01"
result.lr_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.lr_from, result.lr_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.lr_from = term.text
result.lr_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("LR")
elif term.field == "DCOM":
if term.text.isdigit() and len(term.text) == 4:
result.dcom_from = f"{term.text}-01-01"
result.dcom_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.dcom_from, result.dcom_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.dcom_from = term.text
result.dcom_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("DCOM")
elif term.field == "DEP":
if term.text.isdigit() and len(term.text) == 4:
result.dep_from = f"{term.text}-01-01"
result.dep_to = f"{term.text}-12-31"
elif _PARTIAL_DATE_RE.match(term.text):
result.dep_from, result.dep_to = _expand_partial_date(term.text)
else:
if _validate_date_str(term.text):
result.dep_from = term.text
result.dep_to = term.text
else:
result.plain_terms.append(term)
return
if term.is_not:
result.negated_date_ranges.add("DEP")
elif term.field == "__RANGE_DP__":
pass
elif term.field == "__RANGE_EDAT__":
pass
elif term.field == "__RANGE_CRDT__":
pass
elif term.field == "__RANGE_MHDA__":
pass
elif term.field == "__RANGE_LR__":
pass
elif term.field == "__RANGE_DCOM__":
pass
elif term.field == "__RANGE_DEP__":
pass
elif term.field == "ED":
result.ed_terms.append(term)
elif term.field == "IR":
result.investigator_terms.append(term)
elif term.field == "PS":
result.personal_name_terms.append(term)
elif term.field == "PUBN":
result.pubnote_terms.append(term)
elif term.field == "AUID":
result.auid_terms.append(term)
elif term.field == "COIS":
result.cois_terms.append(term)
elif term.field == "TT":
result.tt_terms.append(term)
elif term.field == "SB":
result.sb_terms.append(term)
elif term.field == "STAT":
result.stat_terms.append(term)
elif term.field == "UID":
result.uid_terms.append(term)
# P4 新增独立语义字段
elif term.field == "OT":
result.ot_terms.append(term)
elif term.field == "GEN":
result.gene_terms.append(term)
elif term.field == "PMC":
result.pmc_terms.append(term)
else:
result.plain_terms.append(term)
def _parse_or_expr(self, result: ParsedPubmedQuery) -> list[Term]:
"""or_expr → and_expr (OR and_expr)*
P7-D3: Preserve AND clusters when OR is mixed (e.g. ``A OR B AND C``).
Collect each ``_parse_and_expr`` result as a separate cluster. When OR
is found AND a cluster has >1 term (implicit AND), wrap it in a group
so the engine produces ``A OR (B AND C)`` instead of ``A OR B OR C``.
"""
clusters = [self._parse_and_expr(result)]
had_or = False
while self.peek().type == TokenType.OR:
had_or = True
self.advance()
if self.peek().type == TokenType.EOF:
break # trailing OR, ignore silently
clusters.append(self._parse_and_expr(result))
if not had_or:
return clusters[0] if clusters else []
# OR present: group any AND-cluster with >1 term
# P16: Only do this at top level (depth=0). Inside parens, _parse_primary handles the grouping
# with the correct operator from the token stream scan.
if self._depth > 0:
all_terms = [t for cluster in clusters for t in cluster]
return all_terms
all_terms: list[Term] = []
for cluster in clusters:
if len(cluster) > 1 and not any(t.group_id >= 0 for t in cluster):
gid = len(result.groups)
for t in cluster:
t.group_id = gid
result.groups.append(cluster)
result.group_operators.append("and")
all_terms.extend(cluster)
return all_terms
def _is_primary_start(self, token: Token) -> bool:
"""Check if token could start a primary expression."""
return token.type in (TokenType.WORD, TokenType.QUOTED, TokenType.NUMBER, TokenType.DATE,
TokenType.LPAREN, TokenType.NOT)
def _parse_and_expr(self, result: ParsedPubmedQuery) -> list[Term]:
"""and_expr → not_expr (AND? not_expr)*
AND is optional between consecutive primaries (implicit AND).
e.g. ``smith j[AU]`` → smith AND j[AU]
"""
left = self._parse_not_expr(result)
left = list(left) # P12: copy to prevent mutation of shared group list
while True:
tok = self.peek()
if tok.type == TokenType.AND:
self.advance()
if self.peek().type == TokenType.EOF:
break
elif self._is_primary_start(tok):
pass # implicit AND — continue without consuming
else:
break
right = self._parse_not_expr(result)
left.extend(right)
return left
def _parse_not_expr(self, result: ParsedPubmedQuery) -> list[Term]:
"""not_expr → NOT not_expr | primary"""
if self.peek().type == TokenType.NOT:
self.advance()
# P5: trailing NOT at end of input → ignore silently (avoid IndexError peeking past EOF)
if self.peek().type == TokenType.EOF:
return []
inner = self._parse_not_expr(result)
for t in inner:
t.is_not = not t.is_not
return inner
return self._parse_primary(result, negated=False)
def _parse_primary(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]:
"""primary → atom FIELD? | LPAREN query RPAREN"""
if self.peek().type == TokenType.LPAREN:
self._depth += 1
if self._depth > MAX_PAREN_DEPTH:
raise ParseError(f"括号嵌套过深(超过 {MAX_PAREN_DEPTH} 层),降级为简单文本搜索")
self.advance()
start_pos = self.pos # P2-2: 记录组起始标记位置
terms = self._parse_or_expr(result)
end_pos = self.pos # P2-2: 记录组结束标记位置
self.expect(TokenType.RPAREN)
self._depth -= 1
# P2-F12: (a OR b)[TI] — 组后字段标签应用到组内所有词
if self.peek().type == TokenType.FIELD:
ft = self.advance()
_raw_field = ft.value[1:-1].upper()
_field = _normalize_field_label(_raw_field)
for t in terms:
t.field = _field
# P15-PRIMARY: 如果 _parse_or_expr 已为 AND 集群(如 A OR B AND C → [B, C] sub-group
# 或嵌套括号创建了子组,则这些子组已正确处理结构。
# 只对尚未分组的词创建外层组,避免 Term 被放入两个组导致 _pubmed_conditions 重复处理。
_parent_gid = len(result.groups)
_ungrouped = [t for t in terms if t.group_id < 0]
for t in _ungrouped:
t.group_id = _parent_gid
if _ungrouped:
result.groups.append(_ungrouped)
_has_or = any(t.type == TokenType.OR for t in self.tokens[start_pos:end_pos])
result.group_operators.append("or" if _has_or else "and")
result.group_negated.append(negated) # P16: track external NOT vs internal NOT
# 已分组的 Term(嵌套括号 OR 子组)不再重复加组
if negated:
for t in terms:
t.is_not = True
return terms
return self._parse_atom(result, negated)
def _parse_atom(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]:
"""Parse a single atom with optional field tag.
Handles:
- "quoted phrase"[FIELD]
- word[FIELD]
- NUMBER:NUMBER[DP] (year/date range)
- DATE:DATE[DP] (full date range like 2024-01-01:2024-12-31)
- NUMBER (bare number)
- word (bare word)
"""
# Look ahead for range pattern (NUMBER:NUMBER or DATE:DATE)
t0 = self.peek()
t1 = self.peek_n(1)
t2 = self.peek_n(2)
if (t1 is not None and t1.type == TokenType.COLON
and t2 is not None
and t0.type in (TokenType.NUMBER, TokenType.DATE, TokenType.WORD)
and t2.type in (TokenType.NUMBER, TokenType.DATE, TokenType.WORD)):
return self._parse_range(result, negated)
# Normal atom
token = self.advance()
text = token.value.strip('"') if token.type == TokenType.QUOTED else token.value
is_exact = (token.type == TokenType.QUOTED)
field = None
_noexp = False # P1-4
if self.peek().type == TokenType.FIELD:
ft = self.advance()
raw = ft.value[1:-1].upper()
# P1-4: [MH:noexp] → 抑制树展开
if raw == "MH:NOEXP":
field = "MH"
_noexp = True
else:
field = raw
if field in _FIELD_TAG_MAP:
field = _FIELD_TAG_MAP[field]
return [Term(text, exact=is_exact, field=field, is_not=negated, _noexp=_noexp)]
def _parse_range(self, result: ParsedPubmedQuery, negated: bool = False) -> list[Term]:
"""Parse NUMBER:NUMBER[FIELD] — handles date ranges specially."""
start_val = self.advance().value # NUMBER
self.advance() # COLON
end_val = self.advance().value # NUMBER
field = None
if self.peek().type == TokenType.FIELD:
ft = self.advance()
field = ft.value[1:-1].upper()
if field in _DATE_RANGE_FIELDS:
attr_map = {
"DP": ("date_from", "date_to", "year_from", "year_to", "__RANGE_DP__"),
"EDAT": ("edat_from", "edat_to", None, None, "__RANGE_EDAT__"),
"CRDT": ("crdt_from", "crdt_to", None, None, "__RANGE_CRDT__"),
"MHDA": ("mhda_from", "mhda_to", None, None, "__RANGE_MHDA__"),
"LR": ("lr_from", "lr_to", None, None, "__RANGE_LR__"),
"DCOM": ("dcom_from", "dcom_to", None, None, "__RANGE_DCOM__"),
"DEP": ("dep_from", "dep_to", None, None, "__RANGE_DEP__"), # P1-2
}
date_attr, date_attr_to, yr_from_attr, yr_to_attr, marker_field = attr_map[field]
# 反向范围自动交换(如 2026:2024[DP] → 2024:2026[DP]
# 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_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:
try:
if int(start_val) > int(end_val[:4]):
start_val, end_val = end_val, start_val
except (ValueError, TypeError):
pass
elif not _start_is_digit and _end_is_digit:
try:
if int(start_val[:4]) > int(end_val):
start_val, end_val = end_val, start_val
except (ValueError, TypeError):
pass
# P12: normalize compact YYYYMMDD dates → YYYY-MM-DD
if start_val.isdigit() and len(start_val) == 8:
try:
start_val = f"{start_val[:4]}-{start_val[4:6]}-{start_val[6:8]}"
except IndexError:
pass
if end_val.isdigit() and len(end_val) == 8:
try:
end_val = f"{end_val[:4]}-{end_val[4:6]}-{end_val[6:8]}"
except IndexError:
pass
# P12: validate date values — non-numeric garbage falls back to plain text
_valid_date = lambda s: _validate_date_str(s)
if not _valid_date(start_val) or not _valid_date(end_val):
txt = f"{start_val}:{end_val}[{field}]"
return [Term(txt, field=None, is_not=negated)]
# 确定两端是否是 4 位年份
_start_is_year = start_val.isdigit() and len(start_val) == 4
_end_is_year = end_val.isdigit() and len(end_val) == 4
# Year-only range (e.g., 2024:2026[EDAT])
if _start_is_year and _end_is_year:
try:
if yr_from_attr:
setattr(result, yr_from_attr, int(start_val))
setattr(result, yr_to_attr, int(end_val))
else:
# For non-DP date fields: convert year to full date for consistency
setattr(result, date_attr, f"{start_val}-01-01")
setattr(result, date_attr_to, f"{end_val}-12-31")
except (ValueError, TypeError):
pass
elif _start_is_year and not _end_is_year:
# Mixed: start is year, end is full date (e.g., 2024:2024-12-01[EDAT])
if yr_from_attr:
try:
setattr(result, yr_from_attr, int(start_val))
except (ValueError, TypeError):
pass
setattr(result, date_attr, f"{start_val}-01-01")
setattr(result, date_attr_to, end_val)
elif not _start_is_year and _end_is_year:
# Mixed: start is full date, end is year (e.g., 2024-01-01:2026[EDAT])
setattr(result, date_attr, start_val)
setattr(result, date_attr_to, f"{end_val}-12-31")
else:
# Full date range (e.g., 2024-01-01:2024-12-31[EDAT])
setattr(result, date_attr, start_val)
setattr(result, date_attr_to, end_val)
marker = Term(f"{start_val}:{end_val}", field=marker_field, is_not=negated)
marker._is_range_end = True
result._date_range_markers.append(marker)
if negated:
result.negated_date_ranges.add(field)
return [marker]
# Non-date range or no field → plain text
txt = f"{start_val}:{end_val}"
if field:
txt = f"{txt}[{field}]"
return [Term(txt, field=field, is_not=negated)]
# ─── Public API ───
def is_pubmed_syntax(query: str) -> bool:
"""快速检测查询串是否包含 PubMed 语法特征。
检测条件(任意满足其一):
1. 包含 `[...]`(字段标签)
2. 包含布尔运算符 AND/OR/NOT(大写,词边界)
"""
if not query or not query.strip():
return False
# P5: Normalize fullwidth characters before checking
query = unicodedata.normalize('NFKC', query)
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
return True
if re.search(r'\b(AND|OR|NOT)\b', query):
return True
return False
def parse_pubmed_query(query: str) -> ParsedPubmedQuery:
"""解析 PubMed 查询字符串。
如果解析失败(非法语法),返回全部内容为 plain_terms 的空结构。
"""
if not query or not query.strip():
return ParsedPubmedQuery()
try:
# P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits
query = unicodedata.normalize('NFKC', query)
import re as _re
# 将 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'(^|[\[\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)
parser = PubmedQueryParser(tokens)
return parser.parse()
except (ParseError, IndexError, ValueError):
# P0-1: 降级时清理查询中的 [field] 标签、布尔符、引号和括号
degraded = ParsedPubmedQuery()
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
def extract_pubmed_query_for_prisma(query: str) -> tuple[str, list[str]]:
"""提取 PubMed 查询和所用 MeSH 词,供 PRISMA 导出。
Returns:
(normalized_query, mesh_terms_used)
"""
parsed = parse_pubmed_query(query)
mesh_used = list(set(
[t.text for t in parsed.mesh_terms] + [t.text for t in parsed.majr_terms]
))
mesh_used.sort()
# P5: Handle field tags with '/' (e.g. Title/Article) or special chars
normalized = re.sub(
r'\[([\w/:]+)\]',
lambda m: f'[{m.group(1).upper()}]',
query,
)
return normalized, mesh_used