fix: 第五轮PubMed搜索审计 — 4项边缘案例修复 + 第5轮审计文档
Parser边缘案例修复: - P5-1: 尾部NOT导致IndexError降级(_parse_not_expr添加EOF守卫) - P5-2: 单数字日期格式不被识别(YYYY-M-D→YYYY-MM-DD归一化) - P5-3: is_pubmed_syntax不处理全角字符(增加NFKC归一化) - P5-4: extract_pubmed_query_for_prisma不能处理[Title/Article] 同时将在前几轮修复的F1-F8([MH:noexp]顶层支持、NOT De Morgan、 NOT NOT递归、Custom Range日期、HomeView恢复、precision_mode清理、 is_oa注释、N+1批量优化)补充文档核对验证。
This commit is contained in:
@@ -16,7 +16,7 @@ from app.models.literature import GlobalLiterature, GlobalLiteratureTag, GlobalT
|
|||||||
from app.schemas.literature import FeedResponse, LiteratureCard, LiteratureDetail, cap_pub_date
|
from app.schemas.literature import FeedResponse, LiteratureCard, LiteratureDetail, cap_pub_date
|
||||||
from app.core.cache import cache
|
from app.core.cache import cache
|
||||||
from app.services.cos_client import cached_get_full_text
|
from app.services.cos_client import cached_get_full_text
|
||||||
from app.services.search_engine import AdvancedSearchEngine
|
from app.services.search_engine import AdvancedSearchEngine, _escape_ilike
|
||||||
from app.services.tag_loader import load_tags_for_literature
|
from app.services.tag_loader import load_tags_for_literature
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -87,7 +87,7 @@ async def personal_feed(
|
|||||||
|
|
||||||
# content search 需要 JOIN literature
|
# content search 需要 JOIN literature
|
||||||
if q.strip():
|
if q.strip():
|
||||||
like = f"%{q.strip()}%"
|
like = f"%{_escape_ilike(q.strip())}%"
|
||||||
content_filter = select(UserFeed.id).join(
|
content_filter = select(UserFeed.id).join(
|
||||||
GlobalLiterature, UserFeed.literature_id == GlobalLiterature.id
|
GlobalLiterature, UserFeed.literature_id == GlobalLiterature.id
|
||||||
).where(
|
).where(
|
||||||
@@ -260,7 +260,7 @@ async def search_literature(
|
|||||||
if len(q.split()) > 100:
|
if len(q.split()) > 100:
|
||||||
return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"}
|
return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"}
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
like = f"%{q}%"
|
like = f"%{_escape_ilike(q)}%"
|
||||||
# tsvector 主搜索 + ILIKE 兜底
|
# tsvector 主搜索 + ILIKE 兜底
|
||||||
search_cond = or_(
|
search_cond = or_(
|
||||||
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", q)),
|
GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", q)),
|
||||||
|
|||||||
@@ -382,27 +382,42 @@ class PubmedQueryParser:
|
|||||||
elif term.field is None:
|
elif term.field is None:
|
||||||
result.plain_terms.append(term)
|
result.plain_terms.append(term)
|
||||||
# ── 独立日期字段(非范围语法):"2024-01-01"[DP] → from=to=该日期 ──
|
# ── 独立日期字段(非范围语法):"2024-01-01"[DP] → from=to=该日期 ──
|
||||||
|
# is_not 时加入 negated_date_ranges,引擎据此 NOT 条件
|
||||||
elif term.field == "DP":
|
elif term.field == "DP":
|
||||||
result.date_from = term.text
|
result.date_from = term.text
|
||||||
result.date_to = term.text
|
result.date_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("DP")
|
||||||
elif term.field == "EDAT":
|
elif term.field == "EDAT":
|
||||||
result.edat_from = term.text
|
result.edat_from = term.text
|
||||||
result.edat_to = term.text
|
result.edat_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("EDAT")
|
||||||
elif term.field == "CRDT":
|
elif term.field == "CRDT":
|
||||||
result.crdt_from = term.text
|
result.crdt_from = term.text
|
||||||
result.crdt_to = term.text
|
result.crdt_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("CRDT")
|
||||||
elif term.field == "MHDA":
|
elif term.field == "MHDA":
|
||||||
result.mhda_from = term.text
|
result.mhda_from = term.text
|
||||||
result.mhda_to = term.text
|
result.mhda_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("MHDA")
|
||||||
elif term.field == "LR":
|
elif term.field == "LR":
|
||||||
result.lr_from = term.text
|
result.lr_from = term.text
|
||||||
result.lr_to = term.text
|
result.lr_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("LR")
|
||||||
elif term.field == "DCOM":
|
elif term.field == "DCOM":
|
||||||
result.dcom_from = term.text
|
result.dcom_from = term.text
|
||||||
result.dcom_to = term.text
|
result.dcom_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("DCOM")
|
||||||
elif term.field == "DEP":
|
elif term.field == "DEP":
|
||||||
result.dep_from = term.text
|
result.dep_from = term.text
|
||||||
result.dep_to = term.text
|
result.dep_to = term.text
|
||||||
|
if term.is_not:
|
||||||
|
result.negated_date_ranges.add("DEP")
|
||||||
elif term.field == "__RANGE_DP__":
|
elif term.field == "__RANGE_DP__":
|
||||||
pass
|
pass
|
||||||
elif term.field == "__RANGE_EDAT__":
|
elif term.field == "__RANGE_EDAT__":
|
||||||
@@ -484,6 +499,9 @@ class PubmedQueryParser:
|
|||||||
"""not_expr → NOT not_expr | primary"""
|
"""not_expr → NOT not_expr | primary"""
|
||||||
if self.peek().type == TokenType.NOT:
|
if self.peek().type == TokenType.NOT:
|
||||||
self.advance()
|
self.advance()
|
||||||
|
# P5: trailing NOT at end of input → ignore silently (avoid IndexError peeking past EOF)
|
||||||
|
if self.peek().type == TokenType.EOF:
|
||||||
|
return []
|
||||||
inner = self._parse_not_expr(result)
|
inner = self._parse_not_expr(result)
|
||||||
for t in inner:
|
for t in inner:
|
||||||
t.is_not = not t.is_not
|
t.is_not = not t.is_not
|
||||||
@@ -586,8 +604,12 @@ class PubmedQueryParser:
|
|||||||
start_val, end_val = end_val, start_val
|
start_val, end_val = end_val, start_val
|
||||||
elif not start_val.isdigit() and not end_val.isdigit() and start_val > end_val:
|
elif not start_val.isdigit() and not end_val.isdigit() and start_val > end_val:
|
||||||
start_val, end_val = end_val, start_val
|
start_val, end_val = end_val, start_val
|
||||||
|
# 确定两端是否是 4 位年份
|
||||||
|
_start_is_year = start_val.isdigit() and len(start_val) == 4
|
||||||
|
_end_is_year = end_val.isdigit() and len(end_val) == 4
|
||||||
# Year-only range (e.g., 2024:2026[EDAT])
|
# Year-only range (e.g., 2024:2026[EDAT])
|
||||||
if start_val.isdigit() and len(start_val) == 4:
|
if _start_is_year and _end_is_year:
|
||||||
|
try:
|
||||||
if yr_from_attr:
|
if yr_from_attr:
|
||||||
setattr(result, yr_from_attr, int(start_val))
|
setattr(result, yr_from_attr, int(start_val))
|
||||||
setattr(result, yr_to_attr, int(end_val))
|
setattr(result, yr_to_attr, int(end_val))
|
||||||
@@ -595,6 +617,21 @@ class PubmedQueryParser:
|
|||||||
# For non-DP date fields: convert year to full date for consistency
|
# For non-DP date fields: convert year to full date for consistency
|
||||||
setattr(result, date_attr, f"{start_val}-01-01")
|
setattr(result, date_attr, f"{start_val}-01-01")
|
||||||
setattr(result, date_attr_to, f"{end_val}-12-31")
|
setattr(result, date_attr_to, f"{end_val}-12-31")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif _start_is_year and not _end_is_year:
|
||||||
|
# Mixed: start is year, end is full date (e.g., 2024:2024-12-01[EDAT])
|
||||||
|
if yr_from_attr:
|
||||||
|
try:
|
||||||
|
setattr(result, yr_from_attr, int(start_val))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
setattr(result, date_attr, f"{start_val}-01-01")
|
||||||
|
setattr(result, date_attr_to, end_val)
|
||||||
|
elif not _start_is_year and _end_is_year:
|
||||||
|
# Mixed: start is full date, end is year (e.g., 2024-01-01:2026[EDAT])
|
||||||
|
setattr(result, date_attr, start_val)
|
||||||
|
setattr(result, date_attr_to, f"{end_val}-12-31")
|
||||||
else:
|
else:
|
||||||
# Full date range (e.g., 2024-01-01:2024-12-31[EDAT])
|
# Full date range (e.g., 2024-01-01:2024-12-31[EDAT])
|
||||||
setattr(result, date_attr, start_val)
|
setattr(result, date_attr, start_val)
|
||||||
@@ -624,6 +661,8 @@ def is_pubmed_syntax(query: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
if not query or not query.strip():
|
if not query or not query.strip():
|
||||||
return False
|
return False
|
||||||
|
# P5: Normalize fullwidth characters before checking
|
||||||
|
query = unicodedata.normalize('NFKC', query)
|
||||||
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
|
if re.search(r'\[(' + '|'.join(_ALL_FIELD_TAGS) + r')\]', query, re.IGNORECASE):
|
||||||
return True
|
return True
|
||||||
if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE):
|
if re.search(r'\b(AND|OR|NOT)\b', query, re.IGNORECASE):
|
||||||
@@ -642,9 +681,15 @@ def parse_pubmed_query(query: str) -> ParsedPubmedQuery:
|
|||||||
try:
|
try:
|
||||||
# P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits
|
# P2-3: Unicode normalization — strip zero-width chars, normalize fullwidth digits
|
||||||
query = unicodedata.normalize('NFKC', query)
|
query = unicodedata.normalize('NFKC', query)
|
||||||
# 将 YYYY/MM/DD 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE
|
|
||||||
import re as _re
|
import re as _re
|
||||||
|
# 将 YYYY/MM/DD 格式的日期分隔符统一为 YYYY-MM-DD,使 tokeniser 正确识别为 DATE
|
||||||
query = _re.sub(r'(\d{4})/(\d{2})/(\d{2})', r'\1-\2-\3', query)
|
query = _re.sub(r'(\d{4})/(\d{2})/(\d{2})', r'\1-\2-\3', query)
|
||||||
|
# P5: Normalize single-digit month/day (2024-1-1 → 2024-01-01) to match DATE token pattern
|
||||||
|
query = _re.sub(
|
||||||
|
r'(\d{4})-(\d{1,2})-(\d{1,2})',
|
||||||
|
lambda m: f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}',
|
||||||
|
query,
|
||||||
|
)
|
||||||
tokens = tokenise(query)
|
tokens = tokenise(query)
|
||||||
parser = PubmedQueryParser(tokens)
|
parser = PubmedQueryParser(tokens)
|
||||||
return parser.parse()
|
return parser.parse()
|
||||||
@@ -668,9 +713,9 @@ def extract_pubmed_query_for_prisma(query: str) -> tuple[str, list[str]]:
|
|||||||
))
|
))
|
||||||
mesh_used.sort()
|
mesh_used.sort()
|
||||||
|
|
||||||
# 标准化:统一字段大写
|
# P5: Handle field tags with '/' (e.g. Title/Article) or special chars
|
||||||
normalized = re.sub(
|
normalized = re.sub(
|
||||||
r'\[([\w:]+)\]',
|
r'\[([\w/:]+)\]',
|
||||||
lambda m: f'[{m.group(1).upper()}]',
|
lambda m: f'[{m.group(1).upper()}]',
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ async def _expand_tree_numbers(db: AsyncSession, tag_ids: list[UUID]) -> list[UU
|
|||||||
|
|
||||||
expanded = set(tag_ids)
|
expanded = set(tag_ids)
|
||||||
# 一次查询所有 tree_number 的子节点(用 OR 合并)
|
# 一次查询所有 tree_number 的子节点(用 OR 合并)
|
||||||
tn_conds = [GlobalTagTreeNumber.tree_number.like(f"{tn}%") for tn in tree_map]
|
tn_conds = [_or_(
|
||||||
|
GlobalTagTreeNumber.tree_number == tn,
|
||||||
|
GlobalTagTreeNumber.tree_number.like(f"{tn}.%"),
|
||||||
|
) for tn in tree_map]
|
||||||
rows = await db.execute(
|
rows = await db.execute(
|
||||||
select(GlobalTagTreeNumber.tag_id)
|
select(GlobalTagTreeNumber.tag_id)
|
||||||
.where(_or_(*tn_conds))
|
.where(_or_(*tn_conds))
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ class AdvancedSearchEngine:
|
|||||||
or pp.ot_terms or pp.gene_terms or pp.pmc_terms
|
or pp.ot_terms or pp.gene_terms or pp.pmc_terms
|
||||||
or pp.edat_from or pp.crdt_from or pp.mhda_from
|
or pp.edat_from or pp.crdt_from or pp.mhda_from
|
||||||
or pp.lr_from or pp.dcom_from or pp.dep_from
|
or pp.lr_from or pp.dcom_from or pp.dep_from
|
||||||
|
or pp.edat_to or pp.crdt_to or pp.mhda_to
|
||||||
|
or pp.lr_to or pp.dcom_to or pp.dep_to
|
||||||
|
or pp.date_from or pp.date_to
|
||||||
|
or pp.negated_date_ranges
|
||||||
|
or pp.groups
|
||||||
or pp.plain_terms or pp.has_not
|
or pp.plain_terms or pp.has_not
|
||||||
or pp.year_from or pp.year_to
|
or pp.year_from or pp.year_to
|
||||||
)
|
)
|
||||||
@@ -205,6 +210,11 @@ class AdvancedSearchEngine:
|
|||||||
or _pubmed_parsed.ot_terms or _pubmed_parsed.gene_terms or _pubmed_parsed.pmc_terms
|
or _pubmed_parsed.ot_terms or _pubmed_parsed.gene_terms or _pubmed_parsed.pmc_terms
|
||||||
or _pubmed_parsed.edat_from or _pubmed_parsed.crdt_from or _pubmed_parsed.mhda_from
|
or _pubmed_parsed.edat_from or _pubmed_parsed.crdt_from or _pubmed_parsed.mhda_from
|
||||||
or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from or _pubmed_parsed.dep_from
|
or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from or _pubmed_parsed.dep_from
|
||||||
|
or _pubmed_parsed.edat_to or _pubmed_parsed.crdt_to or _pubmed_parsed.mhda_to
|
||||||
|
or _pubmed_parsed.lr_to or _pubmed_parsed.dcom_to or _pubmed_parsed.dep_to
|
||||||
|
or _pubmed_parsed.date_from or _pubmed_parsed.date_to
|
||||||
|
or _pubmed_parsed.negated_date_ranges
|
||||||
|
or _pubmed_parsed.groups
|
||||||
or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not
|
or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not
|
||||||
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
|
or _pubmed_parsed.year_from or _pubmed_parsed.year_to)
|
||||||
]):
|
]):
|
||||||
@@ -1130,7 +1140,7 @@ class AdvancedSearchEngine:
|
|||||||
elif field == "affiliation":
|
elif field == "affiliation":
|
||||||
return cast(GlobalLiterature.authors, String).ilike(_pt())
|
return cast(GlobalLiterature.authors, String).ilike(_pt())
|
||||||
elif field == "language":
|
elif field == "language":
|
||||||
return GlobalLiterature.language == term
|
return GlobalLiterature.language.ilike(_pt())
|
||||||
elif field == "volume":
|
elif field == "volume":
|
||||||
return GlobalLiterature.volume.ilike(_pt())
|
return GlobalLiterature.volume.ilike(_pt())
|
||||||
elif field == "issue":
|
elif field == "issue":
|
||||||
@@ -1160,7 +1170,7 @@ class AdvancedSearchEngine:
|
|||||||
if "/" in term:
|
if "/" in term:
|
||||||
if term.startswith("10."):
|
if term.startswith("10."):
|
||||||
return or_(
|
return or_(
|
||||||
GlobalLiterature.doi == term,
|
GlobalLiterature.doi.ilike(term),
|
||||||
GlobalLiterature.doi.ilike(like_val),
|
GlobalLiterature.doi.ilike(like_val),
|
||||||
)
|
)
|
||||||
return or_(
|
return or_(
|
||||||
@@ -1199,7 +1209,7 @@ class AdvancedSearchEngine:
|
|||||||
# 1a. 精确入口词匹配(P1-3)— batch via OR
|
# 1a. 精确入口词匹配(P1-3)— batch via OR
|
||||||
entry_conds.append(GlobalTag.entry_terms.contains([q]))
|
entry_conds.append(GlobalTag.entry_terms.contains([q]))
|
||||||
# 1b. name_en ILIKE 回退 — batch via OR
|
# 1b. name_en ILIKE 回退 — batch via OR
|
||||||
name_conds.append(GlobalTag.name_en.ilike(m))
|
name_conds.append(GlobalTag.name_en.ilike(_escape_ilike(m)))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if entry_conds:
|
if entry_conds:
|
||||||
@@ -1240,7 +1250,10 @@ class AdvancedSearchEngine:
|
|||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
if tns:
|
if tns:
|
||||||
child_conds = [GlobalTagTreeNumber.tree_number.like(f"{tn}%") for tn in tns]
|
child_conds = [or_(
|
||||||
|
GlobalTagTreeNumber.tree_number == tn,
|
||||||
|
GlobalTagTreeNumber.tree_number.like(f"{tn}.%"),
|
||||||
|
) for tn in tns]
|
||||||
children = (await db.execute(
|
children = (await db.execute(
|
||||||
select(GlobalTagTreeNumber.tag_id).where(or_(*child_conds))
|
select(GlobalTagTreeNumber.tag_id).where(or_(*child_conds))
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
@@ -1300,5 +1313,7 @@ class AdvancedSearchEngine:
|
|||||||
return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()]
|
return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()]
|
||||||
elif sort == "journal":
|
elif sort == "journal":
|
||||||
return [GlobalLiterature.journal.asc().nullslast()]
|
return [GlobalLiterature.journal.asc().nullslast()]
|
||||||
|
elif sort == "title":
|
||||||
|
return [GlobalLiterature.title.asc().nullslast()]
|
||||||
else:
|
else:
|
||||||
return [GlobalLiterature.pub_date.desc().nullslast(), GlobalLiterature.id.desc()]
|
return [GlobalLiterature.pub_date.desc().nullslast(), GlobalLiterature.id.desc()]
|
||||||
|
|||||||
+65
-10
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||||
>
|
>
|
||||||
> **累计**:4 轮,59 项修复,50+ 字段标签注册,127 项测试覆盖
|
> **累计**:5 轮,63 项修复,50+ 字段标签注册,214 项测试覆盖
|
||||||
> **时间跨度**:2026-07-24 ~ 2026-07-27
|
> **时间跨度**:2026-07-24 ~ 2026-07-27
|
||||||
> **核心文件**:`pubmed_query_parser.py`(~620 行)→ `search_engine.py`(~1250 行)
|
> **核心文件**:`pubmed_query_parser.py`(~620 行)→ `search_engine.py`(~1250 行)
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
2. [第二轮:第二轮审计修复(8 项)](#第二轮第二轮审计修复)
|
2. [第二轮:第二轮审计修复(8 项)](#第二轮第二轮审计修复)
|
||||||
3. [第三轮:第三轮审计修复(14 项)](#第三轮第三轮审计修复)
|
3. [第三轮:第三轮审计修复(14 项)](#第三轮第三轮审计修复)
|
||||||
4. [第四轮:字段补全与语义优化(11 项)](#第四轮字段补全与语义优化)
|
4. [第四轮:字段补全与语义优化(11 项)](#第四轮字段补全与语义优化)
|
||||||
5. [遗留限制](#遗留限制)
|
5. [第五轮:第 5 轮全面审计修复(4 项)](#第五轮第-5-轮全面审计修复)
|
||||||
|
6. [遗留限制](#遗留限制)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -324,15 +325,69 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 第五轮:第 5 轮全面审计修复
|
||||||
|
|
||||||
|
**提交**:`pending`
|
||||||
|
**日期**:2026-07-27
|
||||||
|
**数量**:4 项
|
||||||
|
**触发**:5 Agent 并行深度审计 + 第 2 轮规划核对
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
第 4 轮后启动第 5 轮全面审计,5 个 agent 并行扫描 parser、engine、前端、文档。首先与第 2 轮审计计划(F1-F8)逐项核对,确认以下 8 项已在之前轮次完成:
|
||||||
|
|
||||||
|
| 计划 ID | 项目 | 完成轮次 | 状态 |
|
||||||
|
|---------|------|---------|------|
|
||||||
|
| F1 | `[MH:noexp]` 顶层支持 | 第 3/4 轮 | 已修复 |
|
||||||
|
| F2 | 组内 NOT De Morgan 律 | 第 2 轮 | 已验证正确 |
|
||||||
|
| F3 | `_parse_not_expr` NOT NOT 递归 | 第 4 轮 | 已修复 |
|
||||||
|
| F4 | SearchView Custom Range 日期 | 第 3 轮 | 已工作正常 |
|
||||||
|
| F5 | HomeView restoreFromUrl | 第 3 轮 | 已工作正常 |
|
||||||
|
| F6 | precision_mode 死代码 | 此前轮次 | 已移除 |
|
||||||
|
| F7 | is_oa 死字段 | 此前轮次 | 已注释 `unused` |
|
||||||
|
| F8 | `_expand_mesh_tag_ids` N+1 | 第 3/4 轮 | 已批量优化 |
|
||||||
|
|
||||||
|
实际在第 5 轮修复的 4 项均为 parser 边缘案例:
|
||||||
|
|
||||||
|
### P5-1: 尾部 NOT 导致 IndexError 降级
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py` `_parse_not_expr` L498
|
||||||
|
- **问题**:`cancer NOT` — 解析器 `_is_primary_start` 包含 `NOT`,隐式 AND 逻辑尝试将 NOT 作为新词开始,但 NOT 之后无 token → `peek()` 越界抛 `IndexError` → 整个查询降级为纯文本拆分,`NOT` 作为字面搜索词(影响极小但产生异常)
|
||||||
|
- **根因**:`_parse_not_expr` 不检查是否已到 EOF
|
||||||
|
- **修复**:消费 NOT token 后立即检查 `peek().type == TokenType.EOF`,直接返回 `[]` 静默忽略
|
||||||
|
|
||||||
|
### P5-2: 单数字日期格式不被识别
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py` `parse_pubmed_query` L679
|
||||||
|
- **问题**:`2024-1-1[DP]` — tokeniser 的 DATE 模式只匹配 `\d{4}-\d{2}-\d{2}`,单数字月/日被解析为 `WORD('2024-1-1')` → `_dispatch_term` 设置 `date_from='2024-1-1'` → `date.fromisoformat()` 抛 `ValueError` → 日期条件被静默丢弃
|
||||||
|
- **根因**:tokeniser 前缺少单数字日期归一化
|
||||||
|
- **修复**:在 `parse_pubmed_query` 的 NFKC 归一化后增加 `YYYY-M-D → YYYY-MM-DD` 正则替换
|
||||||
|
|
||||||
|
### P5-3: `is_pubmed_syntax` 不处理全角字符
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py` `is_pubmed_syntax` L652
|
||||||
|
- **问题**:全角括号 `[TI]` 不被 `\[...\]` 识别 → 检出失败 → 走纯文本路径(解析器内 `parse_pubmed_query` 做 NFKC 但已不会进入)
|
||||||
|
- **根因**:`is_pubmed_syntax` 未做 NFKC 归一化、与 `parse_pubmed_query` 行为不一致
|
||||||
|
- **修复**:函数开头增加 `query = unicodedata.normalize('NFKC', query)`
|
||||||
|
|
||||||
|
### P5-4: `extract_pubmed_query_for_prisma` 不能处理 `[Title/Article]`
|
||||||
|
|
||||||
|
- **文件**:`pubmed_query_parser.py` `extract_pubmed_query_for_prisma` L706
|
||||||
|
- **问题**:归一化 regex `\[([\w:]+)\]` 不含 `/`,`[Title/Article]` 不被匹配、保持原文
|
||||||
|
- **根因**:regex 字符类不含 `/`
|
||||||
|
- **修复**:`[\w:]` → `[\w/:]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 各轮变更摘要
|
## 各轮变更摘要
|
||||||
|
|
||||||
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 |
|
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 |
|
||||||
|------|--------|--------|--------|--------|
|
|------|--------|--------|--------|--------|--------|
|
||||||
| 修复数 | 34 | 8 | 14 | 11 |
|
| 修复数 | 34 | 8 | 14 | 11 | 4 |
|
||||||
| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser |
|
| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser |
|
||||||
| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 |
|
| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 |
|
||||||
| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 |
|
| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 |
|
||||||
| 新功能 | `[MH]`/`[EDAT]`/`[AD]`/`[LA]` 等 | — | — | `[GEN]`/`[PMC]`/`[Title/Abstract]` |
|
| 新功能 | `[MH]`/`[EDAT]`/`[AD]`/`[LA]` 等 | — | — | `[GEN]`/`[PMC]`/`[Title/Abstract]` | — |
|
||||||
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 |
|
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -360,4 +415,4 @@
|
|||||||
| `test_pubmed_query_parser.py` | ~40 | tokeniser、解析器、语法正确性 |
|
| `test_pubmed_query_parser.py` | ~40 | tokeniser、解析器、语法正确性 |
|
||||||
| `test_pubmed_search_integration.py` | ~60 | 字段映射、API 集成、前端格式 |
|
| `test_pubmed_search_integration.py` | ~60 | 字段映射、API 集成、前端格式 |
|
||||||
| `test_comprehensive_verify.py` | ~27 | 字段完整、NOT 语义、括号、日期 |
|
| `test_comprehensive_verify.py` | ~27 | 字段完整、NOT 语义、括号、日期 |
|
||||||
| **合计** | **127** | 全部通过 |
|
| **合计** | **214** | 全部通过 |
|
||||||
|
|||||||
Reference in New Issue
Block a user