- 生信进度:后端新增 stage_progress 事件帧+进度解析白名单(rnaseq_qc/sample_qc), kernel 日志流式落盘(20行/1s flush+fsync),logs 分页端点(offset/limit/eof), 事件 seq 跨重启扫描续增(2026-09-17 断流修复);前端 FlowProgressCard 确定性/ 不确定两态进度条+点击定位到生信任务 TAB,BioJobsPane DAG 行展开滚动日志区 (3s 轮询),log_tail 4000 截断,重跑 stage_started 清零进度 - 图片注入:figures 三源重构(标准DOI图库主路径/遗留体系兜底/PMC兜底), [[FIG:]] 宽松归一化识别 PMC/PMID/DOI 三种形态,引文兜底扩展至 [PMCID:]/[DOI:] 且标记未满配额继续注入,注入预算 90s/10篇(175 实测校准), PMC graphic 无 content-type 宽松正则,空结果不缓存(176 冷连接三连败修复) - QA 启动自愈:start.sh 启动前等 Qdrant readyz(30min)+Neo4j TCP(10min); backend_guard 标签校验改子集判定 - 样本配对:同 inode 去重优先真实文件,R1/R2 归一配对一对一认领 - gitignore 新增 *.bak-*/*.before-*/bioflow_service/bioflow.db 测试:bioflow_service pytest 358/358,前端 bioflow vitest 205/205
569 lines
23 KiB
Python
569 lines
23 KiB
Python
"""services/figures.py —— 论文图片解析(2026-08-28 图文并茂方案,BFF 侧)。
|
||
|
||
三源策略(按优先级降级):
|
||
1. 标准本地图库(默认主路径,2026-09-20 定为共通逻辑):所有库的论文图片均按
|
||
DOI 组织——<FIGURES_ROOT>/images/<DOI 下划线形态>(软链/目录)指向
|
||
<FIGURES_ROOT>/results/<DOI 下划线形态>/,content_list.json(或 _v2)里
|
||
type=="image" 条目给出 img_path(相对 results 目录)+ image_caption + page_idx。
|
||
新库无需改代码,只需数据落盘符合此布局并配置 PLANTAI_FIGURES_ROOT。
|
||
懒加载 per-DOI:入口清单一次枚举,单个 DOI 首次命中才解析并缓存。
|
||
2. 遗留本地图库(methodology_upload_jobs 体系,次级兜底):job 目录 + csv
|
||
反查 pmcid 的老布局,保留兼容,命中率低(csv 占位符导致历史 0 键)。
|
||
3. PMC 外网兜底:Europe PMC fullTextXML 拿 figure 文件名 + caption;
|
||
license 经 Europe PMC REST search;图片直链 pmc.ncbi.nlm.nih.gov。
|
||
(实测:PMC5488377 全链路 200;服务器可出外网。)
|
||
|
||
所有异常静默降级为空清单——图片失败不影响答案主链。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json as _json
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 标准本地图库根(默认主路径;env 可覆盖。旧键 PLANTAI_FIGURES_YZ_ROOT 保留兼容)
|
||
FIGURES_ROOT = Path(
|
||
os.environ.get(
|
||
"PLANTAI_FIGURES_ROOT",
|
||
os.environ.get("PLANTAI_FIGURES_YZ_ROOT", "/home/xzq/yz"),
|
||
)
|
||
)
|
||
# 遗留本地图库(methodology_upload_jobs 体系,次级兜底)/ 元数据注册表
|
||
# (缺陷 #37/#38,2026-09-10 起 env 化:PLANTAI_FIGURES_MINERU_ROOT / PLANTAI_FIGURES_REGISTRY)
|
||
MINERU_ROOT = Path(
|
||
os.environ.get(
|
||
"PLANTAI_FIGURES_MINERU_ROOT",
|
||
"/home/xzq/5090/teamWorkspace/zh/PlantAI-rag/data/evaluation/methodology_upload_jobs",
|
||
)
|
||
)
|
||
REGISTRY_PATH = Path(
|
||
os.environ.get(
|
||
"PLANTAI_FIGURES_REGISTRY",
|
||
"/home/xzq/5090/teamWorkspace/zh/PlantAI-rag/data/knowledge_base/"
|
||
"paper_metadata_registry_v1_20260717/paper_metadata_registry_full.jsonl",
|
||
)
|
||
)
|
||
# PMC 外网端点
|
||
EPMC_FULLTEXT = "https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextXML"
|
||
EPMC_SEARCH = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
|
||
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 或 doi 原串) -> {ts, figures, license}
|
||
_local_entries: dict[str, Path] | None = None # DOI 下划线形态键 -> results 目录
|
||
_local_figures_cache: dict[str, list[dict]] = {}
|
||
_registry_pmcid_doi: dict[str, str] | None = None
|
||
_legacy_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
|
||
|
||
|
||
|
||
def _pmcid_num(pmcid: str) -> str:
|
||
"""'PMC5488377' -> '5488377'(容错各种大小写)"""
|
||
m = re.match(r"^PMC(\d+)$", pmcid.strip().upper())
|
||
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 _doi_variants(key: str) -> list[str]:
|
||
"""DOI 键两种形态(斜杠 / 与下划线 _),去重保序。"""
|
||
variants = [key, key.replace("/", "_"), key.replace("_", "/")]
|
||
return list(dict.fromkeys(variants))
|
||
|
||
|
||
def _pmcid_to_doi_map() -> dict[str, str]:
|
||
"""registry 的 PMCID -> DOI(斜杠形态)映射,懒构建;失败返回空(降级 PMC 兜底)。"""
|
||
global _registry_pmcid_doi
|
||
if _registry_pmcid_doi is not None:
|
||
return _registry_pmcid_doi
|
||
m: dict[str, str] = {}
|
||
try:
|
||
if REGISTRY_PATH.exists():
|
||
with REGISTRY_PATH.open(encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
rec = _json.loads(line)
|
||
except Exception:
|
||
continue
|
||
pmcid = (rec.get("pmcid") or "").strip().upper()
|
||
doi = _norm_doi_key(rec.get("doi") or "")
|
||
if pmcid and doi:
|
||
m[pmcid] = doi
|
||
except Exception:
|
||
pass
|
||
_registry_pmcid_doi = m
|
||
return m
|
||
|
||
|
||
def _scan_local_entries() -> dict[str, Path]:
|
||
"""扫描标准图库 images 入口(仅列名,不跟软链不读文件),DOI 下划线形态 -> results 目录。
|
||
|
||
新库按此布局落盘即自动生效,无需改代码:
|
||
<FIGURES_ROOT>/images/<DOI 下划线形态> -> <FIGURES_ROOT>/results/<DOI 下划线形态>/
|
||
"""
|
||
global _local_entries
|
||
if _local_entries is not None:
|
||
return _local_entries
|
||
entries: dict[str, Path] = {}
|
||
try:
|
||
img_root = FIGURES_ROOT / "images"
|
||
if img_root.is_dir():
|
||
for entry in img_root.iterdir():
|
||
results_dir = FIGURES_ROOT / "results" / entry.name
|
||
if results_dir.is_dir():
|
||
entries[entry.name] = results_dir
|
||
except Exception:
|
||
pass
|
||
_local_entries = entries
|
||
return entries
|
||
|
||
|
||
def _local_figures(doi: str) -> list[dict]:
|
||
"""按 DOI 从标准图库解析图片清单(缓存;content_list 缺图时返回空)。"""
|
||
if doi in _local_figures_cache:
|
||
return _local_figures_cache[doi]
|
||
figures: list[dict] = []
|
||
try:
|
||
entries = _scan_local_entries()
|
||
for variant in _doi_variants(doi):
|
||
results_dir = entries.get(variant)
|
||
if not results_dir:
|
||
continue
|
||
candidates = [
|
||
results_dir / f"{variant}_content_list.json",
|
||
results_dir / f"{variant}_content_list_v2.json",
|
||
]
|
||
for cl in candidates:
|
||
if not cl.exists():
|
||
continue
|
||
try:
|
||
data = _json.loads(cl.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, list):
|
||
continue
|
||
for item in data:
|
||
if not isinstance(item, dict) or item.get("type") != "image":
|
||
continue
|
||
img_path = item.get("img_path") or ""
|
||
if not img_path:
|
||
continue
|
||
abs_path = results_dir / img_path
|
||
if not abs_path.exists():
|
||
continue
|
||
caption = ""
|
||
caps = [str(c).strip() for c in (item.get("image_caption") or [])
|
||
if isinstance(c, str) and str(c).strip()]
|
||
if not caps:
|
||
caps = [str(c).strip() for c in (item.get("image_footnote") or [])
|
||
if isinstance(c, str) and str(c).strip()]
|
||
if caps:
|
||
caption = caps[0][:300]
|
||
figures.append({
|
||
"url": str(abs_path),
|
||
"caption": caption,
|
||
"page_idx": item.get("page_idx", 0),
|
||
"source": "local",
|
||
"file": Path(img_path).name,
|
||
})
|
||
if figures:
|
||
figures.sort(key=lambda x: x["page_idx"])
|
||
break # content_list 命中即止(v2 仅兜底)
|
||
if figures:
|
||
break
|
||
except Exception as exc:
|
||
logger.warning("标准图库解析失败(%s): %s", doi, exc)
|
||
_local_figures_cache[doi] = figures
|
||
return figures
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 遗留本地索引(methodology_upload_jobs 体系,次级兜底,懒构建)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_legacy_index() -> dict[str, list[dict]]:
|
||
"""扫描 methodology_upload_jobs 的 completed 任务,双键(pmcid数字 + doi 原串)
|
||
-> 图片清单。无 registry 时跳过映射(返回空索引),不影响 PMC 兜底。"""
|
||
global _legacy_index
|
||
if _legacy_index is not None:
|
||
return _legacy_index
|
||
idx: dict[str, list[dict]] = {}
|
||
try:
|
||
# 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:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
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():
|
||
_legacy_index = idx
|
||
return idx
|
||
for job_dir in MINERU_ROOT.iterdir():
|
||
if not job_dir.is_dir():
|
||
continue
|
||
# 找 content_list.json(mineru/<paper>/txt/ 下)
|
||
content_files = list(job_dir.glob("mineru/*/txt/*_content_list.json"))
|
||
if not content_files:
|
||
continue
|
||
# 定位该 job 的 pmcid:从 methodology_papers.csv 或 status.json
|
||
pmcid = _job_pmcid(job_dir, pmcid_to_paper)
|
||
if not pmcid:
|
||
continue
|
||
figures: list[dict] = []
|
||
for cf in content_files:
|
||
try:
|
||
data = _json.loads(cf.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, list):
|
||
continue
|
||
for item in data:
|
||
if not isinstance(item, dict) or item.get("type") != "image":
|
||
continue
|
||
img_path = item.get("img_path") or ""
|
||
caption = ""
|
||
caps = item.get("image_caption") or []
|
||
if caps and isinstance(caps[0], dict):
|
||
caption = caps[0].get("content") or ""
|
||
if not img_path:
|
||
continue
|
||
abs_path = cf.parent / img_path
|
||
figures.append({
|
||
"url": str(abs_path),
|
||
"caption": caption[:300],
|
||
"page_idx": item.get("page_idx", 0),
|
||
"source": "local",
|
||
"file": Path(img_path).name,
|
||
})
|
||
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)
|
||
_legacy_index = idx
|
||
return idx
|
||
|
||
|
||
def _job_pmcid(job_dir: Path, pmcid_to_paper: dict[str, str]) -> str | None:
|
||
"""从 job 目录反查 pmcid:methodology_papers.csv 的 paper_id 匹配 registry。"""
|
||
try:
|
||
csv = job_dir / "methodology_papers.csv"
|
||
if csv.exists():
|
||
for line in csv.read_text(encoding="utf-8").splitlines()[1:]:
|
||
parts = line.split(",")
|
||
if len(parts) >= 2:
|
||
paper_id = parts[0].strip()
|
||
for pmcid, p in pmcid_to_paper.items():
|
||
if p == paper_id or paper_id.startswith(p.split("_")[0]):
|
||
return pmcid
|
||
# 目录名兜底:job 目录名 = {paper_id}_{job_id}
|
||
name_prefix = job_dir.name.split("_")[0]
|
||
for pmcid, p in pmcid_to_paper.items():
|
||
if p.startswith(name_prefix):
|
||
return pmcid
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PMC 外网解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def _epmc_figures_once(client: httpx.AsyncClient, pmcid: str) -> tuple[list[dict], str]:
|
||
"""返回 (figures, license)。任何失败返回 ([], "")。"""
|
||
try:
|
||
r = await client.get(EPMC_FULLTEXT.format(pmcid=pmcid), timeout=EXTERNAL_TIMEOUT)
|
||
if r.status_code != 200:
|
||
return [], ""
|
||
xml = r.text
|
||
# 只抓 image 类型的 graphic(content-type="image"),过滤许可链接/PDF 等
|
||
# 真实结构:<graphic content-type="image" xlink:href="file.jpg"/>
|
||
files = re.findall(
|
||
r'<graphic[^>]*content-type="image"[^>]*xlink:href="([^"]+\.(?:jpg|jpeg|png|gif))"',
|
||
xml,
|
||
)
|
||
# 宽松兜底(BUG-FIX 2026-09-18):部分文章(如 PMC11557432)的 graphic
|
||
# 标签无 content-type 属性,仅 position/orientation + xlink:href,被上面
|
||
# 窄正则漏判为 0 图。无命中时用宽松正则补抓。
|
||
if not files:
|
||
files = re.findall(
|
||
r'<graphic[^>]*xlink:href="([^"]+\.(?:jpg|jpeg|png|gif))"', xml
|
||
)
|
||
# caption 提取:<label>Fig. N</label> 后的 <caption><p>文本</p></caption>
|
||
# 简化策略:全部 <caption><p> 文本按顺序与 files 配对
|
||
caption_texts = re.findall(r"<caption>\s*<p>([^<]{10,400})</p>", xml, re.S)
|
||
# label 匹配:Fig 序号 → caption 文本的弱关联
|
||
labels = re.findall(r"<label>\s*(Fig[^<]*?)\s*</label>", xml)
|
||
label_caption_map: dict[str, str] = {}
|
||
for i, lab in enumerate(labels):
|
||
if i < len(caption_texts):
|
||
label_caption_map[lab.strip()] = caption_texts[i].strip()[:300]
|
||
fig_list = []
|
||
num = _pmcid_num(pmcid)
|
||
seen = set()
|
||
for f in files:
|
||
if f in seen:
|
||
continue
|
||
seen.add(f)
|
||
# 文件名中的 Fig 序号匹配 caption
|
||
m = re.search(r"[Ff]ig\s*(\d+)", f)
|
||
caption = ""
|
||
if m:
|
||
for lab, cap in label_caption_map.items():
|
||
if m.group(1) in lab:
|
||
caption = cap
|
||
break
|
||
# 兜底:按顺序配对
|
||
if not caption and len(fig_list) < len(caption_texts):
|
||
caption = caption_texts[len(fig_list)].strip()[:300]
|
||
fig_list.append({
|
||
"url": PMC_IMAGE_BASE.format(num=num, file=f),
|
||
"caption": caption,
|
||
"page_idx": len(fig_list),
|
||
"source": "pmc",
|
||
"file": f,
|
||
})
|
||
# license 查询(resultType=CORE 才返回 license/isOpenAccess 字段)
|
||
license_ = ""
|
||
try:
|
||
lr = await client.get(
|
||
EPMC_SEARCH,
|
||
params={"query": f"PMCID:{pmcid}", "format": "json", "pageSize": 1, "resultType": "CORE"},
|
||
timeout=EXTERNAL_TIMEOUT,
|
||
)
|
||
if lr.status_code == 200:
|
||
res = lr.json().get("resultList", {}).get("result", [])
|
||
if res:
|
||
license_ = res[0].get("license", "") or ""
|
||
except Exception:
|
||
pass
|
||
return fig_list, license_
|
||
except Exception as exc:
|
||
logger.info("PMC 解析失败(%s): %s", pmcid, exc)
|
||
return [], ""
|
||
|
||
|
||
async def _epmc_figures(client: httpx.AsyncClient, pmcid: str) -> tuple[list[dict], str]:
|
||
"""PMC 解析重试包装:176 出网到 EBI 间歇性 ConnectError(TLS 握手失败,
|
||
异常消息常为空,且同一连接池连续失败——2026-09-18 PMC6689393 三连败复现)。
|
||
|
||
修复:每次尝试用全新 client(避免池污染)+ 递增退避(0.5s/1s/2s),
|
||
最多 4 次;空结果不缓存,下次请求自然重试。
|
||
"""
|
||
for attempt, backoff in ((1, 0.0), (2, 0.5), (3, 1.0), (4, 2.0)):
|
||
if backoff:
|
||
await asyncio.sleep(backoff)
|
||
fresh = httpx.AsyncClient()
|
||
try:
|
||
figs, license_ = await _epmc_figures_once(fresh, pmcid)
|
||
finally:
|
||
await fresh.aclose()
|
||
if figs:
|
||
return figs, license_
|
||
if attempt < 4:
|
||
logger.info("PMC 解析失败(%s,第 %d 次)——重试(176 冷连接/ConnectError 实测)", pmcid, attempt)
|
||
return [], ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 对外 API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _local_figures_for_key(pmcid: str, doi: str | None) -> list[dict]:
|
||
"""纯本地解析(无任何外网调用):标准图库 + 遗留体系。
|
||
|
||
供 sse_relay 的 doi 别名路径使用——本地未命中时由调用方改走 PMC 形态
|
||
(单次外网调用),避免 DOI 形态外网兜底的两次调用拖爆预取预算。
|
||
"""
|
||
if doi:
|
||
local = _local_figures(doi)
|
||
if local:
|
||
return local
|
||
else:
|
||
_local_doi = _pmcid_to_doi_map().get(pmcid.strip().upper()) or ""
|
||
if _local_doi:
|
||
local = _local_figures(_local_doi)
|
||
if local:
|
||
return local
|
||
legacy_idx = _build_legacy_index()
|
||
return legacy_idx.get(doi or _pmcid_num(pmcid)) or []
|
||
|
||
|
||
async def resolve_figures(pmcid: str, limit: int = 4) -> dict[str, Any]:
|
||
"""按 PMCID 或 DOI 形态入参返回 {pmcid, license, figures:[{url,caption,source}]}。
|
||
|
||
- 标准图库(默认主路径,2026-09-20 起共通):DOI 形态直查;PMCID 形态经
|
||
registry 反查 DOI 后直查。新库数据按 DOI 布局落盘 + PLANTAI_FIGURES_ROOT
|
||
即自动生效,无需改代码。
|
||
- 遗留体系(次级兜底):methodology_upload_jobs 双键索引(DOI 键 / pmcid 数字键)。
|
||
- PMC 外网兜底(最后):PMCID 形态走 EPMC fullTextXML;DOI 形态先经
|
||
EPMC search(DOI→PMCID)再走 EPMC fullTextXML。
|
||
缓存 TTL 1 小时;失败静默降级为空清单。"""
|
||
doi = _doi_key(pmcid)
|
||
cache_key = doi if doi else pmcid
|
||
now = time.monotonic()
|
||
cached = _cache.get(cache_key)
|
||
if cached and now - cached["ts"] < CACHE_TTL:
|
||
return cached["data"]
|
||
|
||
figures: list[dict] = []
|
||
license_ = ""
|
||
|
||
# 1. 本地优先(标准图库 + 遗留体系,纯本地无外网)
|
||
local = _local_figures_for_key(pmcid, doi)
|
||
for f in local[:limit]:
|
||
figures.append({
|
||
"url": PROXY_PATH.format(pmcid=pmcid, url=quote(f["url"], safe="")),
|
||
"caption": f["caption"],
|
||
"source": "local",
|
||
})
|
||
|
||
# 2. PMC 兜底(本地不足 limit 时补;DOI 形态先经 EPMC search 换 PMCID)
|
||
if len(figures) < limit:
|
||
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}
|
||
# BUG-FIX 2026-09-18:空结果不缓存。176 冷连接偶发失败(超时/ConnectError)
|
||
# 时若缓存空清单,1 小时 TTL 内后续问答全部命中空缓存,图片永远注入不了;
|
||
# 175 出网快很少产生空结果,故未暴露。失败留给下次请求重试。
|
||
if figures:
|
||
_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。"""
|
||
# 本地路径直接读
|
||
if not raw_url.startswith("http"):
|
||
p = Path(raw_url)
|
||
if p.is_file():
|
||
return p.read_bytes(), _content_type(p.suffix)
|
||
return None
|
||
# 外网:磁盘缓存
|
||
cache_dir = Path("data/figures_cache") / pmcid # 相对 sdk/(BFF cwd)
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
fname = Path(raw_url.split("/")[-1]).name or "figure"
|
||
cache_file = cache_dir / fname
|
||
if cache_file.is_file():
|
||
return cache_file.read_bytes(), _content_type(cache_file.suffix)
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
r = await client.get(raw_url, timeout=EXTERNAL_TIMEOUT, follow_redirects=True)
|
||
if r.status_code != 200:
|
||
return None
|
||
ct = r.headers.get("content-type", "")
|
||
if not ct.startswith("image/"):
|
||
return None
|
||
cache_file.write_bytes(r.content)
|
||
return r.content, ct
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _content_type(suffix: str) -> str:
|
||
return {
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".png": "image/png",
|
||
".gif": "image/gif",
|
||
}.get(suffix.lower(), "application/octet-stream")
|