Files
PlantAI-rag-new/sdk/app/services/figures.py
T
admin 0282cfeeeb feat(bioflow): 阶段进度条+滚动日志+点击定位全链;图片注入三修;QA启动就绪等待
- 生信进度:后端新增 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
2026-09-24 16:48:52 +08:00

569 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")