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类型转换优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1207,6 +1207,52 @@
|
||||
|
||||
---
|
||||
|
||||
## 第十六轮:第 16 轮审计修复(4 项修复 + 3 项记录)
|
||||
|
||||
**日期**:2026-07-29
|
||||
**提交**:`(待推送)`
|
||||
**数量**:4 项修复 + 3 项记录
|
||||
**触发**:用户第 11 次要求全面检查(Round 16,3 并行 agent:Normal 搜索边缘、前端参数、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 | 问题 | 原因 | 影响 |
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user