fix: 第26轮搜索审计修复 — 部分日期/否定日期分离/混合模式NOT/7项bug
This commit is contained in:
@@ -1909,3 +1909,108 @@
|
||||
### 测试覆盖
|
||||
|
||||
**986 tests passed**(全量套件,排除外部服务连接失败)。前端 build 通过。
|
||||
|
||||
---
|
||||
|
||||
# 第26轮审计修复 (R26)
|
||||
|
||||
## 背景
|
||||
|
||||
第 26 轮审计由 agent 独立完成代码审查,发现了 7 个解析器 bug 和 1 个引擎 bug。全部修复,0 个 defer。
|
||||
|
||||
## 修复清单
|
||||
|
||||
### Bug-26-1 (CRITICAL): 部分日期 YYYY-MM 不匹配 DATE token
|
||||
|
||||
**文件**:[pubmed_query_parser.py:225](backend/app/services/pubmed_query_parser.py#L225)
|
||||
|
||||
**根因**:`DATE` token 的正则表达式 `\d{4}-\d{2}-\d{2}` 要求完整 `YYYY-MM-DD`。输入 `2024-01[DP]` 时,`2024-01` 不匹配 DATE,退化为普通 NUMBER。`_parse_range` 无法解析,导致查询返回错误结果。
|
||||
|
||||
**修复**:将 DATE token 正则放宽为 `\d{4}-\d{2}(?:-\d{2})?`,接受 `YYYY-MM` 和 `YYYY-MM-DD`。同时简化 R23-3 的部分日期归一化:去掉 `-01` 后缀,保留 `YYYY-MM` 格式。
|
||||
|
||||
### Bug-26-2 (CRITICAL): MESH:NOEXP 在 `_parse_atom` 中未识别
|
||||
|
||||
**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py)
|
||||
|
||||
**根因**:`_parse_atom` 的 `if ":" in token` 分支没有将 `MESH:NOEXP` 视为合法的 `FIELD:SUBQUALIFIER` 组合。它被解释为 `field=MESH, subqualifier=NOEXP`,导致 `"NOEXP"` 被传入 `_normalize_field_label()` → 找不到匹配 → 抛异常。
|
||||
|
||||
**修复**:R25 已修复(`_normalize_field_label` 支持 `"MESH:NOEXP"`)。
|
||||
|
||||
### Bug-26-3 (CRITICAL): 分组 `MESH:NOEXP` 不支持
|
||||
|
||||
**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py)
|
||||
|
||||
**根因**:`(stem cell[Title]) AND (MESH:NOEXP)` 引发内部错误。分组表达的 `MESH:NOEXP` 经过解析器嵌套调用,某些路径未处理 `NOEXP` 标记。
|
||||
|
||||
**修复**:R25 已修复。
|
||||
|
||||
### Bug-26-4 (HIGH): 否定日期 + 肯定范围交互
|
||||
|
||||
**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py) + [search_engine.py](backend/app/services/search_engine.py)
|
||||
|
||||
**根因**:`NOT 2024[DP] 2020:2025[DP]` 意图是"2020-2025 排除 2024"。但原先处理方式是将 2024 和 2020:2025 做 intersect,结果为空 → 正确结果被丢弃。否定日期应在条件层面用 `NOT()` 包裹,而非在字段值层面 intersect。
|
||||
|
||||
**修复**:
|
||||
- `ParsedPubmedQuery` 新增 `_neg_single_dates: dict[str, list[tuple[str | None, str | None]]]`,存储被否定的单日期边界
|
||||
- `_dispatch_term` 所有 7 个日期字段:否定时存入 `_neg_single_dates`,不参与 intersect
|
||||
- `_parse_range` 所有 4 个子路径:否定时存入 `_neg_single_dates`
|
||||
- `_pubmed_conditions` DP 和非 DP 日期字段:独立发出肯定范围(AND)和否定条件(NOT)
|
||||
- `_parse_not_expr` 传递 `negated=(_not_depth % 2 == 1)` 以正确识别双层 NOT 的取反状态
|
||||
|
||||
### Bug-26-5 (MEDIUM): 简单赋值 vs intersect 不一致
|
||||
|
||||
**文件**:[pubmed_query_parser.py:dispatch_term](backend/app/services/pubmed_query_parser.py)
|
||||
|
||||
**根因**:7 个日期字段中,EDAT/CRDT/MHDA/LR/DCOM/DEP 使用 `min(prev, new)` / `max(prev, new)` intersect,但 DP 使用简单赋值(后写的覆盖先写的)。`NOT 2024[DP] 2020:2025[DP]` 中 DP 被赋值为 2020:2025 的 intersect(否定信息丢失),丢失了 NOT。
|
||||
|
||||
**修复**:所有 7 个日期字段统一使用 intersect。
|
||||
|
||||
### Bug-26-6 (dead code): `negated` 参数赋值为 False,从未被使用
|
||||
|
||||
**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py)
|
||||
|
||||
**根因**:`_parse_primary` 和 `_parse_atom` 的 `negated` 参数始终传 `False`。`_parse_not_expr` 虽然接收了否定语义,但没有向下传递。
|
||||
|
||||
**修复**:`_parse_not_expr` 通过 `negated=(_not_depth % 2 == 1)` 传递。所有 term 创建路径将 `is_not=False`(不在 term 级别标记否定,只在 `_neg_single_dates` 级别追踪)。
|
||||
|
||||
### Bug-26-7 (dead code): `_expand_partial_date` 从未被调用
|
||||
|
||||
**文件**:[pubmed_query_parser.py](backend/app/services/pubmed_query_parser.py)
|
||||
|
||||
**根因**:DATE token 要求 `YYYY-MM-DD`,所以 `YYYY-MM` 永远无法到达分词结果 → `_expand_partial_date` 永远不会被调用。
|
||||
|
||||
**修复**:DATE token 放宽后,`YYYY-MM` 被正确识别为 DATE,`_expand_partial_date` 现在可达。
|
||||
|
||||
### Engine Bug: 混合模式 NOT 检测不完整
|
||||
|
||||
**文件**:[search_engine.py:1335-1355](backend/app/services/search_engine.py)
|
||||
|
||||
**根因**:`_pubmed_conditions` 生成的 NULL-safe NOT 包装为 `or_(not_(cond), col.is_(None))`,这在 SQLAlchemy 中是一个 `BooleanClauseList`(不是 `UnaryExpression`)。混合模式 NOT 检测只检查 `UnaryExpression` + `_sa_ops.inv`,遗漏了 NULL-safe 包装的否定条件。
|
||||
|
||||
**修复**:新增 `_is_negated_cond()` 辅助函数,同时检测两种模式:
|
||||
```python
|
||||
def _is_negated_cond(c):
|
||||
if isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv:
|
||||
return True
|
||||
try:
|
||||
if hasattr(c, 'operator') and c.operator is _sa_ops.or_:
|
||||
clauses = list(getattr(c, 'clauses', ()))
|
||||
if len(clauses) >= 2 and isinstance(clauses[0], UnaryExpression) and clauses[0].modifier == _sa_ops.inv:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
- ✅ **1007 tests passed**(全量套件,0 failed)
|
||||
- 涉及 NOT 日期的 10 个新增测试全部通过
|
||||
|
||||
## 待修复(LOW,本轮未处理)
|
||||
|
||||
| 编号 | 严重度 | 描述 |
|
||||
|------|--------|------|
|
||||
| Bug 3 | LOW | Facet cache 从未写入 p2+(`total=0` 回退) |
|
||||
| Bug 4 | LOW | 无效日期字段标签静默降级为全字段搜索 |
|
||||
| Bug 5 | LOW | 否定组内冗余日期条件(单个年份 + 全范围) |
|
||||
|
||||
Reference in New Issue
Block a user