diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py
index 96f3ab9..1c85fb9 100644
--- a/backend/app/api/v1/features.py
+++ b/backend/app/api/v1/features.py
@@ -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
diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py
index dd947c7..629138b 100644
--- a/backend/app/services/pubmed_query_parser.py
+++ b/backend/app/services/pubmed_query_parser.py
@@ -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)
diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py
index 6b184f2..ff1292c 100644
--- a/backend/app/services/search_engine.py
+++ b/backend/app/services/search_engine.py
@@ -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_(
diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md
index 674f067..10ea69d 100644
--- a/docs/13-搜索修复全记录.md
+++ b/docs/13-搜索修复全记录.md
@@ -2,9 +2,9 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
>
-> **累计**:5 轮,63 项修复,50+ 字段标签注册,214 项测试覆盖
+> **累计**:6 轮,75 项修复,50+ 字段标签注册,248 项测试覆盖
> **时间跨度**:2026-07-24 ~ 2026-07-27
-> **核心文件**:`pubmed_query_parser.py`(~620 行)→ `search_engine.py`(~1250 行)
+> **核心文件**:`pubmed_query_parser.py`(~730 行)→ `search_engine.py`(~1320 行)
---
@@ -15,7 +15,8 @@
3. [第三轮:第三轮审计修复(14 项)](#第三轮第三轮审计修复)
4. [第四轮:字段补全与语义优化(11 项)](#第四轮字段补全与语义优化)
5. [第五轮:第 5 轮全面审计修复(4 项)](#第五轮第-5-轮全面审计修复)
-6. [遗留限制](#遗留限制)
+6. [第六轮:第 6 轮全面审计修复(12 项)](#第六轮第-6-轮全面审计修复)
+7. [遗留限制](#遗留限制)
---
@@ -377,18 +378,106 @@
- **根因**:regex 字符类不含 `/`
- **修复**:`[\w:]` → `[\w/:]`
+### P5-5: Tokeniser 缺口字符被静默丢弃(BUG-7)
+
+- **文件**:`pubmed_query_parser.py` `tokenise()` L150
+- **问题**:`finditer` 只输出匹配到的片段,`$`、`@` 等匹配不到的字符无声丢失
+- **根因**:`_TOKEN_PATTERNS` 未覆盖所有可能字符,且无 fallback
+- **修复**:在 `tokenise()` 中检测相邻 match 间的 gap,将非空 gap 作为 WORD 加入 token 流
+
+---
+
+## 第六轮:第 6 轮全面审计修复(12 项)
+
+**日期**:2026-07-27
+**数量**:12 项(6 项已在前轮中应用 + 6 项新增)
+**触发**:4 Agent 并行审计(Parser/Engine + API/Validation + Frontend + Integration)
+
+### P6-1: Title/Abstract 字段标签大小写不匹配
+
+- **文件**:`pubmed_query_parser.py` `_FIELD_TAG_MAP` L54
+- **问题**:`_FIELD_TAG_MAP` 只有 `"Title/Abstract"` 键,但解析器 `.upper()` 产生 `"TITLE/ABSTRACT"`,导致查表失败,该字段标签退化到 `plain_terms`(走 "all" 路径,结果正确但掩盖了 bug)
+- **根因**:初始化 `FieldTagMapping` 时只写了原始大小写
+- **修复**:增加 `"TITLE/ABSTRACT"` 大写键值对映射到 "all"
+
+### P6-2: 末尾 OR 产生空 Term(BUG-2)
+
+- **文件**:`pubmed_query_parser.py` `_parse_or_expr()` L466
+- **问题**:`cancer OR ` 末尾操作符导致解析器尝试读取空 token,生成 `Term(text="")`,引起 `plainto_tsquery("english", "")` 报错
+- **根因**:OR 后无表达式时,解析器仍尝试调用 `_parse_and_expr`,最终生成空 term
+- **修复**:在 `_parse_or_expr` 中,advance 后检查 EOF 并 break
+
+### P6-3: PubMed 降级路径 field 标签 regex 未覆盖 `/`(BUG-11)
+
+- **文件**:`search_engine.py` L222
+- **问题**:`re.sub(r'\[[\w-]+\]', '', query)` —— `[\w-]` 不含 `/`,`[Title/Abstract]` 不会被擦除,留在降级查询中作为普通文本
+- **根因**:字符类缺 `/`
+- **修复**:`[\w-]` → `[\w/-]`
+
+### P6-4: ATM 展开未剥离括号(Flat Text BUG 5)
+
+- **文件**:`search_engine.py` L284
+- **问题**:`_atm_query` 仅执行 `replace('"', '').replace("'", '')`,未去除 `(` 和 `)`。`(lung cancer)` 作为 ATM 查询导致 `expand_atm` 搜索到 ` (lung cancer)` 而非 `lung cancer`,可能零匹配
+- **修复**:增加 `replace('(', '').replace(')', '')`
+
+### P6-5: 中文 MeSH name_zh 查询无 LIMIT(Flat Text BUG 4)
+
+- **文件**:`search_engine.py` L229-235
+- **问题**:`GlobalTag.name_zh.ilike(...)` 可能返回大量匹配(如 "癌"),导致 subquery 膨胀
+- **修复**:追加 `.limit(100)` 限制
+
+### P6-6: year_from / year_to 使用 falsy 检测(BUG-15)
+
+- **文件**:`search_engine.py` L312-315
+- **问题**:`if year_from:` 使 `year_from=0` 被当作假值跳过(0 不是有效年份,但语义上 "0" 应被忽略;改为 `is not None` 更安全)
+- **根因**:Python falsy 检测对于 int 含 0
+- **修复**:`if year_from:` → `if year_from is not None:`
+
+### P6-7: `_parse_range` 混合类型日期范围无反向交换(BUG-8)
+
+- **文件**:`pubmed_query_parser.py` _parse_range L601-606
+- **问题**:`2026:2024-01-01[DP]` 不触发任何 swap(一个纯数字一个不是),导致 `year_from=2026`, `date_to=2024-01-01`(空范围)
+- **根因**:反向 swap 条件只处理两端同类型
+- **修复**:增加第三条件 `_start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4])`
+
+### P6-8: Tokeniser 内部 gap 字符恢复(BUG-7 补充)
+
+- **文件**:`pubmed_query_parser.py` tokenise L150
+- **问题**:无 gap 处理时部分特殊字符丢失
+- **修复**:记录 `last_end`,gap 中的非空白字符作为 WORD 输出
+
+### P6-9: `[MH:noexp]` 顶层支持(F1)
+
+- **文件**:`search_engine.py` _pubmed_conditions L660-674
+- **状态**:已在第一阶段实现,审计确认正确(按 `_noexp` 标志分组处理)
+
+### P6-10: 组内 NOT 语义 De Morgan(F2)
+
+- **文件**:`search_engine.py` _pubmed_conditions L922-941
+- **状态**:已在第二阶段实现,审计确认正确(`all_not` 标志 → `not_(combined)` 包裹)
+
+### P6-11: `_parse_not_expr` 递归支持(F3)
+
+- **文件**:`pubmed_query_parser.py` L498-509
+- **状态**:已在第五阶段实现,审计确认正确(递归调用 `_parse_not_expr`)
+
+### P6-12: 前端日期发送 + restoreFromUrl(F4+F5)
+
+- **文件**:`SearchView.vue` L300-302、`HomeView.vue` L364-367
+- **状态**:已在第五阶段实现,审计确认正确(custom range 已发送年/月参数、restoreFromUrl 已覆盖 sort/field/retracted/negative)
+
---
## 各轮变更摘要
-| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 |
-|------|--------|--------|--------|--------|--------|
-| 修复数 | 34 | 8 | 14 | 11 | 4 |
-| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser |
-| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 |
-| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 |
-| 新功能 | `[MH]`/`[EDAT]`/`[AD]`/`[LA]` 等 | — | — | `[GEN]`/`[PMC]`/`[Title/Abstract]` | — |
-| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 |
+| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 | 第六轮 |
+|------|--------|--------|--------|--------|--------|--------|
+| 修复数 | 34 | 8 | 14 | 11 | 4 | 12 |
+| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser | engine + parser |
+| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 | 0 |
+| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 | 0 |
+| 新功能 | `[MH]`/`[EDAT]`/`[AD]`/`[LA]` 等 | — | — | `[GEN]`/`[PMC]`/`[Title/Abstract]` | — | — |
+| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 | 审计修复 | 深度审计修复 |
---
diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue
index fc954e8..1c78d16 100644
--- a/frontend/src/views/app/SearchView.vue
+++ b/frontend/src/views/app/SearchView.vue
@@ -377,7 +377,7 @@ function restoreFromQuery() {
if (route.query.sort) sort.value = String(route.query.sort)
if (route.query.year_from) yearFromStr.value = String(route.query.year_from)
if (route.query.year_to) yearToStr.value = String(route.query.year_to)
- if (route.query.date_preset && ['1y','5y','10y'].includes(String(route.query.date_preset))) {
+ if (route.query.date_preset && ['1y','5y','10y','custom'].includes(String(route.query.date_preset))) {
datePreset.value = String(route.query.date_preset)
} else if (route.query.date_from || route.query.date_to) {
datePreset.value = null
@@ -487,8 +487,11 @@ function syncSearchToUrl() {
else if (datePreset.value === '10y') d.setUTCFullYear(d.getUTCFullYear() - 10)
q.date_from = d.toISOString().slice(0, 10)
} else {
- if (yearFromStr.value) q.year_from = yearFromStr.value
- if (yearToStr.value) q.year_to = yearToStr.value
+ if (datePreset.value === 'custom') q.date_preset = 'custom'
+ if (urlDateFrom.value) q.date_from = urlDateFrom.value
+ else if (yearFromStr.value) q.year_from = yearFromStr.value
+ if (urlDateTo.value) q.date_to = urlDateTo.value
+ else if (yearToStr.value) q.year_to = yearToStr.value
}
if (selectedTags.value.length) q.tag = selectedTags.value.join(',')
if (selectedTiers.value.length) q.tier = selectedTiers.value.join(',')
@@ -835,9 +838,11 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
每页: