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:
2026-09-12 22:19:25 +08:00
co-authored by Claude Fable 5
parent 6bcb88cf76
commit 00c77aa323
5 changed files with 119 additions and 37 deletions
@@ -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
View File
@@ -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。"""
+1
View File
@@ -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)
+6 -5
View File
@@ -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)
+20 -11
View File
@@ -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 已返回代理端点绝对路径,无需拼接