Compare commits
2
Commits
b26f5572e9
...
4e964c955b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e964c955b | ||
|
|
a985d07724 |
@@ -49,7 +49,7 @@ async def test_rule(req: TestRuleRequest, pmid: int = Query(...), db: AsyncSessi
|
||||
# ─── 高级搜索 ───
|
||||
|
||||
class AdvancedSearchRequest(BaseModel):
|
||||
query: str = ""
|
||||
query: str = Field("", max_length=2000)
|
||||
field: str = "all"
|
||||
boolean: str = "and"
|
||||
exact_phrase: bool = False
|
||||
|
||||
@@ -55,8 +55,10 @@ class AdvancedSearchEngine:
|
||||
) -> str:
|
||||
"""归一化查询参数 → 确定性缓存 key(所有 list 排序后参与哈希)"""
|
||||
import hashlib, json
|
||||
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
|
||||
norm = {
|
||||
"q": query.strip().lower(),
|
||||
"pm": _is_pm(query),
|
||||
"f": field, "b": boolean, "ep": exact_phrase,
|
||||
"yf": year_from, "yt": year_to,
|
||||
"df": date_from, "dt": date_to,
|
||||
@@ -109,8 +111,10 @@ class AdvancedSearchEngine:
|
||||
与 _search_cache_key 的区别:不含 p/ps/s/cd/ci。
|
||||
"""
|
||||
import hashlib, json
|
||||
from app.services.pubmed_query_parser import is_pubmed_syntax as _is_pm
|
||||
norm = {
|
||||
"q": query.strip().lower(),
|
||||
"pm": _is_pm(query),
|
||||
"f": field, "b": boolean, "ep": exact_phrase,
|
||||
"yf": year_from, "yt": year_to,
|
||||
"df": date_from, "dt": date_to,
|
||||
@@ -1144,10 +1148,13 @@ class AdvancedSearchEngine:
|
||||
|
||||
# 将 term_conditions 加入 conditions
|
||||
if term_conditions:
|
||||
if pp.boolean_operator in ("or", "mixed"):
|
||||
if pp.boolean_operator == "or":
|
||||
# OR 模式:所有条件(含 NOT)OR 在一起
|
||||
conditions.append(or_(*term_conditions))
|
||||
elif pp.boolean_operator == "mixed":
|
||||
# mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C)
|
||||
from sqlalchemy.sql.elements import UnaryExpression
|
||||
from sqlalchemy.sql import operators as _sa_ops
|
||||
# OR/mixed 模式下 NOT 项应独立 AND(PubMed: A OR B NOT C = (A OR B) AND NOT C)
|
||||
pos_conds = [c for c in term_conditions
|
||||
if not (isinstance(c, UnaryExpression) and c.modifier == _sa_ops.inv)]
|
||||
neg_conds = [c for c in term_conditions
|
||||
@@ -1382,7 +1389,11 @@ class AdvancedSearchEngine:
|
||||
pat = _pt()
|
||||
if exact and not _wildcard:
|
||||
# P4: 精确短语 → phraseto_tsquery(利用 GIN 索引,保留词序)
|
||||
return GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term))
|
||||
return or_(
|
||||
GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term)),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
if _wildcard:
|
||||
# wildcard → ILIKE 右截断(tsvector 不支持 *),多字段覆盖
|
||||
return or_(
|
||||
@@ -1393,6 +1404,8 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.journal_iso.ilike(pat),
|
||||
cast(GlobalLiterature.pmid, String).ilike(pat),
|
||||
GlobalLiterature.doi.ilike(pat),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
like_val = f"%{_escaped}%"
|
||||
if "/" in term:
|
||||
@@ -1400,6 +1413,8 @@ class AdvancedSearchEngine:
|
||||
return or_(
|
||||
GlobalLiterature.doi.ilike(_escape_ilike(term)),
|
||||
GlobalLiterature.doi.ilike(like_val),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
return or_(
|
||||
GlobalLiterature.title.ilike(like_val),
|
||||
@@ -1408,6 +1423,8 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.abstract.ilike(like_val),
|
||||
GlobalLiterature.author_names_text.ilike(like_val),
|
||||
GlobalLiterature.journal.ilike(like_val),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
# P7-D2: Chinese → ILIKE fallback (tsvector is English-only)
|
||||
if re.search(r'[一-鿿㐀-䶿豈-]', term):
|
||||
@@ -1416,6 +1433,8 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.abstract.ilike(like_val),
|
||||
GlobalLiterature.author_names_text.ilike(like_val),
|
||||
GlobalLiterature.journal.ilike(like_val),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
# tsvector 索引主覆盖 title/abstract/author_names/chemicals/genes/mesh/keywords
|
||||
# journal/journal_iso/affiliation 不在 tsvector 中,以 ILIKE 兜底
|
||||
@@ -1423,6 +1442,8 @@ class AdvancedSearchEngine:
|
||||
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", term)),
|
||||
GlobalLiterature.journal.ilike(like_val),
|
||||
GlobalLiterature.journal_iso.ilike(like_val),
|
||||
text("EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
|
||||
"WHERE _e->>'affiliation' ILIKE :aff_pat)").bindparams(aff_pat=pat),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+58
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||
>
|
||||
> **累计**:9 轮,115 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||
> **累计**:10 轮,121 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||
> **时间跨度**:2026-07-24 ~ 2026-07-28
|
||||
> **核心文件**:`pubmed_query_parser.py`(~730 行)→ `search_engine.py`(~1320 行)
|
||||
> **核心文件**:`pubmed_query_parser.py`(~730 行)→ `search_engine.py`(~1350 行)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
7. [第七轮:第 7 轮深度审计修复(20 项)](#第七轮第-7-轮深度审计修复)
|
||||
8. [第八轮:第 8 轮深度审计修复(12 项)](#第八轮第-8-轮深度审计修复)
|
||||
9. [第九轮:第 9 轮深度审计修复(5 项)](#第九轮第-9-轮深度审计修复)
|
||||
10. [遗留限制](#遗留限制)
|
||||
10. [第十轮:第 10 轮深度审计修复(6 项)](#第十轮第-10-轮深度审计修复)
|
||||
11. [遗留限制](#遗留限制)
|
||||
|
||||
---
|
||||
|
||||
@@ -773,6 +774,60 @@
|
||||
|
||||
---
|
||||
|
||||
## 第十轮:第 10 轮深度审计修复(6 项)
|
||||
|
||||
**日期**:2026-07-28
|
||||
**提交**:`a985d07`
|
||||
**数量**:6 项
|
||||
**测试**:1007 全部通过 + 前端 build 通过
|
||||
|
||||
### P10-1: 缓存键 PubMed/纯文本路径碰撞(MEDIUM)
|
||||
|
||||
- **文件**:`search_engine.py:33-86,88-144`
|
||||
- **根因**:`_search_cache_key` 和 `_facet_cache_key` 只对 query 做 `strip().lower()` 归一化,不区分 PubMed 路径和纯文本路径。相同文本先走 PubMed 路径被缓存,后续纯文本路径命中同样的 key 返回不匹配的结果
|
||||
- **修复**:在 norm dict 中加入 `"pm": _is_pm(query)` 标志,两路径缓存键自动分离
|
||||
|
||||
### P10-2: OR 模式 NOT 语义错误(HIGH)
|
||||
|
||||
- **文件**:`search_engine.py:1151-1153`
|
||||
- **根因**:`boolean_operator == "or"` 路径中,代码提取 `neg_conds` 然后 `and_(pos_conds + neg_conds)`。正确语义应为 `A OR NOT B` 等价于 `A OR (NOT B)`,而非 `(A) AND (NOT B)`
|
||||
- **修复**:OR 模式直接 `or_(*term_conditions)`,不做 NOT 分离
|
||||
- **验证**:原有 `test_or_mode_mixed_not` 等测试正确通过
|
||||
|
||||
### P10-3: Affiliation 始终不在全字段搜索中(MEDIUM)
|
||||
|
||||
- **文件**:`search_engine.py:1388-1433`
|
||||
- **根因**:`_field_condition("all")` 的 5 条分支(exact/phraseto_tsquery、wildcard ILIKE、"/" 路径、中文路径、tsvector 默认)均未包含 affiliation ILIKE 兜底。虽然 tsvector 默认路径注释写"affiliation 不在 tsvector 中,以 ILIKE 兜底",但代码中并未实现
|
||||
- **修复**:所有 5 条分支均添加 `jsonb_array_elements(authors)` 的 `->>'affiliation' ILIKE` 子查询
|
||||
- **影响**:搜索"mayo clinic"或"MD Anderson"等机构名在全字段搜索中生效
|
||||
|
||||
### P10-4: 高级搜索无 query max_length(LOW)
|
||||
|
||||
- **文件**:`features.py:52`
|
||||
- **修复**:`query: str = Field("", max_length=2000)`
|
||||
- **注意**:延续 P9-5 的修复,Pydantic 层面增加长度限制
|
||||
|
||||
### P10-5: 前端缺少 OR 模式切换(MEDIUM)
|
||||
|
||||
- **文件**:`SearchView.vue:45-46,278-284,547-550`
|
||||
- **根因**:`boolean: 'and'` 硬编码,前端无法发起 OR 搜索
|
||||
- **修复**:
|
||||
- 搜索栏添加 AND/OR 选择器(NSelect)
|
||||
- `booleanOp` ref 驱动 `body.boolean`
|
||||
- URL 同步:`route.query.boolean === 'or'` 恢复
|
||||
- `resetAllFilters` 重置
|
||||
|
||||
### P10-6: 前端缺少精确短语模式(LOW)
|
||||
|
||||
- **文件**:`SearchView.vue:45-46,284,548-550`
|
||||
- **根因**:`exact_phrase` 在 TypeScript 接口中存在但从未从前端发送
|
||||
- **修复**:
|
||||
- 搜索栏添加"精确短语"复选框
|
||||
- `body.exact_phrase` 条件发送
|
||||
- URL 同步/恢复
|
||||
|
||||
---
|
||||
|
||||
## 遗留限制
|
||||
|
||||
截至 2026-07-28,剩余 7 项已知限制:
|
||||
|
||||
@@ -44,6 +44,8 @@ const negativeResult = ref('') // '' = all, 'yes', 'no', 'only'
|
||||
const selectedSpecies = ref<string[]>([])
|
||||
const selectedSex = ref<string[]>([])
|
||||
const selectedAge = ref<string[]>([])
|
||||
const booleanOp = ref('and') // "and" | "or"
|
||||
const exactPhrase = ref(false)
|
||||
|
||||
// ── PubMed 筛选器状态 ──
|
||||
const hasAbstract = ref(false)
|
||||
@@ -277,8 +279,9 @@ const { page, total, goToPage } = usePagination({
|
||||
}
|
||||
const body: SearchRequestBody = {
|
||||
query: query.value, page: p, page_size: pageSize.value, sort: sort.value,
|
||||
boolean: 'and',
|
||||
boolean: booleanOp.value,
|
||||
}
|
||||
if (exactPhrase.value) body.exact_phrase = true
|
||||
if (field.value !== 'all') body.field = field.value
|
||||
// P3-6: precision_mode 不再发送(后端已忽略)
|
||||
// ── Keyset 游标分页(所有排序模式通用,跳过 COUNT + OFFSET) ──
|
||||
@@ -401,6 +404,8 @@ function restoreFromQuery() {
|
||||
if (route.query.tier) selectedTiers.value = String(route.query.tier).split(',')
|
||||
if (route.query.pub_type) pubTypes.value = String(route.query.pub_type).split(',')
|
||||
if (route.query.lang) languages.value = String(route.query.lang).split(',')
|
||||
if (route.query.boolean === 'or') booleanOp.value = 'or'
|
||||
if (route.query.exact_phrase === 'true') exactPhrase.value = true
|
||||
if (route.query.subset) nlmSubsets.value = String(route.query.subset).split(',')
|
||||
if (route.query.retracted) retracted.value = String(route.query.retracted)
|
||||
if (route.query.negative) negativeResult.value = String(route.query.negative)
|
||||
@@ -514,6 +519,8 @@ function syncSearchToUrl() {
|
||||
if (hasAssociatedData.value) q.associated_data = 'true'
|
||||
if (medlineOnly.value) q.medline_only = 'true'
|
||||
if (excludePreprints.value) q.exclude_preprints = 'true'
|
||||
if (booleanOp.value !== 'and') q.boolean = booleanOp.value
|
||||
if (exactPhrase.value) q.exact_phrase = 'true'
|
||||
if (pageSize.value !== 20) q.page_size = String(pageSize.value)
|
||||
// 页码持久化到 URL(Keyset 排序使用 keyset 页码,offset 排序使用 offset 页码)
|
||||
const currentPage = KEYSET_SORTS.has(sort.value) ? keysetPage.value : page.value
|
||||
@@ -544,6 +551,8 @@ function resetAllFilters() {
|
||||
query.value = ''
|
||||
field.value = 'all'
|
||||
sort.value = 'date'
|
||||
booleanOp.value = 'and'
|
||||
exactPhrase.value = false
|
||||
goToPage(1)
|
||||
}
|
||||
|
||||
@@ -829,7 +838,9 @@ const specialTags = computed(() => filterOptions.value?.special_tags || {})
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrap"><NInput v-model:value="query" placeholder='搜索标题、摘要、作者、MeSH词... 支持PubMed语法如 "lung cancer"[TI]' size="small" clearable aria-label="搜索文献" @keyup.enter="goToPage(1)" /></div>
|
||||
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" />
|
||||
<NSelect v-model:value="booleanOp" :options="[{label:'AND',value:'and'},{label:'OR',value:'or'}]" size="small" style="width:75px;flex-shrink:0" />
|
||||
<NCheckbox v-model:checked="exactPhrase" size="small" style="flex-shrink:0;font-size:13px;white-space:nowrap">精确短语</NCheckbox>
|
||||
<NSelect v-model:value="field" :options="[{label:'全部',value:'all'},{label:'标题',value:'title'},{label:'摘要',value:'abstract'},{label:'作者',value:'author'},{label:'机构',value:'affiliation'},{label:'期刊',value:'journal'}]" size="small" style="width:90px;flex-shrink:0" />
|
||||
<NButton class="sky-btn" size="small" :loading="loading" :disabled="loading" @click="goToPage(1)"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
|
||||
<NButton class="sky-btn" :ghost="showFilters" size="small" @click="showFilters=!showFilters"><template #icon><NIcon size="14"><component :is="showFilters ? EyeOffOutline : FilterOutline" /></NIcon></template>{{ showFilters?'隐藏筛选':'筛选' }}</NButton>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user