forked from admin/PlantAI-rag-new
feat(aqua): figure chain dual-key generalization (PMCID/DOI)
- answer_composer: prefer [[FIG:PMCID]], fallback [[FIG:DOI:fullDOI]] - sse_relay: marker regex accepts DOI form; DOI key passed through to figures - figures: local index dual-key (pmcid + doi via registry paper_id/doi); resolve_figures recognizes DOI form -> local first, EPMC search (DOI->PMCID) fallback; silent degrade semantics unchanged - normalize/humanizer: block protection covers [[FIG:DOI:...]] form - plant KB unaffected: PMCID path byte-for-byte behavior kept; retrieval chain untouched; 52 sdk tests pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,7 +209,7 @@ If the provided data are insufficient, say so directly.
|
||||
|
||||
--- Figure Marker ---
|
||||
|
||||
When citing a paper for the first time, you may insert a marker [[FIG:PMCID]] on its own line immediately after the paragraph containing the citation, to indicate where a figure from that paper should be displayed. Use at most one marker per paper, at most 6 markers total. Do not write image URLs yourself; the system will resolve markers into figures.
|
||||
When citing a paper for the first time, you may insert a marker on its own line immediately after the paragraph containing the citation, to indicate where a figure from that paper should be displayed. Use at most one marker per paper, at most 6 markers total. Prefer [[FIG:PMCID]]; when the paper has no PMCID, use [[FIG:DOI:完整DOI]] (the full DOI string, e.g. [[FIG:DOI:10.1111/j.1365-2109.2010.02587.x]]). Do not write image URLs yourself; the system will resolve markers into figures.
|
||||
|
||||
{correction_block}{dialogue_block}{web_block}Now answer the following question:
|
||||
|
||||
|
||||
+91
-20
@@ -46,8 +46,8 @@ PMC_IMAGE_BASE = "https://pmc.ncbi.nlm.nih.gov/articles/instance/{num}/bin/{file
|
||||
# BFF 代理端点路径(前端经此访问图片,内网可达 + 磁盘缓存)
|
||||
PROXY_PATH = "/api/figures/proxy?pmcid={pmcid}&url={url}"
|
||||
|
||||
_cache: dict[str, dict[str, Any]] = {} # pmcid -> {ts, figures, license}
|
||||
_local_index: dict[str, list[dict]] | None = None # pmcid数字 -> 本地图片清单
|
||||
_cache: dict[str, dict[str, Any]] = {} # 键(pmcid 或 doi 原串) -> {ts, figures, license}
|
||||
_local_index: dict[str, list[dict]] | None = None # 双键: pmcid数字 与 doi 原串 -> 本地图片清单
|
||||
EXTERNAL_TIMEOUT = 15.0 # 176 实测 EBI 2-8s 波动,5s 必超时(2026-09-12 S2 部署质检)
|
||||
CACHE_TTL = 3600.0
|
||||
|
||||
@@ -58,20 +58,39 @@ def _pmcid_num(pmcid: str) -> str:
|
||||
return m.group(1) if m else pmcid.strip()
|
||||
|
||||
|
||||
def _norm_doi_key(doi: str) -> str:
|
||||
"""DOI 形态键归一:去 'doi:' 前缀与首尾空白,保留原样大小写(DOI 大小写不敏感,存两键)。"""
|
||||
value = (doi or "").strip()
|
||||
if value.lower().startswith("doi:"):
|
||||
value = value[len("doi:"):]
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _doi_key(pmcid_param: str) -> str | None:
|
||||
"""识别 DOI 形态入参('DOI:...' 或裸 '10.xxxx/...'),返回归一化 doi;否则 None。"""
|
||||
value = _norm_doi_key(pmcid_param)
|
||||
if not value:
|
||||
return None
|
||||
if re.match(r"^10\.\d{4,9}/", value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地索引(懒构建)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_local_index() -> dict[str, list[dict]]:
|
||||
"""扫描 methodology_upload_jobs 的 completed 任务,pmcid数字 -> 图片清单。
|
||||
无 registry 时跳过映射(返回空索引),不影响 PMC 兜底。"""
|
||||
"""扫描 methodology_upload_jobs 的 completed 任务,双键(pmcid数字 + doi 原串)
|
||||
-> 图片清单。无 registry 时跳过映射(返回空索引),不影响 PMC 兜底。"""
|
||||
global _local_index
|
||||
if _local_index is not None:
|
||||
return _local_index
|
||||
idx: dict[str, list[dict]] = {}
|
||||
try:
|
||||
# registry: pmcid -> paper_id(DOI风格);job 目录名含 paper_id 前缀
|
||||
# registry: 双向映射 —— paper_id(DOI风格) <-> pmcid / doi
|
||||
pmcid_to_paper = {}
|
||||
doi_to_paper = {}
|
||||
if REGISTRY_PATH.exists():
|
||||
with REGISTRY_PATH.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
@@ -82,8 +101,11 @@ def _build_local_index() -> dict[str, list[dict]]:
|
||||
rec = _json.loads(line)
|
||||
pmcid = rec.get("pmcid") or ""
|
||||
paper = rec.get("paper_id") or ""
|
||||
doi = _norm_doi_key(rec.get("doi") or "")
|
||||
if pmcid and paper:
|
||||
pmcid_to_paper[pmcid] = paper
|
||||
if doi and paper:
|
||||
doi_to_paper[doi] = paper
|
||||
if not MINERU_ROOT.is_dir():
|
||||
_local_index = idx
|
||||
return idx
|
||||
@@ -127,7 +149,13 @@ def _build_local_index() -> dict[str, list[dict]]:
|
||||
})
|
||||
if figures:
|
||||
figures.sort(key=lambda x: x["page_idx"])
|
||||
# 双键索引:pmcid 数字键 + doi 键(同一清单对象)
|
||||
idx[_pmcid_num(pmcid)] = figures
|
||||
paper_ref = pmcid_to_paper.get(pmcid)
|
||||
if paper_ref:
|
||||
for doi, paper in doi_to_paper.items():
|
||||
if paper == paper_ref or paper_ref.startswith(paper.split("_")[0]):
|
||||
idx[doi] = figures
|
||||
except Exception as exc: # 索引构建失败不阻断服务
|
||||
logger.warning("本地图片索引构建失败(降级为纯 PMC): %s", exc)
|
||||
_local_index = idx
|
||||
@@ -243,19 +271,25 @@ async def _epmc_figures(client: httpx.AsyncClient, pmcid: str) -> tuple[list[dic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve_figures(pmcid: str, limit: int = 4) -> dict[str, Any]:
|
||||
"""按 PMCID 返回 {pmcid, license, figures:[{url,caption,source}]}。
|
||||
本地优先,PMC 兜底;缓存 TTL 1 小时。"""
|
||||
"""按 PMCID 或 DOI 形态入参返回 {pmcid, license, figures:[{url,caption,source}]}。
|
||||
|
||||
- PMCID 形态(PMC123):原路径不变 —— 本地双键索引 + PMC fullTextXML 兜底。
|
||||
- DOI 形态(DOI:10.xxx/... 或裸 10.xxx/...):pmcid 参数填 DOI 原串传入,
|
||||
先查本地双键索引;未命中再经 EPMC search(DOI→PMCID)走原 PMC 兜底路径。
|
||||
本地优先,PMC 兜底;缓存 TTL 1 小时。失败静默降级为空清单。"""
|
||||
doi = _doi_key(pmcid)
|
||||
cache_key = doi if doi else pmcid
|
||||
now = time.monotonic()
|
||||
cached = _cache.get(pmcid)
|
||||
cached = _cache.get(cache_key)
|
||||
if cached and now - cached["ts"] < CACHE_TTL:
|
||||
return cached["data"]
|
||||
|
||||
figures: list[dict] = []
|
||||
license_ = ""
|
||||
|
||||
# 1. 本地优先
|
||||
# 1. 本地优先(双键索引:DOI 形态查 doi 键,PMCID 形态查 pmcid 数字键)
|
||||
idx = _build_local_index()
|
||||
local = idx.get(_pmcid_num(pmcid)) or []
|
||||
local = idx.get(doi or _pmcid_num(pmcid)) or []
|
||||
for f in local[:limit]:
|
||||
figures.append({
|
||||
"url": PROXY_PATH.format(pmcid=pmcid, url=quote(f["url"], safe="")),
|
||||
@@ -263,22 +297,59 @@ async def resolve_figures(pmcid: str, limit: int = 4) -> dict[str, Any]:
|
||||
"source": "local",
|
||||
})
|
||||
|
||||
# 2. PMC 兜底(本地不足 limit 时补)
|
||||
# 2. PMC 兜底(本地不足 limit 时补;DOI 形态先经 EPMC search 换 PMCID)
|
||||
if len(figures) < limit:
|
||||
async with httpx.AsyncClient() as client:
|
||||
pmc_figs, license_ = await _epmc_figures(client, pmcid)
|
||||
for f in pmc_figs[: limit - len(figures)]:
|
||||
figures.append({
|
||||
"url": PROXY_PATH.format(pmcid=pmcid, url=quote(f["url"], safe="")),
|
||||
"caption": f["caption"],
|
||||
"source": "pmc",
|
||||
})
|
||||
fallback_id = pmcid
|
||||
if doi:
|
||||
fallback_id = await _doi_to_pmcid(doi) or ""
|
||||
if fallback_id:
|
||||
async with httpx.AsyncClient() as client:
|
||||
pmc_figs, license_ = await _epmc_figures(client, fallback_id)
|
||||
for f in pmc_figs[: limit - len(figures)]:
|
||||
figures.append({
|
||||
"url": PROXY_PATH.format(pmcid=pmcid, url=quote(f["url"], safe="")),
|
||||
"caption": f["caption"],
|
||||
"source": "pmc",
|
||||
})
|
||||
|
||||
data = {"pmcid": pmcid, "license": license_, "figures": figures}
|
||||
_cache[pmcid] = {"ts": now, "data": data}
|
||||
_cache[cache_key] = {"ts": now, "data": data}
|
||||
return data
|
||||
|
||||
|
||||
async def _doi_to_pmcid(doi: str) -> str:
|
||||
"""EPMC search 用 DOI 查 PMCID(DOI 形态兜底)。失败返回空串(静默降级)。
|
||||
|
||||
与 _epmc_figures 一致的冷连接重试:EBI 冷 TLS 握手偶发超时,重试一次。"""
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
EPMC_SEARCH,
|
||||
params={
|
||||
"query": f"DOI:\"{doi}\"",
|
||||
"format": "json",
|
||||
"pageSize": 1,
|
||||
"resultType": "LITE",
|
||||
},
|
||||
timeout=EXTERNAL_TIMEOUT,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
res = r.json().get("resultList", {}).get("result", [])
|
||||
if not res:
|
||||
return ""
|
||||
pmcid = res[0].get("pmcid") or ""
|
||||
if pmcid:
|
||||
return pmcid.upper()
|
||||
return ""
|
||||
except Exception as exc:
|
||||
if attempt == 2:
|
||||
logger.info("EPMC DOI→PMCID 查询失败(%s): %s", doi, exc)
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
async def proxy_figure(pmcid: str, raw_url: str) -> tuple[bytes, str] | None:
|
||||
"""图片代理:本地文件直接读;外网图拉取并磁盘缓存。
|
||||
返回 (content_bytes, content_type);失败返回 None。"""
|
||||
|
||||
@@ -52,6 +52,7 @@ RULES: List[Tuple[str, str]] = [
|
||||
# 块级保护:整块跳过(不应用任何规则)
|
||||
_CODE_FENCE = re.compile(r"```[\s\S]*?```")
|
||||
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
|
||||
# 双键形态:[[FIG:PMC123]] 与 [[FIG:DOI:10.xxx/...]] 均为整块保护对象(2026-09-12)
|
||||
_FIG_MARKER = re.compile(r"\[\[FIG:[^\]]+\]\]")
|
||||
_IMG_MARKDOWN = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
||||
_HEADING = re.compile(r"^#{1,6}\s+.*$", re.MULTILINE)
|
||||
|
||||
@@ -300,7 +300,8 @@ def normalize_qa_response(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
# 图片注入(2026-08-28 图文并茂方案)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FIG_MARKER = re.compile(r"\[\[FIG:([A-Za-z0-9]+)\]\]")
|
||||
# 双键形态:[[FIG:PMC123]](原形态)与 [[FIG:DOI:10.xxx/...]](2026-09-12 通用化)
|
||||
_FIG_MARKER = re.compile(r"\[\[FIG:([A-Za-z0-9]+|DOI:[^\]]+)\]\]")
|
||||
_MAX_FIGURES = 8
|
||||
|
||||
|
||||
@@ -309,13 +310,13 @@ def inject_figure_markdown(
|
||||
figures_map: dict[str, dict[str, Any]],
|
||||
base_url: str = "",
|
||||
) -> str:
|
||||
"""把 LLM 的 [[FIG:PMCID]] 标记替换为 markdown 图注块(纯同步函数)。
|
||||
"""把 LLM 的 [[FIG:PMCID]] / [[FIG:DOI:...]] 标记替换为 markdown 图注块(纯同步函数)。
|
||||
|
||||
参数 figures_map: pmcid -> {"license": str, "figures": [{url, caption, source}]}
|
||||
参数 figures_map: 键为 PMCID(大写)或 DOI 原串 -> {"license": str, "figures": [...]}
|
||||
由调用方(sse_relay)预先异步解析后传入,本函数不做 IO,便于单测。
|
||||
|
||||
规则:
|
||||
- 标记 → 该 PMCID 前 2 张图;无图删标记
|
||||
- 标记 → 该键对应前 2 张图;无图删标记
|
||||
- 兜底:无任何标记时,按引用 [PMID: xxx] 首次出现位置插入对应论文前 2 张图
|
||||
(pmcid 从 sources 的 paper_id/pmcid 推导)
|
||||
- 总图数上限 8,超出删除标记/跳过
|
||||
@@ -332,7 +333,7 @@ def inject_figure_markdown(
|
||||
for pmcid in markers:
|
||||
if inserted >= _MAX_FIGURES:
|
||||
break
|
||||
key = pmcid.upper()
|
||||
key = pmcid if pmcid.upper().startswith("DOI:") else pmcid.upper()
|
||||
data = figures_map.get(key) or figures_map.get(pmcid)
|
||||
figures = (data or {}).get("figures") or []
|
||||
take = min(2, len(figures), _MAX_FIGURES - inserted)
|
||||
|
||||
@@ -544,13 +544,15 @@ async def relay_stream(
|
||||
async def _inject_figures_async(normalized: dict) -> dict:
|
||||
"""图文并茂(2026-08-28):异步预取答案引用的论文图片,注入 markdown 图注块。
|
||||
|
||||
流程:从 normalized 的 sources 提取 PMCID 清单 → 逐个 resolve_figures(本地优先
|
||||
PMC 兜底,缓存 1h)→ 组装 figures_map(含 pmid 供兜底路径)→ 调
|
||||
normalize.inject_figure_markdown 纯同步注入。无图/失败静默降级。
|
||||
流程:从 normalized 的 sources 提取 PMCID 清单 + 答案 [[FIG:PMCxxxx]] /
|
||||
[[FIG:DOI:10.xxx/...]] 标记(DOI 形态整体传给 resolve_figures,PMCID 原路径不变)
|
||||
→ 逐个 resolve_figures(本地优先 PMC 兜底,缓存 1h)→ 组装 figures_map
|
||||
(含 pmid 供兜底路径)→ 调 normalize.inject_figure_markdown 纯同步注入。
|
||||
无图/失败静默降级。
|
||||
|
||||
预算控制(缺陷 #7,2026-09-10):整体预取受 FIGURE_INJECT_BUDGET_MS(默认
|
||||
2500ms)硬截断,保证 complete 帧端到端延迟 ≤ 预算;PMCID 只取前 3 个;
|
||||
预算按 PMCID 均分,单篇 wait_for 截断。超时/失败仍静默降级——图片不影响
|
||||
2500ms)硬截断,保证 complete 帧端到端延迟 ≤ 预算;标记只取前 3 个;
|
||||
预算按标记均分,单篇 wait_for 截断。超时/失败仍静默降级——图片不影响
|
||||
答案主链,降级语义与修复前一致。
|
||||
"""
|
||||
from .figures import resolve_figures
|
||||
@@ -561,10 +563,12 @@ async def _inject_figures_async(normalized: dict) -> dict:
|
||||
if not answer or not sources:
|
||||
return normalized
|
||||
|
||||
# 收集答案里引用的 PMCID([[FIG:]] 标记 + sources 的 pmcid)
|
||||
# 收集答案里引用的图片标记:[[FIG:PMCxxxx]] 或 [[FIG:DOI:10.xxx/...]]
|
||||
# ([[FIG:DOI:...]] 形态把 DOI 原串直接传给 figures.resolve_figures,
|
||||
# 由其内部识别并走双键本地索引 + EPMC 兜底;/api/figures/{pmcid} 路由兼容不变。)
|
||||
import re
|
||||
|
||||
marker_pmcids = re.findall(r"\[\[FIG:([A-Za-z0-9]+)\]\]", answer)
|
||||
marker_ids = re.findall(r"\[\[FIG:([A-Za-z0-9.:/_-]+)\]\]", answer)
|
||||
figures_map: dict = {}
|
||||
# 来源里 pmcid 优先(有 pmid 供兜底)
|
||||
for s in sources:
|
||||
@@ -573,11 +577,16 @@ async def _inject_figures_async(normalized: dict) -> dict:
|
||||
if not pmcid:
|
||||
continue
|
||||
figures_map.setdefault(pmcid, {"pmid": pmid, "figures": [], "license": ""})
|
||||
# 标记里的 PMCID 也加入(即使 sources 没有)
|
||||
for pmcid in marker_pmcids:
|
||||
figures_map.setdefault(pmcid.upper(), {"pmid": None, "figures": [], "license": ""})
|
||||
# 标记里的 PMCID / DOI 也加入(即使 sources 没有)
|
||||
for marker in marker_ids:
|
||||
upper = marker.upper()
|
||||
if upper.startswith("PMC"):
|
||||
figures_map.setdefault(upper, {"pmid": None, "figures": [], "license": ""})
|
||||
elif upper.startswith("DOI:"):
|
||||
doi_key = marker
|
||||
figures_map.setdefault(doi_key, {"pmid": None, "figures": [], "license": ""})
|
||||
|
||||
# PMCID 裁 top 3(按出现顺序:sources 在前,标记补充在后)
|
||||
# 标记裁 top 3(按出现顺序:sources 在前,标记补充在后)
|
||||
pmcids = list(figures_map.keys())[:FIGURE_INJECT_MAX_PMCIDS]
|
||||
if not pmcids:
|
||||
base_url = "" # figures.resolve 已返回代理端点绝对路径,无需拼接
|
||||
|
||||
Reference in New Issue
Block a user