From de1f4a4bb8885f4e7e34303215cf09a98044c4ec Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Tue, 28 Jul 2026 14:08:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC16=E8=BD=AE=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20all=5Fnot/gr?= =?UTF-8?q?oup=5Fnegated=E5=88=86=E7=A6=BB=20+=20=5Fparse=5Fprimary?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=BB=84=20+=20=E9=94=99=E8=AF=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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类型转换优化 --- backend/app/api/v1/literature.py | 2 +- backend/app/services/pubmed_query_parser.py | 28 +++++++++---- backend/app/services/search_engine.py | 14 +++++-- docs/13-搜索修复全记录.md | 46 +++++++++++++++++++++ frontend/src/components.d.ts | 1 + frontend/src/views/app/SearchView.vue | 7 +++- 6 files changed, 85 insertions(+), 13 deletions(-) diff --git a/backend/app/api/v1/literature.py b/backend/app/api/v1/literature.py index 13ef421..091e52e 100644 --- a/backend/app/api/v1/literature.py +++ b/backend/app/api/v1/literature.py @@ -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) diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 1eb5533..a89dd38 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -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 diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 8defed4..35cba2b 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -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] diff --git a/docs/13-搜索修复全记录.md b/docs/13-搜索修复全记录.md index 435dbb7..a6cb87a 100644 --- a/docs/13-搜索修复全记录.md +++ b/docs/13-搜索修复全记录.md @@ -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 = []` 导致 `` 显示"未找到匹配文献",用户以为搜索有结果只是条件过严,实际是后端错误 +- **修复**:新增 `searchError` ref,catch 时设置明确错误信息,模板条件渲染 `` 错误面板替代 `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 | 问题 | 原因 | 影响 | diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts index 68c0c60..4fb06c4 100644 --- a/frontend/src/components.d.ts +++ b/frontend/src/components.d.ts @@ -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'] diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index c945e26..0b46778 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -28,6 +28,7 @@ const sort = ref('date') const results = ref([]) const loading = ref(false) const searched = ref(false) +const searchError = ref('') const savedPmids = ref>(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 || {}) - + +