- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
298 lines
12 KiB
Python
298 lines
12 KiB
Python
"""services/figures.py —— 论文图片解析(2026-08-28 图文并茂方案,BFF 侧)。
|
||
|
||
双源策略:
|
||
1. 本地 MinerU 图片(优先):methodology_upload_jobs 下 completed 任务的
|
||
`*_content_list.json` 中 type=="image" 条目(img_path + caption + page_idx)。
|
||
经 paper_metadata_registry(pmcid → DOI 风格 paper_id)与 job 目录映射。
|
||
2. PMC 外网兜底:Europe PMC fullTextXML 拿 figure 文件名 + caption;
|
||
license 经 Europe PMC REST search;图片直链 pmc.ncbi.nlm.nih.gov。
|
||
(实测:PMC5488377 全链路 200;服务器可出外网。)
|
||
|
||
所有异常静默降级为空清单——图片失败不影响答案主链。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 本地 MinerU 图片根(探索实测路径)
|
||
MINERU_ROOT = Path("/home/xzq/5090/teamWorkspace/zh/PlantAI-rag/data/evaluation/methodology_upload_jobs")
|
||
REGISTRY_PATH = Path(
|
||
"/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 -> {ts, figures, license}
|
||
_local_index: dict[str, list[dict]] | None = None # pmcid数字 -> 本地图片清单
|
||
EXTERNAL_TIMEOUT = 5.0
|
||
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 _build_local_index() -> dict[str, list[dict]]:
|
||
"""扫描 methodology_upload_jobs 的 completed 任务,pmcid数字 -> 图片清单。
|
||
无 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 前缀
|
||
pmcid_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
|
||
import json as _json
|
||
rec = _json.loads(line)
|
||
pmcid = rec.get("pmcid") or ""
|
||
paper = rec.get("paper_id") or ""
|
||
if pmcid and paper:
|
||
pmcid_to_paper[pmcid] = paper
|
||
if not MINERU_ROOT.is_dir():
|
||
_local_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] = []
|
||
import json as _json
|
||
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"])
|
||
idx[_pmcid_num(pmcid)] = figures
|
||
except Exception as exc: # 索引构建失败不阻断服务
|
||
logger.warning("本地图片索引构建失败(降级为纯 PMC): %s", exc)
|
||
_local_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(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,
|
||
)
|
||
# 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 [], ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 对外 API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def resolve_figures(pmcid: str, limit: int = 4) -> dict[str, Any]:
|
||
"""按 PMCID 返回 {pmcid, license, figures:[{url,caption,source}]}。
|
||
本地优先,PMC 兜底;缓存 TTL 1 小时。"""
|
||
now = time.monotonic()
|
||
cached = _cache.get(pmcid)
|
||
if cached and now - cached["ts"] < CACHE_TTL:
|
||
return cached["data"]
|
||
|
||
figures: list[dict] = []
|
||
license_ = ""
|
||
|
||
# 1. 本地优先
|
||
idx = _build_local_index()
|
||
local = idx.get(_pmcid_num(pmcid)) or []
|
||
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 时补)
|
||
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",
|
||
})
|
||
|
||
data = {"pmcid": pmcid, "license": license_, "figures": figures}
|
||
_cache[pmcid] = {"ts": now, "data": data}
|
||
return data
|
||
|
||
|
||
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")
|