fix: 第16轮搜索审计修复 — all_not/group_negated分离 + _parse_primary重复组 + 错误显示
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

- 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类型转换优化
This commit is contained in:
34047007@qq.com
2026-07-28 14:08:46 +08:00
parent 1f46f05586
commit de1f4a4bb8
6 changed files with 85 additions and 13 deletions
+1 -1
View File
@@ -291,7 +291,7 @@ async def search_literature(
)).scalars().all()
if _tag_matches:
_tag_lit_subq = select(GlobalLiteratureTag.literature_id).where(
GlobalLiteratureTag.tag_id.in_([str(t) for t in _tag_matches])
GlobalLiteratureTag.tag_id.in_(list(_tag_matches))
)
search_cond = or_(search_cond, GlobalLiterature.id.in_(_tag_lit_subq))
count_q = select(func.count(GlobalLiterature.id)).where(search_cond)
+20 -8
View File
@@ -309,6 +309,7 @@ class ParsedPubmedQuery:
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
@@ -632,6 +633,12 @@ class PubmedQueryParser:
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):
@@ -702,14 +709,19 @@ class PubmedQueryParser:
_field = _normalize_field_label(_raw_field)
for t in terms:
t.field = _field
# 标记为子组,不放入 flat lists,保留括号分组结构
group_id = len(result.groups)
for t in terms:
t.group_id = group_id
result.groups.append(terms)
# P2-2: 检测组内是否有显式 OR
_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")
# 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
+11 -3
View File
@@ -1132,7 +1132,15 @@ class AdvancedSearchEngine:
# 处理括号分组的词(保留 OR/AND 嵌套结构,P2-2
if pp.groups:
for idx, group in enumerate(pp.groups):
all_not = all(t.is_not for t in group)
# P16: group_negated[idx] tracks external NOT wrapper (NOT (...)),
# vs is_not per-term (internal NOT, set by _parse_not_expr).
# Using group_negated instead of all(t.is_not for t in group)
# fixes the case where all terms have is_not internally
# from individual NOTs: (NOT A OR NOT B) → all_not=True but
# should NOT be wrapped in a single not_(or_(...)).
_negated = (pp.group_negated[idx]
if idx < len(pp.group_negated)
else False)
g_pos = []
g_neg = []
for t in group:
@@ -1143,7 +1151,7 @@ class AdvancedSearchEngine:
continue
cond = await AdvancedSearchEngine._single_term_condition(db, t)
if cond is not None:
if all_not:
if _negated:
g_neg.append(cond) # raw condition,外部统一 not_()
elif t.is_not:
g_neg.append(not_(cond))
@@ -1154,7 +1162,7 @@ class AdvancedSearchEngine:
else "and")
combine_fn = or_ if gop == "or" else and_
if all_not:
if _negated:
# NOT(A OR B): single UnaryExpression → 顶层 OR/NOT 分离时被检测为 neg → 独立 AND
if g_neg:
combined = combine_fn(*g_neg) if len(g_neg) > 1 else g_neg[0]
+46
View File
@@ -1207,6 +1207,52 @@
---
## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
**日期**2026-07-29
**提交**`(待推送)`
**数量**:4 项修复 + 3 项记录
**触发**:用户第 11 次要求全面检查(Round 163 并行 agentNormal 搜索边缘、前端参数、NOT 检测)
**测试**1007 全部通过 + 前端 build 通过
### Bug-1 (MEDIUM): `_parse_primary` 括号组内重复 group 赋值
- **文件**`pubmed_query_parser.py:706-709`
- **根因**`_parse_or_expr` 在 `A OR B AND C` 时为 `[B, C]` 创建 sub-group。随后 `_parse_primary` 将所有 terms(含已 sub-group 的)再统一加到 parent group。sub-group 内的 term 同时出现在两个 group → `_pubmed_conditions` 遍历 group 列表时为其生成两套条件 → SQL 中产生重复/多余的过滤条件,静默排除合法结果
- **影响**`NOT (A OR B AND C)` 类带 sub-group 的括号组查询可能返回零结果
- **修复**`_parse_primary` 只从 `t.group_id < 0`(未分配)的 term 创建 parent group。sub-group 已分配的不再加入。同时增加 depth 守卫:`_parse_or_expr` 在 `self._depth > 0`(括号内)时直接 flat 返回,不创建 sub-group
### Bug-2 (MEDIUM): `all_not` 混淆外部 NOT 与内部 NOT
- **文件**`search_engine.py:1134-1172`
- **根因**`all_not = all(t.is_not for t in group)` 无法区分 `NOT (A OR B)`(外部 NOT:应生成 `not_(or_(A, B))` 和 `(NOT A OR NOT B)`(内部 NOT:应生成 `or_(not_(A), not_(B))`)。两者都 `all_not=True`,但语义完全不同
- **修复**
- 解析器端:新增 `ParsedPubmedQuery.group_negated: list[bool]` 字段,`_parse_primary` 在创建 parent group 时记录是否为外部 NOT wrapper
- 引擎端:用 `group_negated[idx]` 替代 `all_not`,外部 NOT 走 `not_(combine_fn(g_neg))`,内部 NOT 走 `combine_fn(g_pos + g_neg_with_not_)`
- **验证**`NOT (A OR B)` 与 `(NOT A OR NOT B)` 生成不同的 SQL 条件组合
### Bug-3 (MEDIUM): 搜索错误显示为"no results"
- **文件**`SearchView.vue:365-367`
- **根因**catch 块只调用 `toast.apiError()`(瞬态通知提示),但 `results = []` 导致 `<NEmpty>` 显示"未找到匹配文献",用户以为搜索有结果只是条件过严,实际是后端错误
- **修复**:新增 `searchError` ref,catch 时设置明确错误信息,模板条件渲染 `<NResult>` 错误面板替代 `NEmpty`。成功搜索时清除 `searchError`
### Bug-4 (LOW): UUID 类型转换在中文标签子查询中
- **文件**`literature.py:293-294`
- **根因**`[str(t) for t in _tag_matches]` 将 UUID 转字符串后传给 `in_(...)`,某些驱动下可能导致类型不匹配
- **修复**:改为 `list(_tag_matches)` 传递原生 UUID 对象
### 审计结果汇总
| 审计维度 | 结果 |
|---------|------|
| Normal 搜索边缘情况 | ✅ `_parse_primary` 重复 group 已修复。PubMed 降级路径 field tag 清洗已正确。ATM 展开括号剥离已正确 |
| 前端参数发送 | ✅ SearchView.vue 完整发送全部 28 个参数,`SearchRequestBody` 类型正确 |
| NOT 检测 | ✅ `group_negated` 新增 track`all_not` 已替换。NOT-wrapped parens 与 sub-group 交互部分缓解(depth guard)。剩余 De Morgan 双重否定场景(LOW,理论正确性,实际罕见) |
---
截至 2026-07-29,剩余 7 项已知限制:
| ID | 问题 | 原因 | 影响 |
+1
View File
@@ -15,6 +15,7 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NoteEditModal: typeof import('./components/notes/NoteEditModal.vue')['default']
NotificationBell: typeof import('./components/common/NotificationBell.vue')['default']
NResult: typeof import('naive-ui')['NResult']
PageSkeleton: typeof import('./components/common/PageSkeleton.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+6 -1
View File
@@ -28,6 +28,7 @@ const sort = ref('date')
const results = ref<LiteratureItem[]>([])
const loading = ref(false)
const searched = ref(false)
const searchError = ref('')
const savedPmids = ref<Set<number>>(new Set())
// ── 筛选参数 ──
@@ -361,8 +362,11 @@ const { page, total, goToPage } = usePagination({
delete keysetCursors.value[p + 1]
}
yearCounts.value = data.year_counts || []
searchError.value = ''
} catch (e: any) {
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
results.value = []
searchError.value = '搜索失败,请检查网络或稍后重试'
toast.apiError(e, '搜索失败,请重试')
}
finally {
@@ -897,7 +901,8 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
</div>
<PageSkeleton :loading="loading && !results.length">
<NEmpty v-if="searched&&!loading&&!results.length" description="未找到匹配文献尝试修改搜索条件" />
<NResult v-if="searchError" status="error" :title="searchError" description="可稍后重试或联系管理员" />
<NEmpty v-else-if="searched&&!loading&&!results.length" description="未找到匹配文献尝试修改搜索条件" />
<LiteratureCard
v-for="item in results"