Compare commits

...
2 Commits
Author SHA1 Message Date
34047007@qq.com 62be0efb59 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
2026-07-27 13:01:52 +08:00
34047007@qq.com 4495ef9e0a fix: 第六轮全面搜索审计修复 — 12项
Parser: Title/Abstract大小写匹配、末尾OR空Term保护、tokeniser gap字符恢复、
混合日期范围反转支持
Engine: field标签正则含/、ATM括号剥离、name_zh LIMIT 100、
year_from/ year_to is not null检查
Docs: 更新修复全记录至第六轮
2026-07-27 12:47:09 +08:00
6 changed files with 347 additions and 55 deletions
+1 -1
View File
@@ -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
+40 -10
View File
@@ -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 → 同 GRgrant_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,16 @@ 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
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
@@ -682,22 +701,33 @@ 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)
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:
+41 -24
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)
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())
@@ -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 [])
@@ -244,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:
@@ -281,11 +282,12 @@ 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)
except Exception:
logger.exception("ATM expansion failed (flat text): %s", _atm_query[:100])
_atm_cond = None
_cond_before = len(conditions)
@@ -309,9 +311,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)
# 具体日期范围(按天搜索)
@@ -334,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:
@@ -385,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 筛选器 ──
@@ -493,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]
@@ -608,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,
@@ -630,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:
@@ -647,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:
@@ -963,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
@@ -982,6 +988,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 = {
@@ -1091,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":
@@ -1170,7 +1187,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_(
@@ -1235,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
@@ -1259,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:
+244 -12
View File
@@ -2,9 +2,9 @@
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
>
> **累计**5 轮,63 项修复,50+ 字段标签注册,214 项测试覆盖
> **累计**7 轮,95 项修复,50+ 字段标签注册,276 项测试覆盖
> **时间跨度**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,246 @@
- **根因**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 产生空 TermBUG-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 查询无 LIMITFlat 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 MorganF2
- **文件**`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: 前端日期发送 + restoreFromUrlF4+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]` | — | — |
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 | 审计修复 | 深度审计修复 |
---
## 第七轮:第 7 轮深度审计修复(20 项)
**日期**2026-07-27
**数量**20 项(4 Agent 第 2 轮并行审计)
**触发**:用户"再次全面、深入地检查、分析,消除漏洞"
**测试**276 通过(新增 ~28 项),13 项预存失败
### P7-1: `isdecimal()` 非 ASCII 数字崩溃 PMID 检测(HIGH
- **文件**`search_engine.py` `search()` L245-246
- **问题**`str.isdecimal()` 对阿拉伯数字 U+0660 等返回 True,但 `int()` 不接受非 ASCII 数字 → `ValueError`,搜索返回 500
- **根因**Python 的 isdecimal() 包含 Unicode 数字字符,int() 只认 ASCII
- **修复**`t.isdecimal()` → `re.match(r'^\d{1,15}$', t)`
### P7-2: 降级 regex `[\w/: -]` 兼容冒号
- **文件**`search_engine.py` `search()` L219
- **问题**`[MH:noexp]` 标签中的冒号不在 `[\w/-]` 内,降级后冒号残留
- **根因**regex 缺少 `:` 和空格
- **修复**`[\w/-]` → `[\w/: -]`
### P7-3: Parse 异常处理器清除字段标签/布尔符/引号
- **文件**`pubmed_query_parser.py` `parse_pubmed_query()` L719-726
- **问题**:降级时 `query.strip().split()` 产生含 `"`、`[`、`]` 的碎片词,传递给 ATM 和 ILIKE 产生无意义匹配
- **根因**:降级路径未做任何清理
- **修复**:先用 regex 去掉 `[field]` 标签、AND/OR/NOT、引号和括号,再 split
### P7-4: `_parse_range` date:year 反向交换(第 4 分支)
- **文件**`pubmed_query_parser.py` `_parse_range()` L621
- **问题**`2026-06-01:2024[DP]` 开始日期、结束年份时未交换
- **根因**:缺少 `not _start_is_digit and _end_is_digit` 分支
- **修复**:增加第 4 分支处理 date:year 反向
### P7-5: `_pubmed_conditions` boolean_operator 应用到字段分组(HIGH
- **文件**`search_engine.py` `_pubmed_conditions()` L612, L635
- **问题**:字段分组(title、abstract 等)内全部用 `and_()` 组合,无视 `boolean_operator="or"`
- **根因**:字段分组硬编码 `and_()`
- **修复**:定义 `field_combine = or_ if boolean_operator=="or" else and_`
### P7-6: `_pubmed_conditions` boolean_operator 应用到无标签词(HIGH
- **文件**`search_engine.py` `_pubmed_conditions()` L652-659
- **问题**:纯文本词固定 AND,分开写的 `cancer OR tumor` 实际变 AND
- **根因**plain_conds 硬编码 `and_()`
- **修复**:全部改用 `field_combine`
### P7-7: best_match 排序剥离字段标签
- **文件**`search_engine.py` `search()` L497
- **问题**sort=="best_match" 时未剥离标签词,`[TI]` 参与 ts_rank 产生噪音
- **根因**:条件只检查 `sort == "relevance"`
- **修复**:改为 `sort in ("relevance", "best_match")`
### P7-8: year_from/year_to 精确空值检测
- **文件**`search_engine.py` `_pubmed_conditions()` L969-972
- **问题**`if pp.year_from:` 当 year_from=0 时 falsy → 条件跳过
- **根因**falsy 检测不适用于年份 0
- **修复**`if pp.year_from is not None`
### P7-9: `_single_term_condition` SB 字段全量分发
- **文件**`search_engine.py` `_single_term_condition()` L1100-1117
- **问题**:组内 SB 只有 nlm_subset 路径,PUBMED/MEDLINE/其他未处理
- **根因**:括号分组内调 `_single_term_condition`,与顶层分发不一致
- **修复**:复制顶层 SB 全量分发逻辑
### P7-10: journal_tiers/nlm_subsets 空条件预警
- **文件**`search_engine.py` `search()` L336-341, L388-393
- **问题**:筛选项匹配 0 个期刊时条件被跳过,用户收到全量结果而非 0 结果
- **根因**`if issns:` 保护,空→跳过
- **修复**:始终添加条件,空 ISSNS 时 0 结果 + `logger.warning`
### P7-11: ATM 展开异常日志化
- **文件**`search_engine.py` `search()` L287, `_pubmed_conditions()` L655
- **问题**flat text 和 pubmed 路径 ATM 异常都用裸 `except Exception: pass`
- **修复**:改为 `logger.exception()`
### P7-12: `_expand_mesh_tag_ids` 异常日志化
- **文件**`search_engine.py` `_expand_mesh_tag_ids()` L1239, L1276
- **问题**MeSH tag 查找和树展开的 `except Exception: pass`
- **修复**:改为 `logger.exception()`
### P7-13: 中文 name_zh ILIKE 加 LIMIT 100
- **文件**`query_expansion.py` `_find_mesh_tags()` L99
- **问题**:中文 name_zh ILIKE 无 LIMIT,常见词匹配数千标签
- **根因**:英文 name_en ILIKE 已有 LIMIT 100,中文忘记加
- **修复**`.limit(100)`
### P7-14: Cursor 分页使用 pub_date 优先(HIGH
- **文件**`SearchView.vue` L358
- **问题**sort 是 pub_date 降序,cursor 却用 article_date → 数据错位/丢失
- **修复**:改为 `pub_date || article_date`
### P7-15: Null cursor 日期安全守卫
- **文件**`SearchView.vue` L361-363
- **问题**:两个日期都为空时 cursor_date="" → `fromisoformat("")` ValueError → 静默回退 offset 分页
- **修复**`delete keysetCursors.value[p+1]` 当两日期都为空
### P7-16: syncSearchToUrl 移到 finally 块
- **文件**`SearchView.vue` L365, L373-376
- **问题**:搜索失败时 URL 状态不更新,下次搜索使用过时参数
- **修复**:移到 `finally` 块
### P7-17: 年份滑块清除 URL 日期
- **文件**`SearchView.vue` `onYearSliderChange()` L89
- **问题**:拖动年份滑块后 URL 残留 `date_from`/`date_to` 与滑块设置冲突
- **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''`
### P7-18: resetAllFilters 清除 URL 日期
- **文件**`SearchView.vue` `resetAllFilters()` L528
- **问题**:重置筛选后 urlDateFrom/urlDateTo 依然存在
- **修复**:添加 `urlDateFrom.value=''; urlDateTo.value=''`
### P7-19: MAX_TERMS 保护
- **文件**`pubmed_query_parser.py` `tokenise()`
- **问题**:超长查询(>200 token)产生过多字段条件,数据库超时
- **修复**:扫描到 `MAX_TERMS=200` 后截断并记录 warning
### P7-20: `_FIELD_TAG_MAP` 补充 `TITLE/ABSTRACT` 大写键
- **文件**`pubmed_query_parser.py` `_FIELD_TAG_MAP`
- **问题**:解析器 `.upper()` 产生 `"TITLE/ABSTRACT"` 但 map 只有 `"Title/Abstract"`
- **修复**:增加大写键
---
@@ -415,4 +644,7 @@
| `test_pubmed_query_parser.py` | ~40 | tokeniser、解析器、语法正确性 |
| `test_pubmed_search_integration.py` | ~60 | 字段映射、API 集成、前端格式 |
| `test_comprehensive_verify.py` | ~27 | 字段完整、NOT 语义、括号、日期 |
| **合计** | **214** | 全部通过 |
| `test_comprehensive_verify.py` | ~55 | 第 7 轮新增覆盖(full dispatch、boolean_operator、cursor 等) |
| **合计** | **276** | 全部通过 |
> **预存失败(13 项)**9 项 `feed_engine` `StopAsyncIteration`(测试数据缺失) + 4 项 `pubmed_api` `_tag_article` import(函数已移入 pipeline
+20 -7
View File
@@ -89,6 +89,7 @@ function onYearSliderChange(val: any) {
yearFromStr.value = String(val[0])
yearToStr.value = String(val[1])
datePreset.value = null
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
if (_sliderTimer) clearTimeout(_sliderTimer)
_sliderTimer = setTimeout(() => goToPage(1), 250)
}
@@ -352,20 +353,26 @@ const { page, total, goToPage } = usePagination({
if (items.length > 0) {
const last = items[items.length - 1]
keysetCursors.value[p + 1] = {
cursor_date: last.article_date || last.pub_date || '',
cursor_date: last.pub_date || last.article_date || '',
cursor_id: last.id,
}
// 当两个日期都为空时,不设游标(避免空串 fromisoformat 失败)
if (!last.pub_date && !last.article_date) {
delete keysetCursors.value[p + 1]
}
}
} else {
total.value = data.total || 0
}
yearCounts.value = data.year_counts || []
syncSearchToUrl()
} catch (e: any) {
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
toast.apiError(e, '搜索失败,请重试')
}
finally { if (gen === searchGeneration.value) loading.value = false }
finally {
syncSearchToUrl() // P6: sync URL even on error (avoid URL/state desync)
if (gen === searchGeneration.value) loading.value = false
}
},
pageSize: pageSize.value,
})
@@ -377,7 +384,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 +494,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(',')
@@ -515,6 +525,7 @@ function syncSearchToUrl() {
function resetAllFilters() {
yearFromStr.value = ''; yearToStr.value = ''
urlDateFrom.value = ''; urlDateTo.value = '' // P6: clear stale URL dates
datePreset.value = null
selectedTiers.value = []
selectedTags.value = []
@@ -835,9 +846,11 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
<NSelect v-model:value="sort" :options="[
{label:'Best Match',value:'best_match'},
{label:'Most Recent',value:'date'},
{label:'Publication date',value:'pub_date'},
{label:'Most Cited',value:'cited'},
{label:'Relevance',value:'relevance'},
{label:'First author',value:'first_author'},
{label:'Journal',value:'journal'},
{label:'Title',value:'title'},
]" size="tiny" style="width:150px" @update:value="goToPage(1)" />
<span class="toolbar-label">每页:</span>
<NSelect v-model:value="pageSize" :options="[