From 00c77aa323e89e7ca0a0cc7c51758aeb21d8ee6d Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 12 Sep 2026 22:19:25 +0800 Subject: [PATCH] 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 --- .../qa/src/rag/qa_v1_1_73/answer_composer.py | 2 +- sdk/app/services/figures.py | 111 ++++++++++++++---- sdk/app/services/humanizer.py | 1 + sdk/app/services/normalize.py | 11 +- sdk/app/services/sse_relay.py | 31 +++-- 5 files changed, 119 insertions(+), 37 deletions(-) diff --git a/agent/qa/src/rag/qa_v1_1_73/answer_composer.py b/agent/qa/src/rag/qa_v1_1_73/answer_composer.py index 75355a7..9a66962 100644 --- a/agent/qa/src/rag/qa_v1_1_73/answer_composer.py +++ b/agent/qa/src/rag/qa_v1_1_73/answer_composer.py @@ -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: diff --git a/sdk/app/services/figures.py b/sdk/app/services/figures.py index c819596..9a6e811 100644 --- a/sdk/app/services/figures.py +++ b/sdk/app/services/figures.py @@ -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。""" diff --git a/sdk/app/services/humanizer.py b/sdk/app/services/humanizer.py index e61ece9..d906b7f 100644 --- a/sdk/app/services/humanizer.py +++ b/sdk/app/services/humanizer.py @@ -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) diff --git a/sdk/app/services/normalize.py b/sdk/app/services/normalize.py index 0fd94b6..334d538 100644 --- a/sdk/app/services/normalize.py +++ b/sdk/app/services/normalize.py @@ -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) diff --git a/sdk/app/services/sse_relay.py b/sdk/app/services/sse_relay.py index f22dba9..3e8d062 100644 --- a/sdk/app/services/sse_relay.py +++ b/sdk/app/services/sse_relay.py @@ -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 已返回代理端点绝对路径,无需拼接