Files
PlantAI-rag-new/sdk/app/services/figures.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- 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>
2026-08-31 12:56:47 +08:00

298 lines
12 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. 本地 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")