Skill: jukiss1/research-paper-to-ppt-v1 (version 1.0.0)

--- name: research-paper-to-ppt-v2 description: 将指定医学研究论文自动生成 PPTX 汇报文件的技能。用于用户给出单篇文献标题、PMID、DOI、InfoX-Med 文献 id 或可唯一定位该论文的信息时：先用 InfoX-Med 相关检索技能精确定位目标论文，再读取全文与原文图表，最后按基础研究型汇报大纲选择性生成 PPTX。适用于"根据这篇论文生成汇报PPT""把这篇 research paper 做成中文组会汇报""按文献生成带原图的PPT"等请求。强制要求：(1) PPT 必须优先使用论文原文图片；(2) 必须按大纲逻辑生成，但论文未涉及的章节直接跳过，不得插入空白页；(3) 若无法定位目标文献或无法获取全文，必须明确返回"检索失败，无法生成"，不得编造内容或伪造图片。默认输出文件名使用论文标题；默认版式为美观组会风格，包含目录页、结尾页和"谢谢！感谢批评指正！"结束文案；字体、图片、坐标等视觉规范必须严格遵循 visual-rendering-spec.md。 --- # Research Paper to PPT v2 按下面流程执行,不要跳步,不要编造。 ## 0. 一键完整生成优先路径 当用户明确要求“直接生成 PPTX / 一键生成 / 输出最终 pptx/ppt 文件”时，优先使用本技能内置完整流水线： - `scripts/complete_pipeline.py` 它会串起以下全部步骤： 1. 精确检索目标文献 2. 获取全文与原文 Figure 3. 提取图注与 Results 上下文 4. 校验输入 5. 生成中间 `slides.json` 6. 强制检查“有原图时必须使用原图且配图解；无原图时允许无图页” 7. 调用 `scripts/render_pptx_from_slides.js` 输出最终 `.pptx` 推荐调用方式： ```bash python3 <skill_dir>/scripts/complete_pipeline.py \ --title "完整英文标题" \ --work-dir /tmp/paper_to_ppt_run \ --output /desired/output/path.pptx ``` 约束： - 若完整流水线任一步失败，直接返回：`检索失败,无法生成` - 不要在失败时改做“纯文字简版” - 一键路径生成的 PPTX 必须保留当前技能已有的功能和效果： - 先检索再全文 - **若全文存在原文图，相关结果页/机制页必须优先使用原文图** - **若全文确实没有可提取原图，允许生成无图的内容页，但不得伪造图片** - 图解优先结合图注 + Results 上下文 - 默认输出美观组会风格 仅在需要调试、局部替换步骤、或修补某一中间产物时，再回退到下面的分步流程。 ## 1. 先确认输入是否足以唯一定位单篇论文 接受以下任一信息: - 完整标题 - PMID - DOI - InfoX-Med 文献 id - 足够唯一的"标题 + 期刊/年份/作者"组合 如果用户给的信息明显不足以唯一定位单篇文献,先要求补充;不要猜测目标论文。 ## 2. 先检索,再读全文;任一步失败都直接终止 必须先调用 `medical-keyword-search` 做精确检索,优先顺序: 1. DOI / PMID / InfoX-Med id 2. 完整英文标题 3. 标题 + 作者/年份/期刊联合检索 检索结果处理规则: - 只有在检索结果能唯一确认目标文献时,才能继续。 - 如果没有结果,或有多个高相似结果但无法唯一确认,返回:`检索失败,无法生成` - 不要把"最像的一篇"当作目标文献。 确认目标文献后,必须调用环境内可用的全文技能获取全文,优先使用 `full-paper-api`;若当前环境实际提供的是 `full-paper-read` 或同类全文读取技能,以环境内实际技能名为准。 - 若全文接口因 token 格式失败或环境 token 异常,先不要立刻放弃;优先调用 `scripts/fetch_fullpaper.py` 做一次本地兜底重试。它会从 `INFOX_MED_TOKEN` 中提取合法 token 候选,重新请求全文 API,并尽量整理 `paper_meta/fulltext/figures`。 - 若全文接口失败、正文缺失、只拿到极少量摘要、或没有可用于制作 PPT 的正文/图表信息,返回:`检索失败,无法生成` - 不能只根据摘要硬做 PPT。 ## 3. 读取参考资料 在开始生成内容前,按需读取: - `references/integration-notes.md`:与检索 / 全文 / pptx 技能的衔接说明 - `references/outline-basic-research.md`:基础研究型汇报大纲与映射规则 - `references/slide-schema.md`:中间结构化 JSON 约定 - `references/figure-caption-policy.md`:Figure 解释写法与禁忌 - `references/ppt-copy-rules.md`:最终 PPT 中禁止出现的元说明文案 - `references/style-preferences-from-reference.md`:参考 PPT 与修改意见沉淀出的默认风格偏好 - `references/layout-rules-from-feedback.md`:更细的版式/字号/拆页/路径图规则 - `references/output-filename-policy.md`:最终 PPT 文件名的默认命名规则 - `references/visual-rendering-spec.md`:**每次生成 PPTX 前必须阅读**,包含精确坐标、颜色 hex 值、字体规范、阴影规范和每种 slide_type 的版式模板 如果用户另给了大纲或版式要求,用户要求优先;否则默认采用本技能内置大纲映射。 ## 4. 先做结构化提取,再决定实际生成哪些页 先从全文提取结构化信息,至少覆盖: - 文献信息:英文标题、中文标题、作者、期刊、年份 - 作者展示规则:要么完整列出全部作者,要么默认只列一作并加 `et al.`;不要出现残缺作者串 - 摘要/全文总述 - 研究背景 - 科学问题/研究假设 - 研究对象与模型 - 分组与干预设计 - 关键实验方法 - 结果链条:现象发现、功能验证、机制探索、救援实验、体内验证、临床/转化验证 - 主要结论 - 优点、局限性、科学意义/转化价值 - 致谢/结束页可用内容 - 原文 Figure / Table 清单:编号、标题/图注、图片来源、与正文对应关系 然后按大纲做"有条件生成": - 仅当论文中有对应内容、且能做出有信息量的一页时,才生成该页。 - 对于论文中没有涉及的部分,直接跳过。 - 严禁为了凑大纲页数插入"暂无数据""文章未提及"之类空白页。 ## 5. 图片规则:有原图时必须优先使用原文图片 这是条件性硬约束。 也就是说： - **如果全文/附件/PDF 中存在可提取的原文 Figure、Table 图像、graphical abstract 或 summary figure，则必须优先使用这些原图** - **如果全文确实没有可提取原图，则允许生成无图版 PPT** - 允许无图版，不等于允许伪造图、网图替代图或把纯文字包装成“原图页” ### 5.1 可使用的图片来源 仅允许: - 全文中明确给出的原文 Figure 图片 - 全文中明确给出的原文 Table 截图或可导出图像 - 全文中的 graphical abstract / overview 图 - 论文 PDF 中原生存在的图,不得自行重绘成"仿原图"后冒充原图 ### 5.2 禁止项 禁止: - 编造图片 URL - 使用与原文无关的网图/示意图替代原文核心图 - 在没有拿到原图时根据图注手绘"伪原图"并当作论文图片 - 把纯文本页伪装成"有图页" ### 5.3 图片选择原则 优先选择: 1. 能代表该结果页核心结论的 Figure 2. 作者机制图 / graphical abstract 3. 同时包含多面板关键证据的 Figure 如果一个结果段落涉及多个 panel(如 Figure 3A-3F): - 优先放整张 Figure,而不是凭空裁切出不存在的 panel 图 - 若工具链支持可靠裁切,可裁切但必须来自原图,并保持编号对应关系清晰 ### 5.4 结果页与机制页的强制要求 这是新增硬约束: - **当论文存在原文图片时**, 所有相关结果页应至少嵌入 1 张原文图片 - **机制页**若论文存在机制图/graphical abstract/summary figure,也必须优先嵌入原图 - 若论文存在原图但不存在独立机制图,允许机制页复用最能代表整体主线的原文 Figure；不要因为“没单独机制图”而让整篇生成失败 - **当论文不存在可提取原图时**, 结果页允许改为高质量纯文字/卡片式内容页,但必须忠实基于全文,不得伪造“图解”或伪造 Figure 编号 - 每张被使用的图都必须配套**全中文**解释,解释**优先直接参考原文图注并翻译成中文** - 如图注不足以支撑讲述,可再补充对应 Results 段的最小必要信息,但补充内容也必须写成中文 - 当单张 Figure 信息量过大时,允许按 panel/区块拆分为多页,但必须保持"来自原图"的可追溯性 - 多图或多 panel 情况下,优先"拆分讲清楚",不要把所有信息硬塞在一页里 - 若论文明明存在原图却未使用,或只能通过伪造图片才能完成版式,直接返回:`检索失败,无法生成` - 机制页校验应区分两种情况： 1. 该页被显式标记为必须配图（如存在可复用的机制/概览/总览型原图）→ 必须带原图 2. 该页未被标记为必须配图 → 允许作为纯文字“主线总结页”存在，但不得冒充原图页 ## 6. Figure 配字规则：优先忠实参照原文图注翻译，再做轻度整理 新版默认要求： - 图的解释**必须输出为中文**，可以直接参照原文图注翻译 - 如原文图注过长，可压缩成更适合汇报的中文表达 - 如 Results 段提供了更明确的实验目的或结论，可补充 1 句，但必须保持中文输出 - 不要求为了“更解释性”而强行改写成复杂长段 对每个 Figure,优先覆盖以下信息: - 这张图在回答什么问题 - 图中主要比较对象/模型/处理条件 - 图里最重要的观察结果 - 必要时再补一句它在全文证据链中的位置 写法要求: - 用中文 - 2-4 条要点或 1 段中等长度说明 - 优先忠实、清楚、可讲 - 不要比原文图注复杂很多 禁止: - 只贴乱码或未经清洗的图注 - 写成冗长论文段落 - 夸大图中没有直接证明的结论 详细规则见 `references/figure-caption-policy.md`。 ## 6.1 结果页标题来源规则（新增硬偏好） 结果部分页面标题默认优先级: 1. **原文 Results 小标题** 2. 若原小标题直译过长,可在不改变原意前提下压缩总结成更短标题 3. 只有在论文没有明确 Results 小标题可映射时,才允许助手自行概括标题 也就是说: - 不要随意把结果页标题改写成完全脱离原文结构的新标题 - 优先让 PPT 结果页和原文结果部分的小标题建立一一对应关系 ## 6.2 单张 Figure 可拆成 2-3 页（新增硬允许） 当单个 Figure（如 Fig 1）信息量很大时: - 允许把**同一张图**连续放在 2-3 页 PPT - **不需要切图**,默认保持原图完整展示 - 每一页只讲这张图的不同部分/不同重点 - 页标题可沿用同一个 Results 小标题,并在必要时加 `(1/3)`、`(2/3)`、`(3/3)` - 这种拆页优先于缩小字体或把全部解释硬塞一页 ## 7. 幻灯片组织规则 默认遵循基础研究型大纲,但做"逻辑压缩 + 条件跳过"。 如果用户没有指定别的风格,优先参考 `references/style-preferences-from-reference.md` 中沉淀的结构偏好: - 更接近组会 / Journal Club 的讲述顺序 - 背景 → 问题 → 方法 → 结果链 → 机制 → 局限与转化 → 结论 - 不做"摘要式大杂烩",而是做"逐层推进式汇报" 推荐优先级: 1. 标题页 2. 目录页 3. 全文概述 4. 研究背景 5. 科学问题与研究假设 6. 文献基本信息 / 研究对象 7. 整体研究思路与技术路线 8. 研究对象与实验材料 / 分组设计 / 核心方法(可合并) 9. 关键结果页若干 10. 主要结果总结 11. 机制模式图 12. 优点与局限 13. 科学意义与转化价值 14. 结论 / take-home message 15. 致谢 / 感谢页 压缩规则: - 若文章较短,可把"研究对象、分组、方法"压缩到 1-2 页 - 若结果很多,优先保留最关键的证据链:现象 → 功能 → 机制 → 因果/救援 → 体内/临床 - 若没有动物或临床部分,直接跳过对应页 - 目录页、感谢页默认建议保留;若用户明确要求极简版,再按需裁剪 ### 7.1 默认美观版式要求 若用户未指定模板,默认按"更适合组会汇报"的风格生成,而不是仅满足信息可放下: - 标题页要有明显视觉层次,不要只是标题加几行字 - 目录页默认生成,帮助听众建立预期 - 非结果页优先使用卡片、分区、流程、摘要框,而不是整页大段 bullet - 结果页优先采用"左侧核心结论 + 右侧原文图 + 下方图解"或等效布局 - 机制页优先采用路径图/箭头链式表达 - 优点、局限、意义、take-home message 等总结页优先做成分组卡片样式 - 结尾页默认生成正式收尾文案;若用户未另行指定,默认使用:`谢谢!感谢批评指正!` ### 7.2 关于动画 若用户明确要求"动画": - 优先通过**分页拆分、分步呈现、目录/章节过渡页**实现接近动画的讲述节奏 - 在当前工具链无法可靠写入原生 PowerPoint 动画时,不要为了动画破坏 PPT 文件稳定性 - 除非当前生成链已验证支持且稳定,否则不要把原生动画作为硬交付要求 - 可以在最终说明中标注:已通过分页与节奏设计实现更强的演示感 ## 8. 先生成中间 JSON,再生成 PPTX 先整理成统一 JSON,再交给 `pptx` 技能生成文件。 如需稳定中间输入,优先使用: - `scripts/fetch_fullpaper.py`:从 InfoX-Med 全文 API 拉取并规范化 `paper_meta/fulltext/figures`,内置 token 异常兜底 - `scripts/extract_figures_and_captions.py`:从全文 markdown 中提取每张原图的图注,并进一步匹配对应 Results 段落,结合"图注 + 结果上下文"生成更扎实的中文讲解要点,同时清洗 markdown/math 噪声,避免图注摘要乱码 - `scripts/validate_inputs.py`:检查是否满足硬失败条件 - `scripts/build_prompt_bundle.py`:把 paper meta、全文、figure 清单整理成统一 bundle - `scripts/enforce_figure_requirements.py`:在生成 PPT 前强制检查"有原图时必须带原图且有图片解释；无原图时允许无图页" 推荐顺序: 1. `fetch_fullpaper.py` 2. `extract_figures_and_captions.py` 3. `validate_inputs.py` 4. `build_prompt_bundle.py` 5. 生成 `slides[]` 6. `enforce_figure_requirements.py` 7. 调用 `pptx` 技能输出 `.pptx` JSON 至少包含: - `title` - `paper_meta` - `slides[]` - 每页的 `slide_type` - 每页的 `title` - `bullets` / `sections` - `figure_refs` - `image_paths_or_urls` - `speaker_intent`(可选,简短说明这一页想讲什么) 中间结构的详细字段见 `references/slide-schema.md`。 ## 9. 调用 pptx 技能时的硬性要求 交给 `pptx` 技能生成时,明确要求: - 输出 `.pptx` - 输出文件名默认使用**论文标题**生成;优先使用英文原题,做文件系统安全清洗后作为文件名 - 若用户明确要求中文文件名或另有命名规则,按用户要求覆盖默认规则 - 不要输出 `final.pptx`、`output.pptx`、`report_beautified.pptx` 这类泛化名称 - **凡是使用图片的页面,必须嵌入原文图片** - **若全文存在原文图片,相关结果页必须优先使用原文图片；若全文无图,允许纯文字结果页** - 每张图旁边必须有中文解释,解释应帮助讲述"实验目的-观察结果-结论意义" - 图解应优先结合 **图注 + Results 对应段落** 生成,不要只做图注直译 - 核心结论必须写成自然、可讲的中文汇报语言,优先包含:主语 / 干预 / 时长或条件 / 结果方向,而不是只写标签词 - 当单页信息过多时,优先拆页、拆 panel、分区呈现,不要优先把字体压小 - 字号层级要明确:标题显著大于正文;正文宁可偏大,也不要牺牲可读性 - 对"动物模型、方法、结论分类"等非结果页,优先考虑图标、卡片、流程图、目录页、章节过渡页等方式减轻纯文本压力 - 机制页要体现上下游路径关系,尽量使用箭头/流程,而不是只堆概念块 - 总结页、优点局限页、意义页优先考虑分组卡片、编号块、视觉分类,而不是纯 bullet 堆叠 - 结尾页默认加入正式收尾文案;若用户未指定,默认文案为:`谢谢!感谢批评指正!` - 每页不要堆太多字,优先保证可讲解性 - 标题与层级统一 - 中文排版清晰 - 不生成空白页、占位页、模板残页 - 不要在最终 PPT 中出现"带原文图片的中文汇报版""本版 PPT 的结果页均直接嵌入原文 Figure..."等元说明文案 - "原始图注摘要"若包含 markdown / LaTeX / OCR 噪声,应先清洗后再决定是否展示;宁可简短,也不要乱码 若已有现成模板,可使用;若没有,保持简洁但美观的科研汇报风格即可。 ## 10. 最终输出规则 ### 成功时 返回: - 已生成的 pptx 文件 - 简短说明生成范围(例如:共 12 页,包含背景、方法、4 页关键结果、机制图、优缺点与总结) - 如有跳过章节,可简单注明"已根据论文内容跳过未涉及模块" ### 失败时 仅返回: - `检索失败,无法生成` 以下情况都属于失败: - 无法唯一定位目标论文 - 检索不到论文 - 全文获取失败 - 全文中拿不到足够正文信息 - 论文存在原文图片,但图片无法获得且无法满足"有图时必须优先使用原文图片"的要求 - 只能依靠伪造图片、网图替代或编造 Figure 才能完成生成 不要在失败时附带猜测性总结,不要提供"先做个简版"。 ## 11. 质量自检清单 交付前逐项确认: - 是否确实是用户指定的那篇论文 - 是否已成功读取全文,而非只基于摘要 - 是否所有图片都来自原文 - 是否没有任何编造图、编造结论、编造章节 - 是否跳过了论文未涉及的模块,而不是插入空页 - 是否每个 Figure 的中文解释比原始图注更适合汇报,但不过度冗长 - 是否结果页围绕证据链组织,而不是简单复制论文目录 - 若不满足上述任一项,停止并返回:`检索失败,无法生成`

path: scripts/build_prompt_bundle.py
content: #!/usr/bin/env python3
"""Build a compact prompt bundle for research-paper-to-ppt-v1.

This script does not call external APIs. It converts resolved paper metadata,
full-text markdown, extracted figure inventory, and outline policy into a
single JSON bundle that another agent/skill can feed into a PPTX generator.

Usage:
  python3 build_prompt_bundle.py \
    --paper-meta paper_meta.json \
    --fulltext fulltext.md \
    --figures figures.json \
    --output bundle.json
"""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path
from typing import Any, Dict, List

SECTION_HINTS = {
    "background": ["introduction", "background"],
    "methods": ["methods", "materials and methods", "experimental procedures"],
    "results": ["results"],
    "discussion": ["discussion"],
    "conclusion": ["conclusion", "conclusions"],
    "abstract": ["abstract"],
}


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def read_json(path: Path) -> Any:
    return json.loads(read_text(path))


def normalize_ws(text: str) -> str:
    return re.sub(r"\s+", " ", text).strip()


def extract_sections(md: str) -> Dict[str, str]:
    lines = md.splitlines()
    buckets: Dict[str, List[str]] = {k: [] for k in SECTION_HINTS}
    current = None
    for line in lines:
        stripped = line.strip()
        heading = stripped.lstrip("#").strip().lower() if stripped.startswith("#") else ""
        matched = None
        if heading:
            for key, hints in SECTION_HINTS.items():
                if any(h in heading for h in hints):
                    matched = key
                    break
            current = matched
            continue
        if current:
            buckets[current].append(line)
    return {k: normalize_ws("\n".join(v)) for k, v in buckets.items() if normalize_ws("\n".join(v))}


def build_bundle(paper_meta: Dict[str, Any], fulltext: str, figures: Any) -> Dict[str, Any]:
    sections = extract_sections(fulltext)
    return {
        "paper_meta": paper_meta,
        "fulltext_sections": sections,
        "figures": figures,
        "constraints": {
            "must_use_original_figures": True,
            "result_slides_require_original_images": True,
            "figure_explanation_required": True,
            "skip_missing_outline_sections": True,
            "fail_on_missing_fulltext": True,
            "fail_message": "检索失败，无法生成",
            "author_display_default": "first_et_al",
            "result_title_priority": "results_subtitle_first",
            [REDACTED_SECRET]: True,
            "figure_explanation_style": "必须输出全中文图解，优先直接参照原文图注翻译并做轻度整理；需要时可补充实验目的、主要比较对象、核心观察结果和该图在全文证据链中的意义，但不要为了显得高级而过度改写。",
        },
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--paper-meta", required=True)
    parser.add_argument("--fulltext", required=True)
    parser.add_argument("--figures", required=True)
    parser.add_argument("--output", required=True)
    args = parser.parse_args()

    paper_meta = read_json(Path(args.paper_meta))
    fulltext = read_text(Path(args.fulltext))
    figures = read_json(Path(args.figures))
    bundle = build_bundle(paper_meta, fulltext, figures)
    Path(args.output).write_text(json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8")


if __name__ == "__main__":
    main()


path: scripts/complete_pipeline.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

FAIL = "检索失败,无法生成"


def author_display(authors: List[str], mode: str = 'first_et_al') -> str:
    authors = [a.strip() for a in (authors or []) if str(a).strip()]
    if not authors:
        return ''
    if mode == 'full':
        return ', '.join(authors)
    return f"{authors[0]} et al." if len(authors) > 1 else authors[0]


def compress_result_title(title: str) -> str:
    title = re.sub(r'^[\d\.\s]+', '', (title or '').strip())
    title = re.sub(r'^(results?|result)\s*[:：-]?\s*', '', title, flags=re.I)
    title = re.sub(r'\s+', ' ', title).strip(' -–—:：')
    if len(title) <= 60:
        return title
    parts = re.split(r'[:：;；,.，]', title)
    first = parts[0].strip() if parts else title
    if 6 <= len(first) <= 60:
        return first
    return title[:60].rstrip()


def split_explanation_points(explanation: str, max_chunks: int = 3) -> List[str]:
    explanation = (explanation or '').strip()
    if not explanation:
        return []
    parts = [p.strip() for p in re.split(r'[；;\n]+', explanation) if p.strip()]
    if len(parts) <= 1:
        parts = [p.strip() for p in re.split(r'(?<=。)', explanation) if p.strip()]
    if len(parts) <= 1:
        parts = [p.strip() for p in re.split(r'(?<=，)', explanation) if p.strip()]
    parts = [p for p in parts if p]
    if not parts:
        return [explanation]
    if len(explanation) > 220 and len(parts) < 2:
        mid = len(explanation) // 2
        split_at = max(explanation.find('。', mid), explanation.find('；', mid), explanation.find('，', mid))
        if split_at != -1:
            parts = [explanation[:split_at+1].strip(), explanation[split_at+1:].strip()]
    if len(parts) <= max_chunks:
        return parts
    chunked = []
    size = (len(parts) + max_chunks - 1) // max_chunks
    for i in range(0, len(parts), size):
        chunked.append('；'.join(parts[i:i+size]))
    return chunked[:max_chunks]


def read_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def write_json(path: Path, data: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess:
    return subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True, capture_output=True, check=check)


def debug_log(work_dir: Path, stage: str, detail: str) -> None:
    try:
        work_dir.mkdir(parents=True, exist_ok=True)
        with (work_dir / 'pipeline_debug.log').open('a', encoding='utf-8') as f:
            f.write(f'[{stage}] {detail}\n')
    except Exception:
        pass


def script_dir() -> Path:
    return Path(__file__).resolve().parent


def infer_title_query(title: str) -> str:
    escaped = title.replace('"', '\\"')
    return f'(\\"{escaped}\\"[Title])'


def search_paper(title: str, out_dir: Path) -> Dict[str, Any]:
    debug_log(out_dir, 'search', f'title={title}')
    local_search = script_dir() / 'medical_search.py'
    search_script = local_search if local_search.exists() else Path.home() / '.openclaw/skills/medical-keyword-search/scripts/medical_search.py'
    exact_out = out_dir / 'search_exact.json'
    broad_out = out_dir / 'search_broad.json'

    def normalize_title(s: str) -> str:
        return re.sub(r'[^a-z0-9]+', '', (s or '').lower())

    def pick_best(items: Any, target_title: str) -> Optional[Dict[str, Any]]:
        if not isinstance(items, list):
            return None
        target = normalize_title(target_title)
        exact = [it for it in items if normalize_title(str(it.get('title', ''))) == target]
        if len(exact) == 1:
            return exact[0]
        close = []
        target_words = {w for w in re.findall(r'[a-z0-9]+', target_title.lower()) if len(w) > 2}
        for it in items:
            cand_title = str(it.get('title', '')).lower()
            cand_words = set(re.findall(r'[a-z0-9]+', cand_title))
            overlap = len(target_words & cand_words)
            if overlap >= max(3, min(len(target_words), 6) - 1):
                close.append((overlap, it))
        close.sort(key=lambda x: x[0], reverse=True)
        if len(close) == 1:
            return close[0][1]
        if len(close) >= 2 and close[0][0] > close[1][0]:
            return close[0][1]
        return None

    def run_search(args: List[str], out_path: Path) -> Any:
        cmd = ['python3', str(search_script), '--output', str(out_path)] + args
        debug_log(out_dir, 'search.cmd', ' '.join(cmd))
        cp = run(cmd, check=False)
        debug_log(out_dir, 'search.ret', f'code={cp.returncode}; stdout={cp.stdout[:500]!r}; stderr={cp.stderr[:500]!r}')
        if cp.returncode != 0:
            raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or 'search failed')
        return read_json(out_path)

    normalized = title.replace('APP_PS1', 'APP/PS1').replace('APP-PS1', 'APP/PS1')
    try:
        exact = run_search(['free', '--query', infer_title_query(title)], exact_out)
    except Exception as e:
        debug_log(out_dir, 'search.exact.err', repr(e))
        exact = []
    picked = pick_best(exact, normalized)
    if picked:
        return picked

    title_keywords = [w for w in re.findall(r"[A-Za-z0-9\-/']+", normalized) if len(w) > 3][:6]
    broad_query = ' AND '.join([f'({w}[Title/Abstract])' for w in title_keywords])
    broad = run_search(['free', '--query', broad_query], broad_out)
    picked = pick_best(broad, normalized)
    if picked:
        return picked
    raise RuntimeError(FAIL)


def fetch_fullpaper(doc_id: str, out_dir: Path) -> Tuple[Path, Path, Path, Path]:
    debug_log(out_dir, 'fetch', f'doc_id={doc_id}')
    meta = out_dir / 'meta.json'
    fulltext = out_dir / 'fulltext.md'
    figures = out_dir / 'figures.json'
    debug = out_dir / 'debug.json'
    script = script_dir() / 'fetch_fullpaper.py'
    cmd = [
        'python3', str(script), '--doc-id', str(doc_id), '--meta-out', str(meta), '--fulltext-out', str(fulltext), '--figures-out', str(figures), '--debug-out', str(debug)
    ]
    debug_log(out_dir, 'fetch.cmd', ' '.join(cmd))
    cp = run(cmd, check=False)
    debug_log(out_dir, 'fetch.ret', f'code={cp.returncode}; stdout={cp.stdout[:500]!r}; stderr={cp.stderr[:500]!r}')
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    return meta, fulltext, figures, debug


def extract_captions(fulltext: Path, figures: Path, out_dir: Path) -> Path:
    output = out_dir / 'figure_captions.json'
    script = script_dir() / 'extract_figures_and_captions.py'
    cp = run(['python3', str(script), '--fulltext', str(fulltext), '--figures', str(figures), '--output', str(output)], check=False)
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    return output


def validate_inputs(meta: Path, fulltext: Path, figures: Path, out_dir: Path) -> Path:
    output = out_dir / 'validation.json'
    script = script_dir() / 'validate_inputs.py'
    cp = run(['python3', str(script), '--paper-meta', str(meta), '--fulltext', str(fulltext), '--figures', str(figures), '--output', str(output)], check=False)
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    data = read_json(output)
    if not data.get('ok'):
        raise RuntimeError(FAIL)
    return output


def build_bundle(meta: Path, fulltext: Path, figures: Path, out_dir: Path) -> Path:
    output = out_dir / 'bundle.json'
    script = script_dir() / 'build_prompt_bundle.py'
    cp = run(['python3', str(script), '--paper-meta', str(meta), '--fulltext', str(fulltext), '--figures', str(figures), '--output', str(output)], check=False)
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    return output


def parse_authors_from_fulltext(fulltext_text: str) -> List[str]:
    lines = [ln.strip() for ln in fulltext_text.splitlines() if ln.strip()]
    if len(lines) < 3:
        return []
    for idx, line in enumerate(lines[:15]):
        if line.startswith('#') and idx + 1 < len(lines):
            candidate = lines[idx + 1]
            if any(k in candidate for k in ['Department of', 'Institute of', 'Hospital', 'University']):
                continue
            parts = [p.strip(' ,') for p in candidate.split(',')]
            cleaned = []
            for p in parts:
                p = re.sub(r'\$.*?\$', ' ', p)
                p = re.sub(r'\s*\d+$', '', p).strip()
                if len(p) >= 3 and not any(k in p for k in ['Department of', 'Institute of', 'Hospital', 'University']):
                    cleaned.append(p)
            if cleaned:
                return cleaned
    return []


def detect_journal_year(search_item: Dict[str, Any], meta: Dict[str, Any]) -> Tuple[str, str]:
    journal = meta.get('journal') or search_item.get('journal') or ''
    year = meta.get('year') or ''
    if not year:
        pub = str(search_item.get('publish_date') or '')
        m = re.match(r'(\d{4})', pub)
        if m:
            year = m.group(1)
    return journal, year


def sentence(text: str) -> str:
    text = re.sub(r'\s+', ' ', (text or '').strip())
    if not text:
        return ''
    return text if re.search(r'[。！？]$', text) else text + '。'


def first_nonempty(*vals: str) -> str:
    for v in vals:
        if str(v or '').strip():
            return str(v).strip()
    return ''


def choose_title_zh(title_en: str, meta: Dict[str, Any], fulltext: str) -> str:
    existing = first_nonempty(meta.get('title_zh'))
    if existing:
        return existing
    t = title_en.lower()
    rule_map = [
        (r'ameliorates|alleviates|attenuates|improves', '改善'),
        (r'promotes|enhances', '促进'),
        (r'inhibits|reduces|suppresses', '抑制'),
    ]
    verb = '影响'
    for pat, rep in rule_map:
        if re.search(pat, t):
            verb = rep
            break
    disease = '相关病理'
    if "alzheimer" in t:
        disease = '阿尔茨海默病相关病理'
    elif 'parkinson' in t:
        disease = '帕金森病相关病理'
    elif 'cancer' in t or 'tumor' in t:
        disease = '肿瘤进展'
    model = '模型'
    m = re.search(r'in ([A-Za-z0-9/\- ]+?) model', title_en, re.I)
    if m:
        model = m.group(1).strip() + ' 模型'
    subject = title_en.split()[0]
    return f'{subject}在{model}中{verb}{disease}'


def infer_general_overview(title_en: str, abstract: str, captions: List[Dict[str, Any]], fulltext: str) -> List[str]:
    abs_clean = re.sub(r'\s+', ' ', abstract or '').strip()
    cap_blob = ' '.join((c.get('matched_result_title') or '') + ' ' + (c.get('original_caption') or '') for c in captions)
    bullets = []
    if abs_clean:
        bullets.append(sentence('研究目标：' + abs_clean[:90].strip(' .;，。')))
    if re.search(r'mice|mouse|murine', fulltext, re.I):
        bullets.append('研究对象：动物实验为主，围绕疾病模型中的关键病理与功能结局展开评估。')
    elif re.search(r'patients|clinical|trial', fulltext, re.I):
        bullets.append('研究对象：以临床人群或患者样本为主，评估干预对关键结局的影响。')
    else:
        bullets.append('研究对象：围绕论文中的核心模型、干预和主要结局进行系统评估。')
    if re.search(r'proteom|transcriptom|sequencing|rna-seq', cap_blob, re.I):
        bullets.append('研究方法：除基础表型评估外，还结合组学或通路分析解释机制层面的变化。')
    else:
        bullets.append('研究方法：综合行为、病理、生化或分子实验结果，构建较完整的证据链。')
    bullets.append('主要发现：原文结果页将优先基于各 Figure 所对应的结果模块逐页组织，不额外编造未出现的章节。')
    return bullets[:4]


def infer_background(title_en: str, abstract: str) -> List[Dict[str, Any]]:
    t = (title_en + ' ' + abstract).lower()
    background = []
    question = []
    if 'alzheimer' in t:
        background = ['阿尔茨海默病的核心特征包括 Aβ、Tau、神经炎症和神经退行性变。', '围绕疾病修饰而非单点对症改善，是当前研究的重要方向。']
    elif 'cancer' in t or 'tumor' in t:
        background = ['肿瘤进展受到增殖、转移、免疫微环境和治疗反应等多因素共同影响。', '寻找能够影响关键病理通路的干预手段具有潜在转化意义。']
    else:
        background = ['该研究聚焦特定疾病模型中的关键病理过程与干预效果。', '作者希望通过多维证据验证干预是否能改变疾病相关表型。']
    question = ['作者关注的核心问题是：该干预能否改善模型中的关键功能与病理结局。', '进一步问题是：这些表型变化背后是否存在可解释的机制链条。']
    return [{'heading': '背景', 'points': background}, {'heading': '科学问题', 'points': question}]


def infer_design_sections(fulltext: str, captions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    blob = (fulltext or '').lower()
    methods = []
    if re.search(r'morris water maze|y-?maze|open field', blob):
        methods.append('行为学评估：包括水迷宫、Y 迷宫或开放场等，用于分析学习记忆和活动能力。')
    if re.search(r'western blot|immunofluorescence|immunohistochemistry|elisa', blob):
        methods.append('病理与分子评估：结合 Western blot、免疫染色、ELISA 等方法验证关键病理变化。')
    if re.search(r'flow cytometry', blob):
        methods.append('细胞层面评估：通过流式细胞术分析特定细胞群或免疫浸润变化。')
    if re.search(r'proteom|gsea|kegg|go analysis|rna-seq|sequencing', blob):
        methods.append('机制补充：结合组学和通路分析解释整体分子网络变化。')
    if not methods:
        methods.append('综合论文中的关键实验手段，对功能、病理和机制层面进行联合评估。')
    model_points = []
    if re.search(r'mice|mouse|murine', blob):
        model_points.append('以动物模型为主要研究对象，比较干预组与对照组的差异。')
    elif re.search(r'patients|clinical|trial', blob):
        model_points.append('以患者样本或临床研究对象为主，围绕干预前后或组间差异开展比较。')
    else:
        model_points.append('围绕论文所采用的核心模型和干预条件组织研究设计。')
    return [
        {'heading': '模型与分组', 'points': model_points},
        {'heading': '评估维度', 'points': methods}
    ]


def build_result_bullets(cap: Dict[str, Any], fallback_title: str) -> List[str]:
    title = first_nonempty(cap.get('matched_result_title_zh'), cap.get('matched_result_title'), fallback_title)
    pts = [p.strip() for p in (cap.get('explanation_points') or []) if str(p).strip()]
    bullets = []
    if title:
        bullets.append(sentence(f'本页围绕“{title}”这一结果模块展开。'))
    for p in pts[:2]:
        cleaned = re.sub(r'^(对应结果模块|实验信息|主要结果|结果解读)：', '', p).strip()
        if cleaned:
            bullets.append(sentence(cleaned))
    if not bullets:
        bullets = [sentence('本页展示原文关键结果图，并结合对应结果段落进行中文讲解。')]
    while len(bullets) < 3:
        bullets.append('该结果与正文描述一致，可用于支撑作者在本节提出的核心结论。')
    return bullets[:3]


def build_strengths(captions: List[Dict[str, Any]], fulltext: str) -> List[Dict[str, Any]]:
    blob = ' '.join((c.get('matched_result_title') or '') for c in captions).lower() + ' ' + fulltext.lower()
    items = [
        {'heading': '证据链较完整', 'points': ['论文通过多种实验层面交叉验证核心结论，而不是仅依赖单一指标。']},
    ]
    if re.search(r'proteom|rna-seq|sequencing|gsea|kegg', blob):
        items.append({'heading': '机制分析较深入', 'points': ['除表型结果外，还结合组学或通路分析，增强了结果解释力。']})
    if re.search(r'mice|mouse|animal', blob) and re.search(r'behavior|maze|open field', blob):
        items.append({'heading': '功能结局明确', 'points': ['论文同时报告功能表型与组织/分子层结果，使结论更具说服力。']})
    items.append({'heading': '图像证据丰富', 'points': ['原文提供多张关键 Figure，适合按证据链组织为组会汇报。']})
    return items[:3]


def build_limitations(fulltext: str) -> List[Dict[str, Any]]:
    blob = fulltext.lower()
    points = [{'heading': '外推性', 'points': ['研究结论首先适用于本文所采用的模型、样本类型和实验条件，推广时仍需谨慎。']}]
    if re.search(r'mice|mouse|animal', blob):
        points.append({'heading': '模型限制', 'points': ['动物模型结果与真实临床疾病之间仍存在差异，后续需要更多临床或人源证据支持。']})
    else:
        points.append({'heading': '机制深度', 'points': ['虽然论文给出了关键关联结果，但仍需进一步研究明确更直接的因果链条。']})
    points.append({'heading': '转化思考', 'points': ['干预效果、适用人群与长期安全性等问题，仍需要在后续研究中继续验证。']})
    return points[:3]


def build_takeaway(title_en: str, captions: List[Dict[str, Any]], fulltext: str) -> List[str]:
    blob = ' '.join((c.get('matched_result_title') or '') + ' ' + (c.get('explanation_cn') or '') for c in captions)
    items = [
        '论文通过原文关键 Figure 展示了干预对核心功能或病理结局的影响，而不是只停留在单一观察指标。',
        '结果页整体提示：功能变化、病理改变和机制线索之间存在可串联的证据链。',
        '这项研究为后续机制研究、转化验证或同类干预策略提供了可参考的证据基础。'
    ]
    if 'lymphocyte' in blob.lower() or 'immune' in blob.lower():
        items[1] = '结果页整体提示：免疫调节、病理缓解与功能改善之间存在可串联的证据链。'
    return items


def build_slides(meta_path: Path, fulltext_path: Path, figures_path: Path, captions_path: Path, search_item: Dict[str, Any], out_dir: Path) -> Path:
    meta = read_json(meta_path)
    fulltext = fulltext_path.read_text(encoding='utf-8')
    figures = read_json(figures_path)
    captions = read_json(captions_path)
    fig_map = {f['figure_ref']: f for f in figures if isinstance(f, dict) and f.get('figure_ref') and f.get('url')}
    has_original_figures = bool(fig_map)
    authors = meta.get('authors') or parse_authors_from_fulltext(fulltext)
    journal, year = detect_journal_year(search_item, meta)
    title_en = meta.get('title_en') or search_item.get('title') or ''
    title_zh = choose_title_zh(title_en, meta, fulltext)
    abstract = str(search_item.get('abstract') or '')

    slides: List[Dict[str, Any]] = []
    author_mode = 'first_et_al'
    slides.append({
        'slide_type': 'title',
        'title': title_zh,
        'subtitle': title_en,
        'bullets': [
            author_display(authors, author_mode),
            f'{journal}，{year}'.strip('，'),
            '基于原文全文与 Figure 自动生成',
            '按背景—问题—设计—结果链—总结的顺序组织汇报'
        ]
    })
    slides.append({
        'slide_type': 'overview',
        'title': '全文概述',
        'bullets': infer_general_overview(title_en, abstract, captions, fulltext)
    })
    slides.append({
        'slide_type': 'background',
        'title': '研究背景与科学问题',
        'sections': infer_background(title_en, abstract)
    })
    slides.append({
        'slide_type': 'design',
        'title': '研究设计与技术路线',
        'sections': infer_design_sections(fulltext, captions)
    })

    used_refs = set()
    for cap in captions:
        ref = cap.get('figure_ref')
        if not ref or ref in used_refs:
            continue
        fig = fig_map.get(ref)
        if not fig:
            continue
        used_refs.add(ref)
        source_result_subtitle = first_nonempty(cap.get('matched_result_title_zh'), cap.get('matched_result_title'))
        slide_title = compress_result_title(source_result_subtitle) if source_result_subtitle else compress_result_title(ref)
        explanation_text = first_nonempty(cap.get('explanation_cn'), '该图用于支撑原文对应结果模块的核心结论。')
        explanation_chunks = split_explanation_points(explanation_text, max_chunks=3) or [explanation_text]
        bullets = build_result_bullets(cap, slide_title)

        if len(explanation_chunks) == 1:
            page_explanation = explanation_chunks[0]
            if len(page_explanation) < 60:
                page_explanation += '；该图与正文结果段落相互印证，可帮助完整理解本节的核心结论。'
            slides.append({
                'slide_type': 'result',
                'title': slide_title,
                'source_result_subtitle': source_result_subtitle,
                'bullets': bullets,
                'figure_refs': [ref],
                'image_paths_or_urls': [fig['url']],
                'figure_explanations': [{'figure_ref': ref, 'explanation': page_explanation}],
                'speaker_intent': '围绕原文关键图讲清该结果模块的实验信息、主要观察和结果意义'
            })
        else:
            for idx, chunk in enumerate(explanation_chunks, start=1):
                page_title = slide_title if idx == 1 else f'{slide_title}（{idx}/{len(explanation_chunks)}）'
                page_bullets = bullets if idx == 1 else [sentence('本页继续拆解同一张原图中的另一层信息重点。'), sentence(chunk)]
                page_explanation = chunk
                if len(page_explanation) < 60:
                    page_explanation += '；这一页继续围绕同一张原图展开讲解，重点补充该结果在全文证据链中的位置，以及它对作者核心结论的支持意义。'
                slides.append({
                    'slide_type': 'result',
                    'title': page_title,
                    'source_result_subtitle': source_result_subtitle,
                    'bullets': page_bullets,
                    'figure_refs': [ref],
                    'image_paths_or_urls': [fig['url']],
                    'figure_explanations': [{'figure_ref': ref, 'explanation': page_explanation}],
                    'speaker_intent': '同一张原图分多页讲解，单页只展开一个重点，避免信息拥挤'
                })

    if not has_original_figures:
        fallback_results = []
        para_candidates = [p.strip() for p in re.split(r'\n\s*\n', fulltext) if len(p.strip()) > 180]
        for para in para_candidates:
            low = para.lower()
            if any(k in low for k in ['caprisa', 'health diplomacy', 'covid-19', 'tuberculosis', 'hiv prevention', 'science diplomacy', 'community engagement', 'global policy', 'funding', 'equity']):
                fallback_results.append(para)
            if len(fallback_results) >= 3:
                break
        if not fallback_results:
            fallback_results = para_candidates[:3]

        for idx, para in enumerate(fallback_results, start=1):
            clean = re.sub(r'\s+', ' ', para)
            title_guess = [
                ('health diplomacy', '健康外交与科学转化'),
                ('hiv prevention', 'HIV 预防的非洲经验'),
                ('tuberculosis', 'HIV/结核协同应对经验'),
                ('covid-19', 'COVID-19 期间的平台应变能力'),
                ('community engagement', '社区共创与研究落地'),
                ('funding', '科研韧性与资金挑战'),
                ('equity', '全球公平与南方科学声音'),
            ]
            slide_title = f'关键内容 {idx}'
            lower_clean = clean.lower()
            for key, val in title_guess:
                if key in lower_clean:
                    slide_title = val
                    break
            summary_sentences = re.split(r'(?<=[\.!?。])\s+', clean)
            bullets = []
            for sent in summary_sentences:
                sent = sent.strip()
                if len(sent) >= 25:
                    bullets.append(sentence(sent[:110].strip()))
                if len(bullets) >= 3:
                    break
            if not bullets:
                bullets = [sentence(clean[:110])]
            slides.append({
                'slide_type': 'result',
                'title': slide_title,
                'source_result_subtitle': slide_title,
                'bullets': bullets,
                'figure_refs': [],
                'image_paths_or_urls': [],
                'figure_explanations': [],
                'speaker_intent': '全文无原图时，提炼正文中的关键论点作为结果/讨论型汇报页面'
            })

    mech_cap = next((c for c in captions if re.search(r'mechan|pathway|proteom|network|summary|model|schema|overview|graphical abstract', (c.get('matched_result_title') or '') + ' ' + (c.get('original_caption') or ''), re.I)), None)
    if not mech_cap and captions:
        mech_cap = captions[0]
    mech_fig = fig_map.get(mech_cap.get('figure_ref')) if mech_cap else None
    mechanism_slide = {
        'slide_type': 'mechanism',
        'title': '机制主线总结',
        'bullets': [
            '围绕原文证据链，将干预、关键中间环节与最终功能/病理结局串联起来。',
            '优先依据机制图、组学图或系统层结果页来概括上下游关系。',
            '若原文没有独立机制图，则使用最能代表整体逻辑的一张 Figure 作为支撑。',
            '这一页服务于报告收束，帮助听众建立对全文主线的整体理解。'
        ],
        'figure_required': bool(has_original_figures),
        'figure_refs': [mech_cap['figure_ref']] if mech_fig and mech_cap else [],
        'image_paths_or_urls': [mech_fig['url']] if mech_fig else [],
        'figure_explanations': [{'figure_ref': mech_cap['figure_ref'], 'explanation': first_nonempty(mech_cap.get('explanation_cn'), '该图可作为机制或系统层证据，用于串联全文的核心逻辑。')}] if mech_fig and mech_cap else []
    }
    if not has_original_figures:
        mechanism_slide['figure_required'] = False
        mechanism_slide['bullets'] = [
            '这篇论文更接近讲座/综述型总结，核心主线不是单一实验机制图，而是“本地科学—伙伴合作—政策转化—公平可及”的连续链条。',
            '作者强调：高质量本地证据、长期科研平台、社区共创与全球协作，共同决定科学能否真正转化为公共卫生影响。',
            '从 HIV、结核到 COVID-19，CAPRISA 的经验说明：科学韧性本身就是健康外交能力的一部分。',
            '因此，本页以概念机制链替代原图机制页，用于帮助听众抓住全文主旨。'
        ]
    slides.append(mechanism_slide)
    slides.append({'slide_type': 'strengths', 'title': '文章优点', 'sections': build_strengths(captions, fulltext)})
    slides.append({'slide_type': 'limitations', 'title': '局限性与转化思考', 'sections': build_limitations(fulltext)})
    slides.append({'slide_type': 'takeaway', 'title': 'Take-home message', 'bullets': build_takeaway(title_en, captions, fulltext)})
    slides.append({'slide_type': 'closing', 'title': '谢谢！感谢批评指正！', 'bullets': ['Questions & Discussion']})

    deck = {
        'title': title_en,
        'paper_meta': {
            'title_en': title_en,
            'title_zh': title_zh,
            'authors': authors,
            'author_display': author_display(authors, author_mode),
            'author_display_mode': author_mode,
            'first_author': authors[0] if authors else '',
            'corresponding_author': meta.get('corresponding_author') or '',
            'journal': journal,
            'year': year,
            'doi': meta.get('doi') or '',
            'pmid': meta.get('pmid') or '',
            'source_id': str(meta.get('source_id') or search_item.get('id') or '')
        },
        'slides': slides
    }
    output = out_dir / 'slides.json'
    write_json(output, deck)
    return output


def enforce_figures(slides_json: Path, out_dir: Path) -> Path:
    output = out_dir / 'figure_check.json'
    script = script_dir() / 'enforce_figure_requirements.py'
    cp = run(['python3', str(script), '--slides-json', str(slides_json), '--output', str(output)], check=False)
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    data = read_json(output)
    if not data.get('ok'):
        raise RuntimeError(FAIL)
    return output


def render_pptx(slides_json: Path, out_dir: Path, output_path: Optional[Path]) -> Path:
    script = script_dir() / 'render_pptx_from_slides.js'
    cmd = ['node', str(script), '--slides-json', str(slides_json), '--assets-dir', str(out_dir / 'assets')]
    if output_path:
        cmd += ['--output', str(output_path)]
    cp = run(cmd, check=False)
    if cp.returncode != 0:
        raise RuntimeError(cp.stderr.strip() or cp.stdout.strip() or FAIL)
    path = Path((cp.stdout or '').strip().splitlines()[-1].strip())
    if not path.exists():
        raise RuntimeError(FAIL)
    return path


def main() -> None:
    ap = argparse.ArgumentParser(description='One-shot complete pipeline for research-paper-to-ppt-v2')
    ap.add_argument('--title', required=True, help='完整英文标题')
    ap.add_argument('--work-dir', required=True, help='中间文件输出目录')
    ap.add_argument('--output', help='最终 PPTX 输出路径，可选')
    args = ap.parse_args()

    work_dir = Path(args.work_dir).resolve()
    work_dir.mkdir(parents=True, exist_ok=True)
    output_path = Path(args.output).resolve() if args.output else None

    try:
        debug_log(work_dir, 'start', f'output={output_path}')
        search_item = search_paper(args.title, work_dir)
        debug_log(work_dir, 'search.ok', json.dumps(search_item, ensure_ascii=False)[:1000])
        doc_id = str(search_item.get('id') or '')
        if not doc_id:
            raise RuntimeError(FAIL)
        meta, fulltext, figures, _debug = fetch_fullpaper(doc_id, work_dir)
        debug_log(work_dir, 'fetch.ok', f'meta={meta.exists()} fulltext={fulltext.exists()} figures={figures.exists()} debug={_debug.exists()}')
        captions = extract_captions(fulltext, figures, work_dir)
        validate_inputs(meta, fulltext, figures, work_dir)
        build_bundle(meta, fulltext, figures, work_dir)
        slides_json = build_slides(meta, fulltext, figures, captions, search_item, work_dir)
        enforce_figures(slides_json, work_dir)
        pptx_path = render_pptx(slides_json, work_dir, output_path)
        print(str(pptx_path))
    except Exception as e:
        debug_log(work_dir, 'fatal', repr(e))
        print(FAIL)
        sys.exit(1)


if __name__ == '__main__':
    main()


path: scripts/enforce_figure_requirements.py
content: #!/usr/bin/env python3
"""Enforce conditional figure requirements for PPT generation.

Fail when:
- the paper has original figures, but result/mechanism slides that should use them do not
- a slide uses an original figure but lacks explanation / explanation is too weak

Allow when:
- the full paper genuinely has no extractable original figures, in which case text-only slides are allowed

Input bundle shape is flexible but expects a slides[] array.
Output JSON: {ok, message, reasons}
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any, Dict, List

FAIL = "检索失败，无法生成"
RESULT_TYPES = {"result"}
STRICT_VISUAL_TYPES = {"mechanism"}


def read_json(path: str) -> Any:
    return json.loads(Path(path).read_text(encoding="utf-8"))


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--slides-json", required=True)
    ap.add_argument("--output", required=True)
    args = ap.parse_args()

    data = read_json(args.slides_json)
    slides = data.get("slides", []) if isinstance(data, dict) else []
    reasons: List[str] = []
    if not slides:
        reasons.append("缺少幻灯片结构")

    has_any_original_figures = False
    for slide in slides:
        if slide.get("figure_refs") or slide.get("image_paths_or_urls"):
            has_any_original_figures = True
            break

    for idx, slide in enumerate(slides, start=1):
        st = str(slide.get("slide_type", "")).lower()
        images = slide.get("image_paths_or_urls") or []
        fig_refs = slide.get("figure_refs") or []
        explanations = slide.get("figure_explanations") or []
        uses_figure = bool(images or fig_refs)
        figure_required = bool(slide.get("figure_required", False))

        if st in RESULT_TYPES and has_any_original_figures:
            if not images or not fig_refs:
                reasons.append(f"第{idx}页结果页缺少原文图片")

        if st in STRICT_VISUAL_TYPES and figure_required:
            if not images or not fig_refs:
                reasons.append(f"第{idx}页机制页被标记为必须配原图，但未嵌入原文图片")

        if uses_figure:
            if not explanations:
                reasons.append(f"第{idx}页使用原文图片但缺少图片解释")
            else:
                weak = []
                for e in explanations:
                    text = str((e or {}).get("explanation", "")).strip()
                    if len(text) < 24:
                        weak.append(text)
                if weak:
                    reasons.append(f"第{idx}页图片解释过短，无法支撑讲解")

    result = {
        "ok": not reasons,
        "message": "OK" if not reasons else FAIL,
        "reasons": reasons,
    }
    Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")


if __name__ == "__main__":
    main()


path: scripts/extract_figures_and_captions.py
content: #!/usr/bin/env python3
"""Extract original figure references, URLs, and presentation-ready Chinese explanations.

Policy:
- Prefer original figure caption content.
- Convert English caption fragments into concise Chinese presentation notes.
- Use matching Results subsection mainly for title mapping and minimal contextual补充.
- Keep explanations close to the paper wording instead of over-interpreting.
"""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path
from typing import Any, Dict, List

FIG_PAT = re.compile(
    r"(?:^|\n)\s*(?:!\[\]\((?P<url>https?://[^\)]+)\)\s*)?"
    r"(?P<label>Fig\.?|Figure|Table)\s*(?P<num>\d+[A-Za-z]?)\.?(?P<rest>.*?)"
    r"(?=(?:\n\s*!\[\]\(|\n\s*(?:Fig\.?|Figure|Table)\s*\d+[A-Za-z]?\.?|\Z))",
    re.I | re.S,
)
RESULTS_SECTION_PAT = re.compile(r"#\s*3\.[\s\S]+?(?=#\s*4\.|\Z)", re.I)
RESULT_SUBSECTION_PAT = re.compile(
    r"#\s*3\.(?P<num>\d+)\.\s*(?P<title>.+?)\n(?P<body>[\s\S]*?)(?=(?:#\s*3\.\d+\.|#\s*4\.|\Z))",
    re.I,
)


def read_text(path: str) -> str:
    return Path(path).read_text(encoding="utf-8")


def read_json(path: str) -> Any:
    return json.loads(read_text(path))


def clean(s: str) -> str:
    s = s or ""
    s = re.sub(r"\$[^$]*\$", " ", s)
    s = re.sub(r"\\[A-Za-z]+\s*", " ", s)
    s = re.sub(r"\{[^{}]*\}", " ", s)
    s = re.sub(r"\[[0-9,–\- ]+\]", " ", s)
    s = re.sub(r"\(\s*[^()]*?(?:USA|UK|China|Abcam|Cell Signaling|Invitrogen|MilliporeSigma|Sigma|Thermo Fisher)[^()]*\)", " ", s, flags=re.I)
    s = re.sub(r"#\s*\d+(?:\.\d+)?\.? .*", " ", s)
    s = re.sub(r"#\s*\d+(?:\.\d+)?\.?.*", " ", s)
    s = re.sub(r"\s+", " ", s).strip()
    return s


def truncate_caption(s: str, max_len: int = 360) -> str:
    s = clean(s)
    if len(s) <= max_len:
        return s
    cut = s[:max_len]
    for sep in ['. ', '。']:
        if sep in cut:
            cut = cut[:cut.rfind(sep) + 1]
    return cut.strip()


def get_results_subsections(md: str) -> Dict[str, Dict[str, str]]:
    m = RESULTS_SECTION_PAT.search(md)
    text = m.group(0) if m else md
    out: Dict[str, Dict[str, str]] = {}
    for sm in RESULT_SUBSECTION_PAT.finditer(text):
        out[sm.group('num')] = {
            'title': clean(sm.group('title')),
            'body': clean(sm.group('body')),
        }
    return out


def infer_result_section(fig_num: str, results_map: Dict[str, Dict[str, str]]) -> Dict[str, str]:
    fig_num_plain = re.sub(r'[^0-9]', '', str(fig_num))
    if not fig_num_plain:
        return {}
    direct_patterns = [
        rf"\bFig\.?\s*{fig_num_plain}(?:[A-Z]|\b)",
        rf"\bFigure\s*{fig_num_plain}(?:[A-Z]|\b)",
    ]
    for _, sec in results_map.items():
        body = sec.get('body', '')
        if any(re.search(p, body, re.I) for p in direct_patterns):
            return sec
    return results_map.get(fig_num_plain, {})


def zh_title(text: str) -> str:
    text = clean(text)
    if not text:
        return ''
    mapping = [
        (r'learning and memory|behavioral deficits|cognitive function', '行为学与认知功能改善'),
        (r'lymphocyte infiltration', '脑内淋巴细胞浸润减少'),
        (r'proteomic', '蛋白组学提示 AD 相关通路被抑制'),
        (r'amyloid|brain deposition', 'Aβ/淀粉样病理减轻'),
        (r'tau', 'Tau 病理缓解'),
        (r'inflammation', '神经炎症减轻'),
        (r'neurodegeneration|synaptic|neuronal networks', '神经元与突触损伤改善'),
        (r'mechanism', '机制总结'),
    ]
    lower = text.lower()
    for pat, rep in mapping:
        if re.search(pat, lower):
            return rep
    return text


def normalize_en_fragment(text: str) -> str:
    text = clean(text)
    if not text:
        return ''
    text = re.sub(r'^[A-Z]\s*[\.,:]\s*', '', text)
    text = re.sub(r'\b(?:representative|tracing graph|images?|photographs?)\b', ' ', text, flags=re.I)
    text = re.sub(r'\b(?:using|used to|after treatment|in the brains? of|in brains? of|in mouse brain|of APP/PS1 mice|of mice|in APP/PS1 mice)\b', ' ', text, flags=re.I)
    text = re.sub(r'\s+', ' ', text).strip(' ,;:.')
    return text


def title_to_cn(text: str) -> str:
    t = clean(text).lower()
    mapping = [
        (r'behavioral deficits|learning and memory|cognitive function', '行为学与认知功能改善'),
        (r'lymphocyte infiltration', '脑内淋巴细胞浸润减少'),
        (r'proteomic', '蛋白组学提示 AD 相关通路被抑制'),
        (r'brain deposition|amyloid', 'Aβ 沉积减轻'),
        (r'tau hyperphosphorylation|tau', 'Tau 过磷酸化减轻'),
        (r'neuroinflammation', '神经炎症减轻'),
        (r'integrity of neuronal networks|neurodegeneration|synaptic', '神经元与突触损伤改善'),
    ]
    for pat, rep in mapping:
        if re.search(pat, t):
            return rep
    return zh_title(text) or clean(text)


METHOD_HINTS = [
    (r'morris water maze', '采用 Morris 水迷宫评估学习记忆'),
    (r'open field', '采用开放场实验评估自主活动'),
    (r'y-?maze', '采用 Y 迷宫评估空间工作记忆'),
    (r'flow cytometry', '采用流式细胞术检测相关细胞群'),
    (r'western blot', '采用 Western blot 检测相关蛋白表达'),
    (r'immunohistochemical|immunohistochemistry', '采用免疫组化评估病理变化'),
    (r'immunofluorescence', '采用免疫荧光评估组织信号变化'),
    (r'elisa', '采用 ELISA 检测相关分子水平'),
    (r'proteomic|gsea|kegg|go analysis', '结合蛋白组学与通路分析评估整体分子变化'),
]

OBS_HINTS = [
    (r'behavioral deficits|learning and memory|cognitive function', '结果提示芬戈莫德可改善 APP/PS1 小鼠的学习记忆与认知表现'),
    (r'b lymphocytes|t lymphocytes|lymphocyte infiltration', '结果提示芬戈莫德减少外周淋巴细胞向脑内浸润'),
    (r'proteomic|downregulation of proteins|ad-associated pathways', '结果提示多条 AD 与免疫相关通路整体下调'),
    (r'amyloid|6e10|thioflavin|congo red|aβ40|aβ42|deposition', '结果提示脑内 Aβ 斑块负荷及淀粉样沉积下降'),
    (r'bace1|ctf-β|ctf-α|app metabolites|amyloidopathogenic', '结果提示 APP 加工偏向非淀粉样途径，Aβ 生成受到抑制'),
    (r'tau hyperphosphorylation|pt231|ser404|thr231|ser199|thr181', '结果提示 Tau 过磷酸化水平下降'),
    (r'gfap|cd68|tnf-α|tnf-alpha|neuroinflammation', '结果提示胶质活化和炎症水平下降'),
    (r'map2|caspase-3|neun|vamp1|snap25|psd95|psd93|neuronal networks', '结果提示神经元损伤、凋亡和突触损害得到缓解'),
]


def infer_method_text(*texts: str) -> str:
    blob = ' '.join(clean(t).lower() for t in texts if t)
    for pat, rep in METHOD_HINTS:
        if re.search(pat, blob):
            return rep
    return '该图展示作者用于验证该结论的关键实验结果'


def infer_observation_text(*texts: str) -> str:
    blob = ' '.join(clean(t).lower() for t in texts if t)
    for pat, rep in OBS_HINTS:
        if re.search(pat, blob):
            return rep
    return '图中比较了给药组与对照组的关键差异，并支持该部分核心结论'


def split_sentences(text: str) -> List[str]:
    text = clean(text)
    if not text:
        return []
    parts = re.split(r'(?<=[\.;])\s+|(?<=。)|(?<=；)', text)
    return [clean(p).strip('.;') for p in parts if clean(p)]


def zh_explain(caption: str, ref: str, result_title: str = '', result_body: str = '') -> List[str]:
    caption = normalize_en_fragment(caption)
    result_title = clean(result_title)
    result_body = normalize_en_fragment(result_body)
    points: List[str] = []

    module_cn = title_to_cn(result_title) if result_title else ''
    if module_cn:
        points.append(f'对应结果模块：{module_cn}。')

    points.append(f'实验信息：{infer_method_text(caption, result_body, result_title)}。')
    points.append(f'主要结果：{infer_observation_text(caption, result_body, result_title)}。')

    if result_body:
        short_body = truncate_caption(result_body, max_len=90)
        points.append(f'结果解读：该图与正文结果一致，支持作者在本节提出的核心结论。')
    else:
        points.append('结果解读：该图用于支撑本节核心结论，并与全文证据链保持一致。')

    cleaned = []
    for p in points:
        p = re.sub(r'\s+', ' ', p).strip()
        p = re.sub(r'。+', '。', p)
        p = re.sub(r'：\s*。', '：', p)
        if p and p not in cleaned:
            cleaned.append(p)
    return cleaned[:4]


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument('--fulltext', required=True)
    ap.add_argument('--figures', required=True)
    ap.add_argument('--output', required=True)
    args = ap.parse_args()

    md = read_text(args.fulltext)
    figures = read_json(args.figures)
    results_map = get_results_subsections(md)

    by_url: Dict[str, Dict[str, Any]] = {}
    for f in figures:
        if isinstance(f, dict) and f.get('url'):
            by_url[f['url']] = dict(f)

    found: List[Dict[str, Any]] = []
    used_urls = set()

    for m in FIG_PAT.finditer(md):
        url = m.group('url')
        num = m.group('num')
        raw_rest = m.group('rest') or ''
        ref = f"Figure {num}" if m.group('label').lower().startswith(('fig', 'figure')) else f"Table {num}"
        item = None
        if url and url in by_url:
            item = dict(by_url[url])
            used_urls.add(url)
        else:
            for f in figures:
                if isinstance(f, dict) and clean(str(f.get('figure_ref', ''))).lower() == ref.lower():
                    item = dict(f)
                    break
        if not item:
            continue

        result_ctx = infer_result_section(str(num), results_map)
        result_title = result_ctx.get('title', '')
        result_body = result_ctx.get('body', '')
        item['figure_ref'] = item.get('figure_ref') or ref
        item['original_caption'] = truncate_caption(raw_rest)
        item['evidence_snippet'] = truncate_caption(result_body or raw_rest, max_len=420)
        item['matched_result_title'] = result_title
        item['matched_result_title_zh'] = zh_title(result_title)
        item['matched_result_snippet'] = truncate_caption(result_body, max_len=420)
        item['explanation_points'] = zh_explain(raw_rest, item['figure_ref'], result_title, result_body)
        item['explanation_cn'] = '；'.join(item['explanation_points'])
        found.append(item)

    for f in figures:
        if not isinstance(f, dict):
            continue
        url = f.get('url')
        if url and url in used_urls:
            continue
        found.append({
            **f,
            'original_caption': '',
            'evidence_snippet': '',
            'matched_result_title': '',
            'matched_result_title_zh': '',
            'matched_result_snippet': '',
            'explanation_points': [],
            'explanation_cn': ''
        })

    Path(args.output).write_text(json.dumps(found, ensure_ascii=False, indent=2), encoding='utf-8')


if __name__ == '__main__':
    main()


path: scripts/fetch_fullpaper.py
content: #!/usr/bin/env python3
"""Fetch full paper content from InfoX-Med full-paper API with token normalization and figure extraction.

Purpose:
- Work around environment tokens that may contain non-hex prefixes or wrong lengths.
- Try a small set of token candidates derived from INFOX_MED_TOKEN.
- Return normalized paper meta, full text, and a best-effort figure inventory.

This script is intentionally conservative:
- It does not fabricate figures.
- It only emits figure refs/URLs found in API payload fields or text.
- If the API cannot be read with a valid token, it exits non-zero.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from typing import Any, Dict, Iterable, List, Tuple
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError

API_BASE = os.environ.get("INFOX_MED_API_BASE", "http://60.205.166.229:9306").rstrip("/")
FAIL = "检索失败，无法生成"
TOKEN_RE = re.compile(r"^[0-9a-f]{32}\|\d{7}$")
URL_RE = re.compile(r"https?://[^\s\]\)>'\"]+")
FIG_RE = re.compile(r"\b(?:Figure|Fig\.?|Table)\s*([0-9]+[A-Za-z]?(?:\s*[A-Za-z]\s*[–\-]\s*[A-Za-z])?)", re.I)
IMG_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|webp|gif|tif|tiff|svg)(?:\?|$)", re.I)
PDF_RE = re.compile(r"\.pdf(?:\?|$)", re.I)


def normalize_token_candidates(raw: str) -> List[str]:
    raw = (raw or "").strip()
    if not raw:
        return []
    candidates: List[str] = []
    seen = set()

    def add(tok: str) -> None:
        tok = tok.strip()
        if tok and tok not in seen:
            seen.add(tok)
            candidates.append(tok)

    add(raw)
    if "|" in raw:
        left, right = raw.rsplit("|", 1)
        digits = re.sub(r"\D", "", right)
        hex32s = re.findall(r"[0-9a-f]{32}", left.lower())
        for hx in hex32s:
            if len(digits) >= 7:
                add(f"{hx}|{digits[:7]}")
        # Also try taking the last 32 hex chars from a mixed prefix.
        compact_hex = "".join(re.findall(r"[0-9a-f]", left.lower()))
        if len(compact_hex) >= 32 and len(digits) >= 7:
            add(f"{compact_hex[-32:]}|{digits[:7]}")
    return [t for t in candidates if TOKEN_RE.match(t)]


def http_get_json(url: str, token: str) -> Dict[str, Any]:
    req = Request(url, headers={"X-Token": token, "Accept": "application/json"})
    with urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode("utf-8", errors="replace"))


def fetch_by_doc_id(doc_id: str, tokens: Iterable[str]) -> Tuple[Dict[str, Any], str]:
    url = f"{API_BASE}/api/v1/paper/doc-id/{doc_id}?raw=true"
    last_err = None
    for token in tokens:
        try:
            payload = http_get_json(url, token)
            return payload, token
        except HTTPError as e:
            body = e.read().decode("utf-8", errors="replace") if hasattr(e, 'read') else str(e)
            last_err = f"HTTP {e.code}: {body}"
            continue
        except URLError as e:
            last_err = f"URL error: {e}"
            continue
        except Exception as e:
            last_err = str(e)
            continue
    raise SystemExit(last_err or FAIL)


def first_nonempty(d: Dict[str, Any], keys: List[str]) -> Any:
    for k in keys:
        v = d.get(k)
        if v not in (None, "", [], {}):
            return v
    return None


def collect_urls(obj: Any, out: List[str]) -> None:
    if isinstance(obj, dict):
        for v in obj.values():
            collect_urls(v, out)
    elif isinstance(obj, list):
        for v in obj:
            collect_urls(v, out)
    elif isinstance(obj, str):
        out.extend(URL_RE.findall(obj))


def infer_figure_ref(text: str, idx: int) -> str:
    m = FIG_RE.search(text or "")
    if m:
        return f"Figure {m.group(1).replace(' ', '')}"
    return f"Figure {idx}"


def extract_figures(rec: Dict[str, Any]) -> List[Dict[str, str]]:
    urls: List[str] = []
    collect_urls(rec, urls)
    figures: List[Dict[str, str]] = []
    seen = set()

    # First pass: structured figure/image-like fields if present
    for key, value in rec.items():
        lk = str(key).lower()
        if any(x in lk for x in ["figure", "fig", "image", "img", "graphic", "table"]):
            bucket: List[str] = []
            collect_urls(value, bucket)
            for u in bucket:
                if IMG_EXT_RE.search(u) and u not in seen:
                    seen.add(u)
                    figures.append({"figure_ref": infer_figure_ref(str(value), len(figures)+1), "url": u})

    # Second pass: any image-like URLs anywhere in payload
    for u in urls:
        if IMG_EXT_RE.search(u) and u not in seen:
            seen.add(u)
            figures.append({"figure_ref": f"Figure {len(figures)+1}", "url": u})

    # Deduplicate obvious PDF URLs away from figures.
    figures = [f for f in figures if not PDF_RE.search(f.get("url", ""))]
    return figures


def derive_title_from_markdown(md: str) -> str:
    for line in (md or "").splitlines():
        s = line.strip()
        if not s:
            continue
        if s.startswith("#"):
            return s.lstrip("#").strip()
    return ""


def build_meta(rec: Dict[str, Any]) -> Dict[str, Any]:
    md = str(first_nonempty(rec, ["md_content", "full_text", "fulltext", "content_md", "content"]) or "")
    authors_raw = first_nonempty(rec, ["authors", "author", "author_list"]) or ""
    authors = authors_raw if isinstance(authors_raw, list) else [a.strip() for a in str(authors_raw).split(",") if a.strip()]
    return {
        "title_en": first_nonempty(rec, ["title", "title_en"]) or derive_title_from_markdown(md),
        "title_zh": first_nonempty(rec, ["title_zh", "title_cn", "chinese_title"]) or "",
        "authors": authors,
        "first_author": authors[0] if authors else "",
        "corresponding_author": first_nonempty(rec, ["corresponding_author", "corr_author"]) or "",
        "journal": first_nonempty(rec, ["journal", "journal_name"]) or "",
        "year": str(first_nonempty(rec, ["year", "publish_year"]) or "")[:4],
        "doi": first_nonempty(rec, ["doi"]) or "",
        "pmid": str(first_nonempty(rec, ["pmid"]) or ""),
        "source_id": str(first_nonempty(rec, ["doc_id", "id", "_id"]) or ""),
        "pdf_url": first_nonempty(rec, ["pdf_url", "pdf", "pdf_link", "qiniu_url"]) or "",
    }


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--doc-id", required=True)
    ap.add_argument("--meta-out", required=True)
    ap.add_argument("--fulltext-out", required=True)
    ap.add_argument("--figures-out", required=True)
    ap.add_argument("--debug-out")
    args = ap.parse_args()

    raw = os.environ.get("INFOX_MED_TOKEN", "")
    tokens = normalize_token_candidates(raw)
    if not tokens:
        raise SystemExit("No valid token candidate derived from INFOX_MED_TOKEN")

    payload, used_token = fetch_by_doc_id(args.doc_id, tokens)
    data = payload.get("data") or []
    if not data:
        raise SystemExit(FAIL)
    rec = data[0]
    meta = build_meta(rec)
    fulltext = first_nonempty(rec, ["full_text", "fulltext", "body_markdown", "content_md", "content", "md_content"]) or ""
    fulltext_zh = first_nonempty(rec, ["full_text_zh", "fulltext_zh"]) or ""
    merged_fulltext = fulltext if len(fulltext) >= len(fulltext_zh) else fulltext_zh
    figures = extract_figures(rec)

    with open(args.meta_out, "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=2)
    with open(args.fulltext_out, "w", encoding="utf-8") as f:
        f.write(merged_fulltext)
    with open(args.figures_out, "w", encoding="utf-8") as f:
        json.dump(figures, f, ensure_ascii=False, indent=2)
    if args.debug_out:
        with open(args.debug_out, "w", encoding="utf-8") as f:
            json.dump({
                "used_token": used_token,
                "token_candidates": tokens,
                "payload_keys": list(rec.keys()),
                "figure_count": len(figures),
            }, f, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    main()


path: scripts/medical_search.py
content: #!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
InfoX-Med 高级搜索接口 — 支持关键词检索、指南搜索、系统评价/Meta分析、RCT 搜索。
通过命令行参数调用，输出 JSON 格式结果。
"""

import asyncio
import argparse
import json
import logging
import os
import re
import sys
from enum import Enum
from itertools import combinations
from typing import List, Dict, Any, Union

try:
    import aiohttp
except ImportError:  # optional dependency
    aiohttp = None

# ---------------------------------------------------------------------------
# 日志配置
# ---------------------------------------------------------------------------
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
API_URL = "https://api.infox-med.com/search/home/keywords"
API_TOKEN = os.environ.get("INFOX_MED_TOKEN", "e3f62087e126439aa12ad4637cf4f12b|1106970")
DEFAULT_PAGE_SIZE = 20
REQUEST_TIMEOUT = 20


# ---------------------------------------------------------------------------
# 枚举
# ---------------------------------------------------------------------------
class SearchField(Enum):
    """keywords 搜索支持的字段标签"""
    TITLE = "Title"
    ABSTRACT = "Abstract"
    TITLE_ABSTRACT = "Title/Abstract"
    MESH_TERMS = "MeSH Terms"
    JOURNAL = "Journal"
    AUTHOR = "Author"
    FIRST_AUTHOR = "First Author"
    LAST_AUTHOR = "Last Author"
    AFFILIATION = "Affiliation"
    FIRST_AUTHOR_AFFILIATION = "First Author Affiliation"
    LAST_AUTHOR_AFFILIATION = "Last Author Affiliation"
    CORPORATE_AUTHOR = "Corporate Author"


class LogicOp(Enum):
    """逻辑运算符"""
    AND = "AND"
    OR = "OR"
    NOT = "NOT"


class SortType(Enum):
    """排序规则"""
    IF = "docIf"
    PUBLISH_TIME = "docPublishTime"
    CITED_BY = "citedBy"
    RELEVANT = "relevant"


# ---------------------------------------------------------------------------
# 查询构造器
# ---------------------------------------------------------------------------
class QueryBuilder:
    """构建 InfoX-Med keywords 查询字符串"""

    @staticmethod
    def term(value: str, field: Union[SearchField, str] = None) -> str:
        """创建带字段标签的术语，如 Lung Cancer[Title]"""
        clean_val = str(value).replace('"', '\\"')
        field_str = field.value if isinstance(field, SearchField) else field
        if field_str:
            return f"{clean_val}[{field_str}]"
        return clean_val

    @staticmethod
    def combine(items: List[str], operator: LogicOp = LogicOp.AND) -> str:
        """用逻辑运算符连接多个查询片段，每个子条件用括号包裹"""
        valid = []
        for item in items:
            if not item:
                continue
            s = item.strip()
            if s.startswith("(") and s.endswith(")"):
                valid.append(s)
            else:
                valid.append(f"({s})")
        if not valid:
            return ""
        if len(valid) == 1:
            return valid[0]
        return f" {operator.value} ".join(valid)


def build_pairwise_combination_query(keywords: List[str], field: SearchField) -> str:
    """
    构建两两组合查询：((K1 AND K2) OR (K1 AND K3) OR (K2 AND K3))
    若仅 1 个关键词则直接返回该词的带字段形式。
    """
    terms = [QueryBuilder.term(k, field) for k in keywords]

    if len(terms) <= 1:
        return QueryBuilder.combine(terms, LogicOp.OR)

    and_groups = []
    for t1, t2 in combinations(terms, 2):
        group = QueryBuilder.combine([t1, t2], LogicOp.AND)
        if group:
            and_groups.append(f"({group})")

    combined = QueryBuilder.combine(and_groups, LogicOp.OR)
    return f"({combined})" if combined else ""


# ---------------------------------------------------------------------------
# 筛选构造器
# ---------------------------------------------------------------------------
class FilterBuilder:
    """构建 filter 字符串，格式: $$key$$val..."""

    def __init__(self):
        self.filters: List[str] = []

    def add_range(self, key: str, start: Any, end: Any) -> "FilterBuilder":
        self.filters.append(f"{key}$${start}$${end}")
        return self

    def add_value(self, key: str, value: Any) -> "FilterBuilder":
        self.filters.append(f"{key}$${value}")
        return self

    def add_options(self, key: str, values: List[Any]) -> "FilterBuilder":
        if values:
            joined = "$OR$".join(str(v) for v in values)
            self.filters.append(f"{key}$${joined}")
        return self

    def add_publish_time(self, start_date: str, end_date: str) -> "FilterBuilder":
        return self.add_range("doc_publish_time", start_date, end_date)

    def build(self) -> str:
        if not self.filters:
            return ""
        return "@@AND$$" + "@@AND$$".join(self.filters)


# ---------------------------------------------------------------------------
# 参数校验
# ---------------------------------------------------------------------------
VALID_FIELD_TAGS = {
    "Title", "Abstract", "Title/Abstract", "MeSH Terms",
    "Author", "Affiliation", "Journal", "First Author", "Last Author",
    "First Author Affiliation", "Last Author Affiliation", "Corporate Author",
}


def validate_search_params(query_string: str, filter_string: str = "") -> str | None:
    """校验搜索参数。返回错误信息字符串，或 None 表示通过。"""
    if not query_string or not query_string.strip():
        return "query_string 不能为空。"

    # 括号匹配
    if query_string.count("(") != query_string.count(")"):
        return (
            f"query_string 中括号不匹配 "
            f"(左 {query_string.count('(')} / 右 {query_string.count(')')})。"
        )

    # 字段标签校验
    tags = re.findall(r"\[(.*?)\]", query_string)
    if not tags:
        return "缺少字段限定符，例如 [Title]。"
    for tag in tags:
        if tag not in VALID_FIELD_TAGS:
            return (
                f"无效字段限定符 '[{tag}]'。"
                f"方括号内只能放字段名（如 [Title]），不要把关键词放进方括号。"
            )

    # 多条件间需要逻辑运算符
    if re.search(r"\]\s+[\"(]", query_string):
        logic_ops = (" AND ", " OR ", " NOT ")
        if not any(op in query_string for op in logic_ops):
            return "多个搜索条件之间缺少逻辑运算符 (AND/OR/NOT)。"

    # filter 校验
    if filter_string:
        if "$$" not in filter_string:
            return "filter_string 分隔符错误，请使用 '$$' 分隔键和值。"
        if "doc_publish_time" in filter_string:
            if not re.findall(r"\d{4}-\d{2}-\d{2}", filter_string):
                return "doc_publish_time 日期格式错误，请使用 YYYY-MM-DD。"

    return None


# ---------------------------------------------------------------------------
# 结果清洗
# ---------------------------------------------------------------------------
def _simplify_record(record: Dict[str, Any]) -> Dict[str, Any]:
    """保留核心字段并标准化命名"""
    return {
        "id": record.get("id"),
        "title": record.get("docTitle"),
        "abstract": record.get("docAbstract"),
        "authors": record.get("docAuthor"),
        "journal": record.get("docSourceJournal") or record.get("docSimpleJournal"),
        "publish_date": record.get("docPublishTime"),
        "impact_factor": record.get("docIf"),
        "publication_type": record.get("docPublishType"),
        "link": (
            f"https://www.infox-med.com/#/articleDetails?id={record['id']}"
            if record.get("id") else None
        ),
    }


# ---------------------------------------------------------------------------
# 核心搜索函数
# ---------------------------------------------------------------------------
async def search_infox_advanced(
    query_string: str,
    filter_string: str = "",
    sort_field: str = "relevant",
    page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
    """
    调用 InfoX-Med API 执行高级搜索。

    参数:
        query_string:  检索表达式，如 (Lung Cancer[Title]) AND (Immunotherapy[Title/Abstract])
        filter_string: 筛选条件，如 $$doc_publish_type$$Review
        sort_field:    排序字段 (relevant / docPublishTime / docIf / citedBy)
        page_size:     每页结果数

    返回:
        {"code": 200, "msg": "success", "records": [...]}
    """
    logger.info(f"搜索请求: query='{query_string}', filter='{filter_string}', sort='{sort_field}'")

    # 参数校验
    error = validate_search_params(query_string, filter_string)
    if error:
        logger.warning(f"参数校验失败: {error}")
        return {"code": 400, "msg": f"INPUT_VALIDATION_ERROR: {error}", "records": []}

    sort_val = sort_field.value if isinstance(sort_field, SortType) else sort_field
    if sort_val == "relevant":
        sort_val = ""

    payload = {
        "type": "doc",
        "pageNum": 1,
        "pageSize": page_size,
        "keywords": query_string,
        "filter": filter_string,
        "sort": sort_val,
    }
    headers = {
        "Content-Type": "application/json;charset=UTF-8",
        "Accept": "application/json, text/plain, */*",
        "Origin": "https://infox-med.com",
        "token": API_TOKEN,
    }

    try:
        if aiohttp is not None:
            timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
            async with aiohttp.ClientSession(timeout=timeout) as session:
                async with session.post(API_URL, json=payload, headers=headers) as resp:
                    resp.raise_for_status()
                    data = await resp.json()
        else:
            import urllib.request
            body = json.dumps(payload).encode("utf-8")
            req = urllib.request.Request(API_URL, data=body, headers=headers, method="POST")
            loop = asyncio.get_running_loop()
            def _do_request():
                with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
                    return json.loads(resp.read().decode("utf-8", errors="replace"))
            data = await loop.run_in_executor(None, _do_request)
        records_raw = (data.get("data") or {}).get("records") or []
        records = [_simplify_record(r) for r in records_raw]
        return {"code": 200, "msg": "success", "records": records}
    except Exception as e:
        logger.error(f"请求异常: {e}")
        return {"code": 500, "msg": str(e), "records": []}


# ---------------------------------------------------------------------------
# 高层搜索函数
# ---------------------------------------------------------------------------
async def search_chinese_guideline(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索中文指南（仅限中华系列期刊）"""
    keyword_query = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    query_string = f'{keyword_query} AND ("Zhonghua"[Journal])'
    filter_string = "$$doc_publish_type$$Guideline$OR$Practice Guideline"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


async def search_english_guideline(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索英文指南（排除中华系列期刊）"""
    keyword_query = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    query_string = f'{keyword_query} NOT ("Zhonghua"[Journal])'
    filter_string = "$$doc_publish_type$$Guideline$OR$Practice Guideline"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


async def search_systematic_meta(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索系统评价和 Meta 分析"""
    query_string = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    filter_string = "$$doc_publish_type$$Meta-Analysis$OR$Systematic Review"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    records = result.get("records", [])
    # 二次过滤：标题必须包含 meta-analysis 或 systematic review
    return [
        r for r in records
        if r.get("title") and (
            "meta-analysis" in r["title"].lower()
            or "systematic review" in r["title"].lower()
        )
    ]


async def search_rct(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索随机对照试验 (RCT)"""
    query_string = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    filter_string = "$$doc_publish_type$$Randomized Controlled Trial"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


# ---------------------------------------------------------------------------
# 综合搜索：并行执行 4 类搜索
# ---------------------------------------------------------------------------
async def search_all(keywords: List[str]) -> Dict[str, List[Dict[str, Any]]]:
    """并行搜索中文指南、英文指南、系统评价/Meta分析、RCT，返回分类结果。"""
    cn_guide, en_guide, sys_meta, rct = await asyncio.gather(
        search_chinese_guideline(keywords),
        search_english_guideline(keywords),
        search_systematic_meta(keywords),
        search_rct(keywords),
    )
    return {
        "chinese_guideline": cn_guide,
        "english_guideline": en_guide,
        "systematic_meta": sys_meta,
        "rct": rct,
    }


# ---------------------------------------------------------------------------
# 自由搜索（直接传 query_string + filter_string）
# ---------------------------------------------------------------------------
async def search_free(
    query_string: str,
    filter_string: str = "",
    sort_field: str = "relevant",
) -> List[Dict[str, Any]]:
    """自由搜索：直接使用检索表达式和筛选条件。"""
    result = await search_infox_advanced(
        query_string=query_string,
        filter_string=filter_string,
        sort_field=sort_field,
    )
    return result.get("records", [])


# ---------------------------------------------------------------------------
# CLI 入口
# ---------------------------------------------------------------------------
def build_cli_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="InfoX-Med 医学文献搜索")
    sub = parser.add_subparsers(dest="command", help="搜索模式")

    # 模式 1：按关键词并行搜 4 类库
    p_all = sub.add_parser("all", help="并行搜索 4 类文献库 (中文指南/英文指南/系统评价/RCT)")
    p_all.add_argument("keywords", nargs="+", help="搜索关键词列表")

    # 模式 2：仅搜指定类别
    for name, desc in [
        ("chinese-guideline", "搜索中文指南"),
        ("english-guideline", "搜索英文指南"),
        ("systematic-meta", "搜索系统评价/Meta分析"),
        ("rct", "搜索随机对照试验 (RCT)"),
    ]:
        p = sub.add_parser(name, help=desc)
        p.add_argument("keywords", nargs="+", help="搜索关键词列表")

    # 模式 3：自由搜索
    p_free = sub.add_parser("free", help="自由搜索（直接传检索表达式）")
    p_free.add_argument("--query", required=True, help="检索表达式 (query_string)")
    p_free.add_argument("--filter", default="", help="筛选条件 (filter_string)")
    p_free.add_argument(
        "--sort", default="relevant",
        choices=["relevant", "docPublishTime", "docIf", "citedBy"],
        help="排序方式",
    )

    # 通用参数
    parser.add_argument("--output", "-o", help="结果输出到 JSON 文件（不指定则输出到 stdout）")

    return parser


async def async_main(args: argparse.Namespace):
    cmd = args.command
    if not cmd:
        print("请指定搜索模式。使用 --help 查看帮助。", file=sys.stderr)
        sys.exit(1)

    dispatch = {
        "all": lambda: search_all(args.keywords),
        "chinese-guideline": lambda: search_chinese_guideline(args.keywords),
        "english-guideline": lambda: search_english_guideline(args.keywords),
        "systematic-meta": lambda: search_systematic_meta(args.keywords),
        "rct": lambda: search_rct(args.keywords),
        "free": lambda: search_free(args.query, args.filter, args.sort),
    }

    result = await dispatch[cmd]()
    output = json.dumps(result, ensure_ascii=False, indent=2)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(output)
        logger.info(f"结果已写入 {args.output}")
    else:
        print(output)


def main():
    parser = build_cli_parser()
    args = parser.parse_args()
    asyncio.run(async_main(args))


if __name__ == "__main__":
    main()


path: scripts/medical_search_fallback_reference.py
content: #!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
InfoX-Med 高级搜索接口 — 支持关键词检索、指南搜索、系统评价/Meta分析、RCT 搜索。
通过命令行参数调用，输出 JSON 格式结果。
"""

import asyncio
import argparse
import json
import logging
import os
import re
import sys
from enum import Enum
from itertools import combinations
from typing import List, Dict, Any, Union

try:
    import aiohttp
except ImportError:  # optional dependency
    aiohttp = None

# ---------------------------------------------------------------------------
# 日志配置
# ---------------------------------------------------------------------------
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
API_URL = "https://api.infox-med.com/search/home/keywords"
API_TOKEN = os.environ.get("INFOX_MED_TOKEN", "e3f62087e126439aa12ad4637cf4f12b|1106970")
DEFAULT_PAGE_SIZE = 20
REQUEST_TIMEOUT = 20


# ---------------------------------------------------------------------------
# 枚举
# ---------------------------------------------------------------------------
class SearchField(Enum):
    """keywords 搜索支持的字段标签"""
    TITLE = "Title"
    ABSTRACT = "Abstract"
    TITLE_ABSTRACT = "Title/Abstract"
    MESH_TERMS = "MeSH Terms"
    JOURNAL = "Journal"
    AUTHOR = "Author"
    FIRST_AUTHOR = "First Author"
    LAST_AUTHOR = "Last Author"
    AFFILIATION = "Affiliation"
    FIRST_AUTHOR_AFFILIATION = "First Author Affiliation"
    LAST_AUTHOR_AFFILIATION = "Last Author Affiliation"
    CORPORATE_AUTHOR = "Corporate Author"


class LogicOp(Enum):
    """逻辑运算符"""
    AND = "AND"
    OR = "OR"
    NOT = "NOT"


class SortType(Enum):
    """排序规则"""
    IF = "docIf"
    PUBLISH_TIME = "docPublishTime"
    CITED_BY = "citedBy"
    RELEVANT = "relevant"


# ---------------------------------------------------------------------------
# 查询构造器
# ---------------------------------------------------------------------------
class QueryBuilder:
    """构建 InfoX-Med keywords 查询字符串"""

    @staticmethod
    def term(value: str, field: Union[SearchField, str] = None) -> str:
        """创建带字段标签的术语，如 Lung Cancer[Title]"""
        clean_val = str(value).replace('"', '\\"')
        field_str = field.value if isinstance(field, SearchField) else field
        if field_str:
            return f"{clean_val}[{field_str}]"
        return clean_val

    @staticmethod
    def combine(items: List[str], operator: LogicOp = LogicOp.AND) -> str:
        """用逻辑运算符连接多个查询片段，每个子条件用括号包裹"""
        valid = []
        for item in items:
            if not item:
                continue
            s = item.strip()
            if s.startswith("(") and s.endswith(")"):
                valid.append(s)
            else:
                valid.append(f"({s})")
        if not valid:
            return ""
        if len(valid) == 1:
            return valid[0]
        return f" {operator.value} ".join(valid)


def build_pairwise_combination_query(keywords: List[str], field: SearchField) -> str:
    """
    构建两两组合查询：((K1 AND K2) OR (K1 AND K3) OR (K2 AND K3))
    若仅 1 个关键词则直接返回该词的带字段形式。
    """
    terms = [QueryBuilder.term(k, field) for k in keywords]

    if len(terms) <= 1:
        return QueryBuilder.combine(terms, LogicOp.OR)

    and_groups = []
    for t1, t2 in combinations(terms, 2):
        group = QueryBuilder.combine([t1, t2], LogicOp.AND)
        if group:
            and_groups.append(f"({group})")

    combined = QueryBuilder.combine(and_groups, LogicOp.OR)
    return f"({combined})" if combined else ""


# ---------------------------------------------------------------------------
# 筛选构造器
# ---------------------------------------------------------------------------
class FilterBuilder:
    """构建 filter 字符串，格式: $$key$$val..."""

    def __init__(self):
        self.filters: List[str] = []

    def add_range(self, key: str, start: Any, end: Any) -> "FilterBuilder":
        self.filters.append(f"{key}$${start}$${end}")
        return self

    def add_value(self, key: str, value: Any) -> "FilterBuilder":
        self.filters.append(f"{key}$${value}")
        return self

    def add_options(self, key: str, values: List[Any]) -> "FilterBuilder":
        if values:
            joined = "$OR$".join(str(v) for v in values)
            self.filters.append(f"{key}$${joined}")
        return self

    def add_publish_time(self, start_date: str, end_date: str) -> "FilterBuilder":
        return self.add_range("doc_publish_time", start_date, end_date)

    def build(self) -> str:
        if not self.filters:
            return ""
        return "@@AND$$" + "@@AND$$".join(self.filters)


# ---------------------------------------------------------------------------
# 参数校验
# ---------------------------------------------------------------------------
VALID_FIELD_TAGS = {
    "Title", "Abstract", "Title/Abstract", "MeSH Terms",
    "Author", "Affiliation", "Journal", "First Author", "Last Author",
    "First Author Affiliation", "Last Author Affiliation", "Corporate Author",
}


def validate_search_params(query_string: str, filter_string: str = "") -> str | None:
    """校验搜索参数。返回错误信息字符串，或 None 表示通过。"""
    if not query_string or not query_string.strip():
        return "query_string 不能为空。"

    # 括号匹配
    if query_string.count("(") != query_string.count(")"):
        return (
            f"query_string 中括号不匹配 "
            f"(左 {query_string.count('(')} / 右 {query_string.count(')')})。"
        )

    # 字段标签校验
    tags = re.findall(r"\[(.*?)\]", query_string)
    if not tags:
        return "缺少字段限定符，例如 [Title]。"
    for tag in tags:
        if tag not in VALID_FIELD_TAGS:
            return (
                f"无效字段限定符 '[{tag}]'。"
                f"方括号内只能放字段名（如 [Title]），不要把关键词放进方括号。"
            )

    # 多条件间需要逻辑运算符
    if re.search(r"\]\s+[\"(]", query_string):
        logic_ops = (" AND ", " OR ", " NOT ")
        if not any(op in query_string for op in logic_ops):
            return "多个搜索条件之间缺少逻辑运算符 (AND/OR/NOT)。"

    # filter 校验
    if filter_string:
        if "$$" not in filter_string:
            return "filter_string 分隔符错误，请使用 '$$' 分隔键和值。"
        if "doc_publish_time" in filter_string:
            if not re.findall(r"\d{4}-\d{2}-\d{2}", filter_string):
                return "doc_publish_time 日期格式错误，请使用 YYYY-MM-DD。"

    return None


# ---------------------------------------------------------------------------
# 结果清洗
# ---------------------------------------------------------------------------
def _simplify_record(record: Dict[str, Any]) -> Dict[str, Any]:
    """保留核心字段并标准化命名"""
    return {
        "id": record.get("id"),
        "title": record.get("docTitle"),
        "abstract": record.get("docAbstract"),
        "authors": record.get("docAuthor"),
        "journal": record.get("docSourceJournal") or record.get("docSimpleJournal"),
        "publish_date": record.get("docPublishTime"),
        "impact_factor": record.get("docIf"),
        "publication_type": record.get("docPublishType"),
        "link": (
            f"https://www.infox-med.com/#/articleDetails?id={record['id']}"
            if record.get("id") else None
        ),
    }


# ---------------------------------------------------------------------------
# 核心搜索函数
# ---------------------------------------------------------------------------
async def search_infox_advanced(
    query_string: str,
    filter_string: str = "",
    sort_field: str = "relevant",
    page_size: int = DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
    """
    调用 InfoX-Med API 执行高级搜索。

    参数:
        query_string:  检索表达式，如 (Lung Cancer[Title]) AND (Immunotherapy[Title/Abstract])
        filter_string: 筛选条件，如 $$doc_publish_type$$Review
        sort_field:    排序字段 (relevant / docPublishTime / docIf / citedBy)
        page_size:     每页结果数

    返回:
        {"code": 200, "msg": "success", "records": [...]}
    """
    logger.info(f"搜索请求: query='{query_string}', filter='{filter_string}', sort='{sort_field}'")

    # 参数校验
    error = validate_search_params(query_string, filter_string)
    if error:
        logger.warning(f"参数校验失败: {error}")
        return {"code": 400, "msg": f"INPUT_VALIDATION_ERROR: {error}", "records": []}

    sort_val = sort_field.value if isinstance(sort_field, SortType) else sort_field
    if sort_val == "relevant":
        sort_val = ""

    payload = {
        "type": "doc",
        "pageNum": 1,
        "pageSize": page_size,
        "keywords": query_string,
        "filter": filter_string,
        "sort": sort_val,
    }
    headers = {
        "Content-Type": "application/json;charset=UTF-8",
        "Accept": "application/json, text/plain, */*",
        "Origin": "https://infox-med.com",
        "token": API_TOKEN,
    }

    try:
        if aiohttp is not None:
            timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
            async with aiohttp.ClientSession(timeout=timeout) as session:
                async with session.post(API_URL, json=payload, headers=headers) as resp:
                    resp.raise_for_status()
                    data = await resp.json()
        else:
            import urllib.request
            body = json.dumps(payload).encode("utf-8")
            req = urllib.request.Request(API_URL, data=body, headers=headers, method="POST")
            loop = asyncio.get_running_loop()
            def _do_request():
                with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
                    return json.loads(resp.read().decode("utf-8", errors="replace"))
            data = await loop.run_in_executor(None, _do_request)
        records_raw = (data.get("data") or {}).get("records") or []
        records = [_simplify_record(r) for r in records_raw]
        return {"code": 200, "msg": "success", "records": records}
    except Exception as e:
        logger.error(f"请求异常: {e}")
        return {"code": 500, "msg": str(e), "records": []}


# ---------------------------------------------------------------------------
# 高层搜索函数
# ---------------------------------------------------------------------------
async def search_chinese_guideline(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索中文指南（仅限中华系列期刊）"""
    keyword_query = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    query_string = f'{keyword_query} AND ("Zhonghua"[Journal])'
    filter_string = "$$doc_publish_type$$Guideline$OR$Practice Guideline"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


async def search_english_guideline(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索英文指南（排除中华系列期刊）"""
    keyword_query = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    query_string = f'{keyword_query} NOT ("Zhonghua"[Journal])'
    filter_string = "$$doc_publish_type$$Guideline$OR$Practice Guideline"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


async def search_systematic_meta(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索系统评价和 Meta 分析"""
    query_string = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    filter_string = "$$doc_publish_type$$Meta-Analysis$OR$Systematic Review"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    records = result.get("records", [])
    # 二次过滤：标题必须包含 meta-analysis 或 systematic review
    return [
        r for r in records
        if r.get("title") and (
            "meta-analysis" in r["title"].lower()
            or "systematic review" in r["title"].lower()
        )
    ]


async def search_rct(keywords: List[str]) -> List[Dict[str, Any]]:
    """搜索随机对照试验 (RCT)"""
    query_string = build_pairwise_combination_query(keywords, SearchField.TITLE_ABSTRACT)
    filter_string = "$$doc_publish_type$$Randomized Controlled Trial"
    result = await search_infox_advanced(query_string=query_string, filter_string=filter_string)
    return result.get("records", [])


# ---------------------------------------------------------------------------
# 综合搜索：并行执行 4 类搜索
# ---------------------------------------------------------------------------
async def search_all(keywords: List[str]) -> Dict[str, List[Dict[str, Any]]]:
    """并行搜索中文指南、英文指南、系统评价/Meta分析、RCT，返回分类结果。"""
    cn_guide, en_guide, sys_meta, rct = await asyncio.gather(
        search_chinese_guideline(keywords),
        search_english_guideline(keywords),
        search_systematic_meta(keywords),
        search_rct(keywords),
    )
    return {
        "chinese_guideline": cn_guide,
        "english_guideline": en_guide,
        "systematic_meta": sys_meta,
        "rct": rct,
    }


# ---------------------------------------------------------------------------
# 自由搜索（直接传 query_string + filter_string）
# ---------------------------------------------------------------------------
async def search_free(
    query_string: str,
    filter_string: str = "",
    sort_field: str = "relevant",
) -> List[Dict[str, Any]]:
    """自由搜索：直接使用检索表达式和筛选条件。"""
    result = await search_infox_advanced(
        query_string=query_string,
        filter_string=filter_string,
        sort_field=sort_field,
    )
    return result.get("records", [])


# ---------------------------------------------------------------------------
# CLI 入口
# ---------------------------------------------------------------------------
def build_cli_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="InfoX-Med 医学文献搜索")
    sub = parser.add_subparsers(dest="command", help="搜索模式")

    # 模式 1：按关键词并行搜 4 类库
    p_all = sub.add_parser("all", help="并行搜索 4 类文献库 (中文指南/英文指南/系统评价/RCT)")
    p_all.add_argument("keywords", nargs="+", help="搜索关键词列表")

    # 模式 2：仅搜指定类别
    for name, desc in [
        ("chinese-guideline", "搜索中文指南"),
        ("english-guideline", "搜索英文指南"),
        ("systematic-meta", "搜索系统评价/Meta分析"),
        ("rct", "搜索随机对照试验 (RCT)"),
    ]:
        p = sub.add_parser(name, help=desc)
        p.add_argument("keywords", nargs="+", help="搜索关键词列表")

    # 模式 3：自由搜索
    p_free = sub.add_parser("free", help="自由搜索（直接传检索表达式）")
    p_free.add_argument("--query", required=True, help="检索表达式 (query_string)")
    p_free.add_argument("--filter", default="", help="筛选条件 (filter_string)")
    p_free.add_argument(
        "--sort", default="relevant",
        choices=["relevant", "docPublishTime", "docIf", "citedBy"],
        help="排序方式",
    )

    # 通用参数
    parser.add_argument("--output", "-o", help="结果输出到 JSON 文件（不指定则输出到 stdout）")

    return parser


async def async_main(args: argparse.Namespace):
    cmd = args.command
    if not cmd:
        print("请指定搜索模式。使用 --help 查看帮助。", file=sys.stderr)
        sys.exit(1)

    dispatch = {
        "all": lambda: search_all(args.keywords),
        "chinese-guideline": lambda: search_chinese_guideline(args.keywords),
        "english-guideline": lambda: search_english_guideline(args.keywords),
        "systematic-meta": lambda: search_systematic_meta(args.keywords),
        "rct": lambda: search_rct(args.keywords),
        "free": lambda: search_free(args.query, args.filter, args.sort),
    }

    result = await dispatch[cmd]()
    output = json.dumps(result, ensure_ascii=False, indent=2)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(output)
        logger.info(f"结果已写入 {args.output}")
    else:
        print(output)


def main():
    parser = build_cli_parser()
    args = parser.parse_args()
    asyncio.run(async_main(args))


if __name__ == "__main__":
    main()


path: scripts/render_pptx_from_slides.js
content: #!/usr/bin/env node
const PptxGenJS = require('/root/.openclaw/workspace/node_modules/pptxgenjs');
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');

const C = {
  navy: '13293D', navy2: '1B3A57', blue: '2A628F', teal: '2CA6A4', gold: 'D5A44A',
  mint: 'DDF4F1', light: 'F5F8FC', white: 'FFFFFF', dark: '18222D', mid: '5C6773',
  line: 'D6E1EB', gold_bg: 'FBF7EE', gold2: 'ECD7A4'
};

const FONT = 'Microsoft YaHei';
const FONT_HEAD = 'Microsoft YaHei';
const SIZE = {
  headerTitle: 25,
  headerSubtitle: 10.5,
  pageNum: 10,
  titleZh: 27,
  titleEn: 17,
  titleMeta: 17.5,
  titleAuthors: 14.5,
  smallLabel: 12,
  smallText: 11,
  agendaIndex: 14,
  agendaTitle: 21,
  sectionHeading: 17.5,
  body: 16.5,
  bodyDense: 15,
  resultLabel: 19,
  resultCaption: 14,
  resultFigRef: 10.5,
  mechanismLabel: 18,
  mechanismNode: 14,
  mechanismExplain: 14,
  takeaway: 18,
  closingTitle: 30,
  closingSubtitle: 21,
  closingNote: 14.5,
  badge: 13
};

function softShadow() { return { type: 'outer', color: '000000', blur: 2, offset: 1, angle: 45, opacity: 0.12 }; }
function sanitize(name) {
  return String(name || 'output').replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
}
function parseArgs(argv) {
  const out = {};
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith('--')) out[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
  }
  return out;
}
function addBg(slide, dark = false) {
  if (dark) {
    slide.addShape('rect', { x: 0, y: 0, w: 13.333, h: 7.5, fill: { color: C.navy } });
    slide.addShape('rect', { x: 8.7, y: 0, w: 4.633, h: 7.5, fill: { color: C.navy2 } });
  } else {
    slide.addShape('rect', { x: 0, y: 0, w: 13.333, h: 7.5, fill: { color: C.light } });
    slide.addShape('rect', { x: 0, y: 0, w: 13.333, h: 0.78, fill: { color: C.navy } });
    slide.addShape('rect', { x: 0, y: 7.18, w: 13.333, h: 0.32, fill: { color: 'E8EEF4' } });
  }
}
function addHeader(slide, title, subtitle='') {
  slide.addText(title, { x: 0.3, y: 0.16, w: 10.5, h: 0.42, fontFace: FONT_HEAD, fontSize: SIZE.headerTitle, bold: true, color: C.white, margin: 0, fit: 'shrink' });
  if (subtitle) slide.addText(subtitle, { x: 8.8, y: 0.2, w: 3.9, h: 0.24, fontFace: FONT, fontSize: SIZE.headerSubtitle, color: 'DCE7F1', align: 'right', margin: 0, fit: 'shrink' });
}
function addPageNum(slide, n) { slide.addText(String(n), { x: 12.4, y: 7.02, w: 0.28, h: 0.14, fontFace: FONT, fontSize: SIZE.pageNum, color: '6B7280', align: 'right', margin: 0, fit: 'shrink' }); }
function addLeftAccent(slide, x, y, h, color) { slide.addShape('rect', { x, y, w: 0.12, h, fill: { color }, line: { color } }); }
function addBulletText(slide, text, opts={}) {
  slide.addText('• ' + text, { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fontFace: FONT, fontSize: opts.fontSize || SIZE.body, color: opts.color || C.dark, margin: 0.03, valign: 'top', fit: 'shrink' });
}
function download(url, outPath) {
  return new Promise((resolve, reject) => {
    const lib = url.startsWith('https') ? https : http;
    lib.get(url, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return resolve(download(res.headers.location, outPath));
      if (res.statusCode !== 200) return reject(new Error('HTTP ' + res.statusCode));
      const f = fs.createWriteStream(outPath);
      res.pipe(f);
      f.on('finish', () => f.close(() => resolve(outPath)));
      f.on('error', reject);
    }).on('error', reject);
  });
}
async function ensureAsset(url, cacheDir) {
  if (!url) return null;
  if (fs.existsSync(url)) return url;
  const ext = path.extname(new URL(url).pathname) || '.jpg';
  const crypto = require('crypto');
  const base = crypto.createHash('sha1').update(String(url)).digest('hex');
  const out = path.join(cacheDir, base + ext);
  if (fs.existsSync(out) && fs.statSync(out).size > 0) return out;
  try { await download(url, out); return out; } catch { return null; }
}
function titleSlide(pptx, deck, page) {
  const s = pptx.addSlide(); addBg(s, true);
  s.addShape('rect', { x: 0.65, y: 0.8, w: 0.18, h: 5.2, fill: { color: C.gold }, line: { color: C.gold } });
  s.addText(deck.paper_meta.title_zh || deck.paper_meta.title_en, { x: 1.06, y: 1.02, w: 7.05, h: 1.55, fontFace: FONT_HEAD, fontSize: SIZE.titleZh, bold: true, color: C.white, margin: 0, fit: 'shrink' });
  s.addText(deck.paper_meta.title_en, { x: 1.08, y: 2.82, w: 6.95, h: 1.05, fontFace: FONT, fontSize: SIZE.titleEn, italic: true, color: 'D5E4F0', margin: 0, fit: 'shrink' });
  s.addText(`${deck.paper_meta.journal || ''}  ${deck.paper_meta.year || ''}`.trim(), { x: 1.1, y: 4.15, w: 4.8, h: 0.25, fontFace: FONT, fontSize: SIZE.titleMeta, bold: true, color: 'C9DBEA', margin: 0, fit: 'shrink' });
  s.addText(deck.paper_meta.author_display || (deck.paper_meta.first_author ? `${deck.paper_meta.first_author} et al.` : (deck.paper_meta.authors || []).join('、')), { x: 1.1, y: 4.72, w: 7.0, h: 0.7, fontFace: FONT, fontSize: SIZE.titleAuthors, color: C.white, margin: 0, valign: 'top', fit: 'shrink' });
  s.addShape('rect', { x: 8.95, y: 1.15, w: 3.45, h: 1.05, fill: { color: '1D405D' }, line: { color: C.gold, width: 1 } });
  s.addText('研究主线', { x: 9.1, y: 1.24, w: 1.2, h: 0.2, fontFace: FONT, fontSize: SIZE.smallLabel, bold: true, color: C.gold, margin: 0, fit: 'shrink' });
  s.addText('免疫调节 → 神经炎症↓ → Aβ/Tau↓ → 神经保护↑', { x: 9.1, y: 1.55, w: 3.0, h: 0.42, fontFace: FONT, fontSize: 10.6, color: 'C9DBEA', margin: 0, fit: 'shrink' });
  s.addShape('rect', { x: 8.95, y: 2.62, w: 3.45, h: 2.4, fill: { color: '1D405D' }, line: { color: C.teal, width: 1 } });
  s.addText('关键词', { x: 9.1, y: 2.72, w: 1, h: 0.22, fontFace: FONT, fontSize: SIZE.smallLabel, bold: true, color: C.teal, margin: 0, fit: 'shrink' });
  s.addText('Fingolimod\nAPP/PS1\nAβ\nTau\nLymphocyte\nNeurodegeneration', { x: 9.1, y: 3.05, w: 3.0, h: 1.8, fontFace: FONT, fontSize: SIZE.smallText, color: 'C9DBEA', margin: 0, fit: 'shrink' });
  addPageNum(s, page);
}
function agendaSlide(pptx, page) {
  const s = pptx.addSlide(); addBg(s); addHeader(s, '目录'); addPageNum(s, page);
  s.addShape('rect', { x: 0.7, y: 1.2, w: 12.0, h: 5.55, fill: { color: C.white }, shadow: softShadow() });
  const items = ['全文概述','研究背景与科学问题','研究设计与技术路线','关键结果','机制主线总结','优点与局限','Take-home message'];
  items.forEach((t, i) => {
    const y = 1.48 + i * 0.72;
    s.addShape('ellipse', { x: 1.05, y, w: 0.5, h: 0.5, fill: { color: i >= 3 && i <=4 ? C.gold : C.teal } });
    s.addText(String(i+1).padStart(2,'0'), { x: 1.05, y, w: 0.5, h: 0.5, fontFace: FONT, fontSize: SIZE.agendaIndex, bold: true, color: C.white, align: 'center', valign: 'middle', margin: 0, fit: 'shrink' });
    s.addText(t, { x: 1.82, y: y+0.02, w: 9.8, h: 0.3, fontFace: FONT, fontSize: SIZE.agendaTitle, bold: true, color: C.dark, margin: 0, fit: 'shrink' });
    if (i < items.length - 1) s.addShape('line', { x: 1.82, y: y+0.58, w: 9.8, h: 0, line: { color: 'E8EEF4', width: 1 } });
  });
}
function genericTextSlide(pptx, slideData, page) {
  const s = pptx.addSlide(); addBg(s); addHeader(s, slideData.title, slideData.subtitle || ''); addPageNum(s, page);
  s.addShape('rect', { x: 0.7, y: 1.18, w: 12.0, h: 5.56, fill: { color: C.white }, shadow: softShadow() });
  addLeftAccent(s, 0.7, 1.18, 5.56, slideData.slide_type === 'limitations' ? C.gold : C.teal);
  if (slideData.sections?.length) {
    const cols = slideData.sections.length >= 3 ? 3 : Math.min(2, slideData.sections.length);
    const colW = cols === 3 ? 3.55 : 5.5; const gap = cols === 3 ? 0.28 : 0.45;
    slideData.sections.forEach((sec, i) => {
      const col = i % cols; const row = Math.floor(i / cols); const x = 1.05 + col * (colW + gap); const y = 1.4 + row * 2.5;
      s.addText(sec.heading, { x, y, w: colW, h: 0.28, fontFace: FONT, fontSize: SIZE.sectionHeading, bold: true, color: slideData.slide_type === 'limitations' ? C.gold : C.teal, margin: 0, fit: 'shrink' });
      s.addShape('line', { x, y: y+0.34, w: colW, h: 0, line: { color: C.line, width: 1 } });
      (sec.points || []).forEach((p, j) => addBulletText(s, p, { x, y: y+0.5 + j*0.78, w: colW, h: 0.7, fontSize: SIZE.bodyDense }));
    });
  } else if (slideData.bullets?.length) {
    slideData.bullets.forEach((b, i) => addBulletText(s, b, { x: 1.0, y: 1.55 + i*0.9, w: 11.1, h: 0.75, fontSize: slideData.slide_type === 'takeaway' ? SIZE.takeaway : SIZE.body }));
  }
}
function resultSlide(pptx, slideData, page) {
  const s = pptx.addSlide(); addBg(s); addHeader(s, slideData.title, slideData.subtitle || ''); addPageNum(s, page);
  const hasImage = !!(slideData.localImagePath && fs.existsSync(slideData.localImagePath) && fs.statSync(slideData.localImagePath).size > 0);
  if (hasImage) {
    s.addShape('rect', { x: 0.48, y: 1.03, w: 4.04, h: 5.9, fill: { color: C.white }, shadow: softShadow() });
    addLeftAccent(s, 0.48, 1.03, 5.9, C.gold);
    s.addText('核心结论', { x: 0.82, y: 1.22, w: 3.2, h: 0.28, fontFace: FONT, fontSize: SIZE.resultLabel, bold: true, color: C.navy, margin: 0, fit: 'shrink' });
    (slideData.bullets || []).slice(0,4).forEach((b, i) => addBulletText(s, b, { x: 0.78, y: 1.62 + i*1.05, w: 3.42, h: 0.92, fontSize: SIZE.body }));
    s.addShape('rect', { x: 4.82, y: 1.03, w: 8.02, h: 3.98, fill: { color: 'F4F7FA' }, line: { color: 'C8D5E1', width: 1 } });
    s.addImage({ path: slideData.localImagePath, x: 4.91, y: 1.12, w: 7.84, h: 3.8, sizing: { type: 'contain', w: 7.84, h: 3.8 } });
    s.addShape('rect', { x: 4.82, y: 5.2, w: 8.02, h: 1.55, fill: { color: C.white }, line: { color: 'C8D5E1', width: 1 } });
    s.addShape('rect', { x: 5.0, y: 5.34, w: 0.72, h: 0.26, fill: { color: C.mint }, line: { color: C.mint } });
    s.addText('图解', { x: 5.0, y: 5.34, w: 0.72, h: 0.26, fontFace: FONT, fontSize: SIZE.smallLabel, bold: true, color: C.teal, align: 'center', valign: 'middle', margin: 0, fit: 'shrink' });
    const figRef = (slideData.figure_refs || [])[0] || '';
    s.addText(figRef, { x: 11.0, y: 5.38, w: 1.4, h: 0.18, fontFace: FONT, fontSize: SIZE.resultFigRef, color: C.mid, align: 'right', margin: 0, fit: 'shrink' });
    const exp = slideData.figure_explanations?.[0]?.explanation || '';
    s.addText(exp, { x: 5.08, y: 5.68, w: 7.1, h: 0.9, fontFace: FONT, fontSize: SIZE.resultCaption, color: C.dark, margin: 0, valign: 'top', fit: 'shrink' });
  } else {
    s.addShape('rect', { x: 0.72, y: 1.18, w: 12.0, h: 5.56, fill: { color: C.white }, shadow: softShadow() });
    addLeftAccent(s, 0.72, 1.18, 5.56, C.gold);
    s.addText('关键内容', { x: 1.02, y: 1.42, w: 2.2, h: 0.3, fontFace: FONT, fontSize: SIZE.resultLabel, bold: true, color: C.navy, margin: 0, fit: 'shrink' });
    (slideData.bullets || []).slice(0,5).forEach((b, i) => {
      s.addShape('roundRect', { x: 1.0, y: 1.95 + i*0.83, w: 11.2, h: 0.66, rectRadius: 0.05, fill: { color: i % 2 === 0 ? 'EEF7F6' : 'F8FBFD' }, line: { color: i % 2 === 0 ? 'EEF7F6' : 'F8FBFD' } });
      s.addText(String(i+1).padStart(2,'0'), { x: 1.22, y: 2.08 + i*0.83, w: 0.5, h: 0.22, fontFace: FONT, fontSize: 18, bold: true, color: i % 2 === 0 ? C.teal : C.gold, margin: 0, fit: 'shrink' });
      s.addText(b, { x: 1.9, y: 2.03 + i*0.83, w: 9.9, h: 0.34, fontFace: FONT, fontSize: SIZE.takeaway, color: C.dark, margin: 0, fit: 'shrink' });
    });
  }
}
function mechanismSlide(pptx, slideData, page) {
  const s = pptx.addSlide(); addBg(s); addHeader(s, slideData.title); addPageNum(s, page);
  const hasImage = !!(slideData.localImagePath && fs.existsSync(slideData.localImagePath) && fs.statSync(slideData.localImagePath).size > 0);
  s.addShape('rect', { x: 0.62, y: 1.16, w: hasImage ? 5.0 : 12.0, h: 5.55, fill: { color: C.white }, shadow: softShadow() });
  addLeftAccent(s, 0.62, 1.16, 5.55, C.teal);
  s.addText('路径主线', { x: 0.95, y: 1.35, w: 1.6, h: 0.25, fontFace: FONT, fontSize: SIZE.mechanismLabel, bold: true, color: C.teal, margin: 0, fit: 'shrink' });
  const bullets = slideData.bullets || [];
  bullets.forEach((b, i) => {
    const y = 1.82 + i * 1.03; const fill = i === 0 ? C.teal : (i === bullets.length -1 ? C.gold : 'EEF5FB'); const txtColor = i === 0 ? C.white : C.dark;
    s.addShape('roundRect', { x: 1.0, y, w: hasImage ? 4.1 : 10.8, h: 0.55, rectRadius: 0.05, fill: { color: fill }, line: { color: fill } });
    s.addText(b, { x: 1.12, y: y+0.08, w: hasImage ? 3.86 : 10.5, h: 0.36, fontFace: FONT, fontSize: hasImage ? SIZE.mechanismNode : 15.5, bold: i===0 || i===bullets.length-1, color: txtColor, align: hasImage ? 'center' : 'left', valign: 'middle', margin: 0, fit: 'shrink' });
    if (i < bullets.length -1) s.addText('↓', { x: hasImage ? 2.85 : 5.9, y: y+0.56, w: 0.4, h: 0.24, fontFace: FONT, fontSize: 18, bold: true, color: 'B7CBDC', align: 'center', margin: 0, fit: 'shrink' });
  });
  if (hasImage) {
    s.addShape('rect', { x: 5.95, y: 1.16, w: 6.6, h: 3.6, fill: { color: 'F4F7FA' }, line: { color: 'C8D5E1', width: 1 } });
    s.addImage({ path: slideData.localImagePath, x: 6.05, y: 1.28, w: 6.4, h: 3.35, sizing: { type: 'contain', w: 6.4, h: 3.35 } });
    s.addShape('rect', { x: 5.95, y: 5.0, w: 6.6, h: 1.7, fill: { color: C.gold_bg }, shadow: softShadow() });
    addLeftAccent(s, 5.95, 5.0, 1.7, C.gold);
    s.addText('机制解读', { x: 6.2, y: 5.22, w: 1.2, h: 0.22, fontFace: FONT, fontSize: 16, bold: true, color: 'A2721D', margin: 0, fit: 'shrink' });
    const exp = slideData.figure_explanations?.[0]?.explanation || '';
    s.addText(exp, { x: 6.2, y: 5.55, w: 5.9, h: 0.85, fontFace: FONT, fontSize: SIZE.mechanismExplain, color: C.dark, margin: 0, valign: 'top', fit: 'shrink' });
  }
}
function closingSlide(pptx, page) {
  const s = pptx.addSlide(); addBg(s, true);
  s.addShape('roundRect', { x: 1.1, y: 1.45, w: 5.5, h: 3.7, rectRadius: 0.08, fill: { color: '1D405D' } });
  s.addText('谢谢！', { x: 1.55, y: 2.1, w: 3.2, h: 0.7, fontFace: FONT_HEAD, fontSize: SIZE.closingTitle, bold: true, color: C.white, margin: 0, fit: 'shrink' });
  s.addText('感谢批评指正！', { x: 1.58, y: 3.0, w: 4.3, h: 0.42, fontFace: FONT, fontSize: SIZE.closingSubtitle, color: 'DDE9F2', margin: 0, fit: 'shrink' });
  s.addText('Questions & Discussion', { x: 1.58, y: 3.72, w: 3.5, h: 0.3, fontFace: FONT, fontSize: SIZE.closingNote, italic: true, color: 'BDD2E3', margin: 0, fit: 'shrink' });
  s.addShape('rect', { x: 9.2, y: 1.75, w: 2.65, h: 2.65, fill: { color: '274967' } });
  s.addShape('line', { x: 9.52, y: 3.08, w: 2.0, h: 0, line: { color: C.gold, width: 2 } });
  s.addShape('line', { x: 10.52, y: 2.08, w: 0, h: 2.0, line: { color: C.gold, width: 2 } });
  s.addText('AD Journal Club', { x: 9.46, y: 4.7, w: 2.6, h: 0.22, fontFace: FONT, fontSize: SIZE.badge, color: 'D0DFEB', margin: 0, fit: 'shrink' });
  addPageNum(s, page);
}
(async function main() {
  const args = parseArgs(process.argv);
  if (!args['slides-json']) { console.error('missing --slides-json'); process.exit(1); }
  const deck = JSON.parse(fs.readFileSync(args['slides-json'], 'utf8'));
  const cacheDir = args['assets-dir'] || path.join(path.dirname(args['slides-json']), 'assets');
  fs.mkdirSync(cacheDir, { recursive: true });
  for (const s of deck.slides) {
    if (s.image_paths_or_urls?.length) s.localImagePath = await ensureAsset(s.image_paths_or_urls[0], cacheDir);
  }
  const pptx = new PptxGenJS();
  pptx.layout = 'LAYOUT_WIDE';
  pptx.author = 'OpenClaw';
  pptx.title = deck.paper_meta.title_en;
  pptx.subject = deck.paper_meta.title_en;
  pptx.lang = 'zh-CN';
  pptx.theme = { headFontFace: FONT_HEAD, bodyFontFace: FONT, lang: 'zh-CN' };
  let page = 1;
  titleSlide(pptx, deck, page++);
  agendaSlide(pptx, page++);
  for (const slideData of deck.slides.slice(1, -1)) {
    if (slideData.slide_type === 'result') resultSlide(pptx, slideData, page++);
    else if (slideData.slide_type === 'mechanism') mechanismSlide(pptx, slideData, page++);
    else genericTextSlide(pptx, slideData, page++);
  }
  closingSlide(pptx, page++);
  const outPath = args['output'] || path.join(process.cwd(), sanitize(deck.paper_meta.title_en) + '.pptx');
  await pptx.writeFile({ fileName: outPath });
  console.log(outPath);
})().catch(err => { console.error(err?.stack || String(err)); process.exit(1); });


path: scripts/validate_inputs.py
content: #!/usr/bin/env python3
"""Validate minimal inputs for research-paper-to-ppt-v1.

Checks whether the resolved paper identity, full text availability, and figure
inventory satisfy the hard failure conditions described in SKILL.md.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any, Dict

FAIL = "检索失败，无法生成"


def read_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def validate(meta: Dict[str, Any], fulltext: str, figures: Any) -> Dict[str, Any]:
    reasons = []
    identity_keys = ("doi", "pmid", "source_id", "title_en", "title")
    if not any(meta.get(k) for k in identity_keys):
        reasons.append("缺少可唯一定位文献的标识")

    text = (fulltext or "").strip()
    if len(text) < 2000:
        reasons.append("全文正文过短，无法支持PPT生成")
    low = text.lower()
    if low and not any(h in low for h in ["abstract", "introduction", "methods", "results", "discussion", "conclusion", "背景", "方法", "结果", "讨论", "结论"]):
        reasons.append("全文缺少可识别章节，疑似只有摘要或残缺正文")

    has_figures = False
    if isinstance(figures, dict):
        has_figures = len(figures) > 0
    elif isinstance(figures, list):
        has_figures = len(figures) > 0

    # 图片不再是“必须有”的全局前置条件。
    # 新规则：如果全文确实有原图，则后续页面必须优先使用原图；
    # 如果全文没有可提取图片，则允许继续生成无图版 PPT。
    if has_figures:
        # Best-effort source traceability check.
        bad = []
        items = figures.values() if isinstance(figures, dict) else figures
        for item in items:
            if isinstance(item, dict):
                url = str(item.get("url") or item.get("image_url") or item.get("path") or "")
                if url and not (url.startswith("http://") or url.startswith("https://") or url.startswith("/") or url.startswith("./")):
                    bad.append(url)
        if bad:
            reasons.append("图片来源不可追溯")
    return {
        "ok": not reasons,
        "message": "OK" if not reasons else FAIL,
        "reasons": reasons,
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--paper-meta", required=True)
    parser.add_argument("--fulltext", required=True)
    parser.add_argument("--figures", required=True)
    parser.add_argument("--output", required=True)
    args = parser.parse_args()

    result = validate(
        read_json(Path(args.paper_meta)),
        read_text(Path(args.fulltext)),
        read_json(Path(args.figures)),
    )
    Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")


if __name__ == "__main__":
    main()


path: references/figure-caption-policy.md
content: # Figure 解释策略

本文件规定 PPT 中 Figure 配字的写法。

新版默认偏好：图的解释必须输出为中文，可直接参考原文图注翻译，再结合 Results 对应段落做轻度整理；重点是讲清楚，不是强行改写得比原文更复杂。

## 目标

让图片说明满足三个条件：
1. 比原始图注更容易讲
2. 比简单结论更可信
3. 不夸大原文证据

## 推荐结构

每个 Figure 的说明，尽量覆盖以下 3 层：

### 1. 研究目的
回答：作者为什么展示这张图？
示例：
- 用于验证 XX 在疾病模型中的表达变化
- 用于说明敲低 XX 后细胞增殖是否受到影响
- 用于支持 XX 与 YY 存在机制联系

### 2. 主要观察
回答：图里最值得讲的现象是什么？
示例：
- 与对照组相比，处理组中 XX 明显升高
- 敲低 XX 后，增殖相关指标下降、凋亡指标上升
- rescue 后表型部分恢复，提示作用具有因果链条

### 3. 结论意义
回答：这张图在全文逻辑里支撑什么结论？
示例：
- 支持 XX 可能参与疾病进展
- 支持 XX 对该表型具有促进作用
- 支持作者提出的 XX→YY→表型 机制链

## 推荐长度

优先两种形式：

### 形式 A：2–4 条短要点
适合汇报型页面。

### 形式 B：1 段 50–110 字说明
适合图较复杂但不想拆成太多点。

### 形式 C：同一张 Figure 拆成 2–3 页
当 Fig 1 / Fig 2 等单张图信息量过大时：
- 允许连续生成 2–3 页 PPT
- 每页都放同一张原图
- 但每页解释不同部分（例如：研究目的 / 关键比较 / 核心结论 或 panel A-B / C-D / E-F）
- 不要求切图；默认优先保留整图，靠分页讲解不同重点

## 严禁写法

- 直接复制图注
- 只写“Figure 3 结果如图所示”
- 只写“敲低后下降”而不交代对象与比较关系
- 把相关性说成因果性，除非图中确有 rescue / 干预 / 机制证据
- 把统计学细节堆成一大段，影响讲解

## 面板图处理

若是多面板 Figure（如 4A–4F）：
- 先概括整张图的中心问题
- 再选 1–2 个最关键 panel 做补充说明
- 不需要逐 panel 机械翻译全部图注

## 可直接套用的模板

### 模板 1：表达差异图
- 该图用于验证 XX 在不同样本/条件中的表达差异。
- 可以看到，与对照相比，XX 在疾病/处理组中明显上调（或下调）。
- 这一结果提示 XX 可能参与相关病理过程，是后续功能实验的切入点。

### 模板 2：功能实验图
- 该图用于评估干预 XX 后细胞功能是否发生变化。
- 结果显示，干预后增殖/迁移增强（或减弱），同时凋亡/炎症指标出现相应改变。
- 说明 XX 并非单纯相关分子，而可能直接影响关键表型。

### 模板 3：机制图
- 该图聚焦 XX 与 YY 通路之间的关系。
- 作者通过表达变化、互作或上下游干预证据，提示 XX 可调控 YY 信号活性。
- 这一结果把前面的表型变化与具体机制连接起来，是全文证据链的核心部分。

### 模板 4：救援实验图
- 该图用于检验作者提出的机制链是否具备因果性。
- 在上游分子被干预后，再通过补回/激活下游环节，原有表型得到部分或明显逆转。
- 这说明 XX 对表型的影响很可能是通过 YY 通路实现的，而不只是伴随变化。


path: references/integration-notes.md
content: # 集成说明

本文件说明如何把本技能与环境内已有技能串起来。

## 依赖技能

本技能作为编排型 skill 使用，默认依赖以下已有技能：
- `medical-keyword-search`
- `full-paper-api` 或与之等价的全文读取 skill（若当前环境实际使用 `full-paper-read`，以环境内实际技能名为准）
- `pptx`

## 推荐调用顺序

1. 用 `medical-keyword-search` 精确定位单篇文献
2. 优先用全文技能读取标题、摘要、正文、图表、PDF/图片来源
3. 若全文 API 因 token 格式问题失败，调用 `scripts/fetch_fullpaper.py`：它会从 `INFOX_MED_TOKEN` 中提取合法的 `32位hex|7位数字` 候选 token 并重试全文接口，同时整理 `paper_meta/fulltext/figures`
4. 调用 `scripts/extract_figures_and_captions.py`：把原文 Figure 与其图注、Results 对应段落、证据片段、中文解释要点绑定起来；图解应优先来自“图注 + Results 上下文”，而不是只改写图注
5. 若命中失败或全文不足，直接返回：`检索失败，无法生成`
6. 用 `scripts/validate_inputs.py` 先做本地硬校验
7. 读取 `references/outline-basic-research.md`、`references/slide-schema.md`、`references/figure-caption-policy.md`
8. 用 `scripts/build_prompt_bundle.py` 整理为中间 bundle
9. 先生成带 `image_paths_or_urls / figure_explanations` 的 `slides[]`
10. 用 `scripts/enforce_figure_requirements.py` 进行最终硬校验
11. 将 bundle/slide JSON 交给 `pptx` 技能生成最终 `.pptx`

## 图片强约束落地

为避免“内容总结得很好，但没有原文图”的失败模式，当前 skill 额外要求：
- 结果页必须显式携带 `image_paths_or_urls`
- 结果页必须显式携带 `figure_explanations`
- 生成 PPT 前必须跑 `enforce_figure_requirements.py`
- 若某结果页没有图或图解过短，直接失败，不允许输出简化版

## token bug 修复说明

某些运行环境中的 `INFOX_MED_TOKEN` 可能带有非 hex 前缀，导致原始值长度不是全文 API 要求的 40 字符，直接请求会返回“无效的 token 格式”。

`fetch_fullpaper.py` 的处理策略：
- 保留原 token 作为首选候选
- 从 `|` 左侧提取 32 位连续 hex 串
- 对混合前缀做 hex 压缩后取最后 32 位
- 右侧仅保留数字并截取前 7 位
- 仅对满足 `^[0-9a-f]{32}\|\d{7}$` 的候选发请求

这样可以在不改系统环境变量的前提下兼容更多部署环境。
## 为什么保留脚本层

这些脚本不是为了替代外部技能，而是为了：
- 把失败条件落到可复用的本地检查
- 把输入整理成稳定 JSON，减少临时 prompt 漂移
- 让 skill 打包后具备更完整、可复用的资源结构

## 失败策略

以下任一情况都应直接失败，不要尝试“先生成简版”：
- 文献无法唯一定位
- 只有摘要，没有足够正文
- 缺少可追溯的原文图片
- 图表来源不清楚，无法确认来自原文

失败时统一返回：`检索失败，无法生成`

## Figures 输入建议

`figures.json` 可以是以下任一格式：

### 对象格式
```json
{
  "Figure1": "https://...",
  "Figure2": "https://..."
}
```

### 数组格式
```json
[
  {"figure_ref": "Figure1", "url": "https://..."},
  {"figure_ref": "Figure2", "url": "https://..."}
]
```

只要能明确对应原文图即可。


path: references/layout-rules-from-feedback.md
content: # 版式微调规则

本文档定义了生成稳定、一致 PPT 的精确版式规则。所有渲染实现必须严格遵循本文档，不得随意偏离。

> **重要**：完整的视觉规范（包括精确坐标、颜色 hex 值、字体大小）请参见 `references/visual-rendering-spec.md`。本文档是规则索引和高层约束，两者必须配合使用。

---

## 1. 字号优先级（绝对规则）

当内容放不下时，优先顺序：
1. **拆页**
2. 精简次要信息
3. 调整布局
4. **最后**才是缩小字体

**禁止**：从一开始就把正文字号压到 13pt 以下。

---

## 2. 标题与正文必须拉开层级

- 标题字号（header 内）必须 ≥ 25pt
- 正文 bullet 必须 ≥ 13pt
- 层级差至少 6pt

---

## 3. 坐标与边距规则

- 所有 x 坐标以 0.1" 为最小单位对齐
- 内容左边界不得小于 0.48"（略大于 0.5" 安全区）
- 内容右边界不得大于 13.1"（留 0.23" 给页码）
- 正文区垂直起点约 1.05"–1.2"，底部不超过 7.1"

详见 `visual-rendering-spec.md` 第 1 节。

---

## 4. 颜色规则（禁止自创）

所有颜色必须来自以下固定色板，不得在渲染代码中自创 hex 值：

| 用途 | Hex |
|------|-----|
| navy（主背景）| `13293D` |
| navy2（侧栏）| `1B3A57` |
| blue | `2A628F` |
| teal（主强调）| `2CA6A4` |
| gold（金色强调）| `D5A44A` |
| light（浅背景）| `F5F8FC` |
| white | `FFFFFF` |
| dark（正文）| `18222D` |
| mid（次要文字）| `5C6773` |
| line（边框）| `D6E1EB` |

---

## 5. 字体规则

- 所有文字框必须显式指定 `fontFace`
- 不得依赖 PptxGenJS 默认字体行为
- 标题类使用 `Aptos Display`，正文类使用 `Aptos`
- 详见 `visual-rendering-spec.md` 第 3 节

---

## 6. 阴影规则

统一使用 `softShadow()` 函数返回的对象；**每次调用必须返回新对象**，不得复用同一个对象引用。详见 `visual-rendering-spec.md` 第 4 节。

---

## 7. 结果页图片规则

- 图片区域固定：`x=4.82", y=1.03", w=8.02", h=3.98"`
- 必须使用 `sizing: { type: 'contain' }`，不得裁切原图
- 图片在图片区域内水平垂直居中
- 当同一张 Figure 需拆成 2–3 页讲解时，允许在多页中重复放置同一张原图
- 这种拆页默认不切图，仅更换每页标题与解释重点
- 详见 `visual-rendering-spec.md` 第 5.4 节

---

## 8. 目录页与结束页

### 8.1 目录页
- 编号圆直径 0.5"，居中对齐
- 项目标题 21pt bold，bullet 正文 18pt
- 详见 `visual-rendering-spec.md` 第 5.2 节

### 8.2 结束页
- **必须使用深色背景**（navy + navy2），不混用浅色
- "谢谢！"字号 30pt bold，"感谢批评指正！"字号 21pt
- 不得使用白色或浅色背景作为结束页
- 详见 `visual-rendering-spec.md` 第 5.8 节

---

## 9. 关于动画

- 即使没有原生 PowerPoint 动画，也应通过拆页、章节页、路径式讲述来提供节奏感
- 优先"逐步呈现的页序设计"，而不是在单页上塞满所有信息
- 若当前工具链无法稳定写入动画，不要为了动画牺牲文件稳定性

---

## 10. 渲染脚本结构要求

渲染脚本必须遵循 `visual-rendering-spec.md` 第 8 节规定的结构，包括：
- 颜色常量集中声明
- 工具函数（softShadow、sanitize）统一实现
- 每个 slide_type 有独立布局函数
- 输出文件名使用论文标题

禁止：
- 在渲染函数内部声明颜色常量（导致不可复用）
- 硬编码魔法数字（如 `x: 4.83` 而非 `x: 4.82`）
- 在 slide 循环外做单例 shadow 对象复用

---

## 11. 质量自检清单（渲染层面）

渲染完成后，逐项确认：
- [ ] 所有 slide 的 header navy 条高度一致（h=0.78"）
- [ ] 所有 slide 的 footer 位置一致（y=7.02"）
- [ ] 所有文字显式指定 fontFace，无默认字体依赖
- [ ] 所有颜色值来自固定色板，无自创颜色
- [ ] 所有 shadow 调用使用 `softShadow()` 函数（每次新对象）
- [ ] 所有图片使用 `sizing: { type: 'contain' }`，不裁切
- [ ] 结果页图片区域：x=4.82", y=1.03", w=8.02", h=3.98"
- [ ] 机制页左侧路径卡：x=0.62", w=5.0"
- [ ] 结束页使用深色背景（navy + navy2），不混用浅色
- [ ] 所有 bullet 正文字号 ≥ 13pt
- [ ] 输出文件名使用论文标题，无泛化名称


path: references/outline-basic-research.md
content: # 基础研究型 PPT 大纲映射

本文件把用户提供的“基础研究型”大纲压缩为适合自动生成的逻辑框架。

## A. 固定保留页

### 1. 标题页
必须包含：
- 英文原题
- 中文标题
- 第一作者 / 通讯作者（能提取则放）
- 期刊、年份

### 2. 全文概述
目标：用 3–5 条概括整篇论文的研究对象、核心问题、主要发现与意义。

## B. 条件生成页

### 3. 研究背景
生成条件：前言/引言中存在较完整背景与研究空白。

### 4. 科学问题与研究假设
生成条件：能从全文明确提炼“作者想回答什么问题”。

### 5. 文献基本信息
适合在以下情况下生成：
- 模型较多
- 涉及细胞、动物、临床样本、数据库多层验证
- 听众需要快速理解研究类型

### 6. 整体研究思路与技术路线
优先生成。
如果原文有 graphical abstract / schematic model / study design figure，优先使用。
没有现成图时，可用文字流程页，但不要冒充原图。

### 7. 研究对象与实验材料
生成条件：模型、样本、材料信息比较清楚。
可与“实验分组与干预设计”合并。

### 8. 实验分组与干预设计
生成条件：有明确对照、过表达/敲低/敲除/抑制剂/激动剂/rescue 设计。

### 9. 主要实验方法
只保留与核心结论最相关的方法，不需要面面俱到。

## C. 结果页逻辑

结果页按证据链组织，不按机械目录组织。

### 10. 结果一：关键现象或初始发现
常见内容：
- 差异表达
- 初始表型
- 数据库筛选得到候选分子

### 11. 结果二：功能验证
常见内容：
- 增殖
- 凋亡
- 迁移/侵袭
- 炎症/分化/自噬等

### 12. 结果三：机制探索
常见内容：
- 上下游关系
- 通路变化
- 蛋白互作
- 转录调控

### 13. 结果四：救援实验 / 因果验证
生成条件：有 rescue / epistasis / 补回实验。

### 14. 结果五：体内实验验证
生成条件：有动物或体内证据。

### 15. 结果六：临床样本或转化相关性验证
生成条件：有患者样本、预后分析、分型分析或诊断/治疗相关性证据。

## D. 总结与评价页

### 16. 主要结果总结
把前面关键结果整合成 3–5 条。

### 17. 机制模式图
优先使用原文机制图；若原文没有，就不要伪造原文图。
可改成“机制总结页”。

### 18. 文章优点
从设计完整性、证据链、技术交叉验证、转化相关性等角度概括。

### 19. 文章局限性
不要只复述作者原话，要做适度科研判断，但必须基于文中证据。

### 20. 科学意义与转化价值
区分：
- 基础科学意义
- 临床/转化潜力

### 21. 结论与个人观点
如用户未要求强主观点评，可弱化为“结论与启示”。

### 22. Take-home message
压缩成 2–3 条。

### 23. 致谢 / 讨论
仅在需要完整收尾时生成；否则可省略。

## E. 自动压缩建议

### 10 页左右简版
推荐结构：
1. 标题页
2. 研究背景与科学问题
3. 研究假设与技术路线
4. 模型 / 分组 / 方法
5. 关键结果一
6. 关键结果二
7. 关键结果三
8. 体内/临床验证 或 rescue
9. 优缺点 + 科学意义
10. 总结与启示

### 12–16 页标准版
在简版基础上拆分更多结果页与评价页。

## F. 跳页规则

以下章节若无充分信息，直接跳过：
- 救援实验
- 体内实验
- 临床样本验证
- 致谢
- 机制模式图

不要输出“本研究未涉及该部分”的空白页。

path: references/output-filename-policy.md
content: # 输出文件名规则

## 默认规则

最终输出的 `.pptx` 文件名默认使用**目标论文标题**生成。

优先级：
1. 用户明确指定的文件名
2. 论文英文标题
3. 论文中文标题
4. 若标题缺失，再退回安全的临时名

## 清洗规则

文件名需要做文件系统安全清洗：
- 去掉或替换路径分隔符、冒号、问号、引号等非法字符
- 合并多余空格
- 避免只输出 `output.pptx`、`final.pptx`、`report.pptx` 这类泛化名

## 推荐做法

### 英文标题
保留英文单词和空格，必要时可替换为下划线或短横线。

示例：
- `Fingolimod ameliorates amyloid deposition and neurodegeneration in APP_PS1 mouse model of Alzheimer's disease.pptx`
- `Fingolimod-ameliorates-amyloid-deposition-and-neurodegeneration-in-APP_PS1-mouse-model-of-Alzheimers-disease.pptx`

### 中文标题
若用户明确要求中文文件名，可直接使用中文标题，但仍要做非法字符清洗。

示例：
- `芬戈莫德改善APP_PS1阿尔茨海默病小鼠的淀粉样沉积与神经退行性变.pptx`

## 交付要求

向用户汇报结果时，应直接给出最终生成的文件路径，并确保该路径中的文件名与论文标题对应。


path: references/ppt-copy-rules.md
content: # PPT 文案拷贝规则

用于约束最终 PPT 中不应出现的多余系统性文案。

## 禁止出现的文案

以下文字不要直接出现在最终用户交付的 PPT 中：

- “带原文图片的中文汇报版”
- “本版 PPT 的结果页均直接嵌入原文 Figure，并补充中文讲解，以便组会/Journal Club 直接使用。”
- 任何类似“这是自动生成的”“以下为AI总结”“图解由系统生成”等元说明

## 原则

最终 PPT 应该看起来像正常科研汇报文件，而不是工具说明文件。

允许出现：
- 论文标题
- 作者 / 期刊 / 年份
- 规范的 Figure 编号
- 与汇报内容直接相关的背景、结果、结论、局限

不允许出现：
- 对生成过程的自我描述
- 对技能/系统/自动化流程的说明
- 面向操作者而非听众的注释性文案


path: references/release-audit.md
content: # 发布审计结论

## 当前结论

该技能已经达到“可直接打包发布”的基本标准：
- 目录结构正确
- 前言 frontmatter 合法
- 触发描述明确
- 核心流程完整
- 引用资料与脚本资源齐全
- 已通过本地校验与 `.skill` 打包

## 已修正项

### 1. 触发描述更像市场文案
已在 `SKILL.md` description 中补充典型触发语：
- 根据这篇论文生成汇报PPT
- 把这篇 research paper 做成中文组会汇报
- 按文献生成带原图的PPT

### 2. 全文技能名称歧义已收敛
原先正文里只写 `full-paper-read`，容易与环境实际可用技能名不一致。
现改为：
- 优先使用 `full-paper-api`
- 若环境中实际提供 `full-paper-read` 或同类技能，以实际技能名为准

### 3. 参考资料加载顺序更清晰
已把 `integration-notes.md` 提前，减少执行时的歧义。

### 4. 补充了统一失败策略
已在 `integration-notes.md` 中明确：
- 无法唯一定位
- 只有摘要
- 无原文图
- 图源不清
都应直接返回 `检索失败，无法生成`

## 仍然存在但可接受的发布级风险

### A. 这是“编排型 skill”，不是“全内置执行型 skill`
当前 skill 依赖环境内已有技能：
- `medical-keyword-search`
- `full-paper-api` / `full-paper-read`
- `pptx`

这不是缺陷，但在发布说明中应默认用户运行环境已经具备这些技能。

### B. 本地脚本只做整理与校验，不直接发起 API
这是合理的，因为真正的检索 / 全文读取 / pptx 生成应由对应技能负责。

### C. 暂无 assets
当前不强制需要模板、图标或品牌资源，因此没有 `assets/` 是合理的。
若未来要固定版式或医院模板，再补 `assets/` 更合适。

## 发布建议

可以直接发布当前版本。

如果要进一步提升市场可用性，优先级建议：
1. 补一个统一入口脚本或示例调用链
2. 增加一个最小示例输入输出说明
3. 若后续强调版式一致性，再补 `assets/` 模板


path: references/slide-schema.md
content: # 中间 JSON 结构约定

生成 PPTX 前，先整理成统一 JSON。

```json
{
  "title": "中文标题或英文标题",
  "paper_meta": {
    "title_en": "",
    "title_zh": "",
    "authors": [""],
    "author_display": "",
    "author_display_mode": "first_et_al|full",
    "first_author": "",
    "corresponding_author": "",
    "journal": "",
    "year": "",
    "doi": "",
    "pmid": "",
    "source_id": ""
  },
  "slides": [
    {
      "slide_type": "title|overview|background|question|design|materials|methods|result|summary|mechanism|strengths|limitations|significance|takeaway|closing",
      "title": "",
      "subtitle": "",
      "bullets": [""],
      "sections": [
        {
          "heading": "",
          "points": [""]
        }
      ],
      "figure_refs": ["Figure 1", "Figure 3A-F"],
      "image_paths_or_urls": [""],
      "figure_explanations": [
        {
          "figure_ref": "Figure 1",
          "explanation": "中文解释"
        }
      ],
      "source_result_subtitle": "Results 原文小标题",
      "speaker_intent": "这一页想帮助听众理解什么"
    }
  ]
}
```

## 字段要求

### slide_type
用于提示版式和排版，不要求死板，但应尽量准确。

### bullets
适合纯文本页。
每页建议 3–6 条。

### sections
适合多栏结构，如“研究对象 / 样本 / 处理条件”。

### figure_refs
保留原文编号，方便追溯。

### image_paths_or_urls
只能填原文图片来源。
如果该页没有图，可以为空数组。

### figure_explanations
这是重点字段。
必须是“更适合 PPT 讲解的中文解释”，不是原始图注照抄。
默认可直接参照原文图注翻译，再做最小必要的中文讲解整理；不要额外发散编造成更复杂的机制说明。

### source_result_subtitle
优先填写该页对应的 Results 原文小标题。
- 若原小标题中文直译过长，可在不改变原意前提下压缩成更适合 PPT 的短标题。
- 结果页标题默认优先来自这里，而不是助手自行另写完全不同的标题。

## 结果页建议

结果页必须包含：
- 1 个标题
- 2–4 条结果要点
- 至少 1 张核心原文图
- 至少 1 段或 2–4 条对应图解释

## 强制字段建议

对于 `slide_type = result | mechanism`，应视为强制：
- `figure_refs`
- `image_paths_or_urls`
- `figure_explanations`

若这些字段为空，生成器应直接停止，而不是输出纯文字结果页。

## 禁止事项

- 不要把不存在的字段当成必要字段
- 不要在 `image_paths_or_urls` 中放伪造 URL
- 不要输出空白 slide 对象凑数
- 不要把整篇全文原文大段塞进 bullets


path: references/style-preferences-from-reference.md
content: # 参考风格偏好

将以下风格默认化，除非用户明确指定其他风格。

## 一、推荐的行文结构

优先采用下面这种顺序：

1. 标题页
2. 目录页
3. 全文概述
4. 研究背景
5. 研究问题与科学假设
6. 实验设计 / 材料与方法
7. 结果页（按证据链顺序逐页展开）
8. 机制讨论
9. 局限性与临床转化展望
10. 研究结论
11. 感谢页

说明：
- 这套结构比“摘要式汇总”更适合组会/Journal Club。
- 结果页应尽量按病理链条推进，而不是简单复制论文目录。

## 二、版式偏好

### 0. 默认审美目标

- 默认生成结果应接近“可直接用于组会”的成品，而不是只满足“信息已放入 PPT”。
- 版面应有明显层次、留白和视觉重点，避免满页文字。
- 若用户没有给模板，也应主动生成较完整的视觉结构：封面、目录、结果图页、总结页、结束页。

### 1. 字体与字号

- 标题字号要明显大于正文，形成清晰层级。
- 正文字号宁可偏大，也不要因为塞太多字导致看不清。
- 当单页信息过多时，优先：
  1) 拆页；
  2) 缩减次要文字；
  3) 调整布局；
  不要优先把字压得很小。

### 2. 文本框与背景框

- 背景框大小应服务于内容，不要出现“框很大但字很挤”或“框很小导致内容拥堵”的情况。
- 卡片/信息框要保持统一边距与留白。
- 不要让说明文字贴边。

### 3. 非结果页尽量减少纯文本疲劳

- 对于“动物模型”“病理与生化”“技术路线”“结论分类”等页面，优先考虑：
  - 图标
  - 简单流程图
  - 分区卡片
  - 小示意图
  - 目录页 / 章节过渡页
- 目标是减轻大段纯文本造成的视觉疲劳。

### 4. 结果页推荐默认版式

若无模板限制，结果页优先使用：
- 左侧：核心结论 / 讲述要点
- 右侧：原文 Figure
- 下方或侧边：图解说明

目标：
- 让讲者一眼知道“这页要讲什么”
- 让听众一眼看到“证据在哪”
- 保持原图可追溯性，同时避免整页只剩一张大图或一堆文字

## 三、结果页写法偏好

### 1. 结果页要写完整句意，不要只写短标签

避免只写：
- “B淋巴细胞下降”
- “Tau下降”

优先写成更完整的汇报语言，例如：
- “APP/PS1 小鼠经 1 mg/kg/day 芬戈莫德口服 2 个月后，脑内 B 淋巴细胞相关信号下降。”
- “芬戈莫德处理后，多个 Tau 磷酸化位点下调，并在组织染色中表现为病理负荷减轻。”

也就是说，每条核心结论尽量包含：
- 主语（模型/对象）
- 干预（药物/剂量/时长）
- 结果（变化方向）
- 必要时点出检测维度

### 2. 多图时不要硬塞整图

如果一个 Figure 信息量过大：
- 可以拆成多页
- 可以按 panel/子主题分开展示
- 可以在保证“来自原图”的前提下做合理裁切

原则：
- **要有重点**
- **要可讲解**
- **不要为了保留完整而让信息不可读**

### 3. 多 panel 图可按区块呈现

例如：
- 2A 讲 B 细胞变化
- 2B / 2C 讲 T 细胞变化
- 2D–F 讲 WB 验证

若图非常复杂，优先考虑“分区讲解”，而不是把所有 panel 在一页里平均对待。

## 四、机制页偏好

机制讨论页不要只摆模块名称。

必须尽量体现：
- 单向箭头 / 双向关系
- 上下游关系
- 关键中介环节

推荐表达：
- Fingolimod → 淋巴细胞归巢↑ → 脑内浸润↓ → 神经炎症↓ → Aβ/Tau/神经元损伤↓ → 认知改善

也就是说，机制页应是“路径图”，不是“概念堆叠图”。

## 五、结论页偏好

结论页不要只是流水账复述。

优先做法：
- 将结论按维度分类，如：
  - 行为学
  - 免疫浸润
  - 核心病理
  - 神经保护
  - 转化意义
- 适度加入图标或视觉分组
- 保持“强结论 + 支撑证据”的结构

## 六、结束页偏好

- 默认生成正式结束页，而不是草草结束在结论页。
- 若用户未指定结尾文案，默认使用：`谢谢！感谢批评指正！`
- 若用户指定更正式 / 更口语 / 更学术的结尾，以用户要求为准。

## 七、执行优先级

当与默认技能规则冲突时，优先采用以下原则：

1. 可读性 > 压缩字数
2. 重点突出 > 面面俱到
3. 分页/分区讲清楚 > 单页塞满
4. 路径化表达 > 名词堆砌
5. 组会汇报感 > 摘要罗列感


path: references/visual-rendering-spec.md
content: # 视觉渲染规范

本文档定义所有 PPT 生成的精确视觉规则，是生成稳定、一致输出的核心依据。

---

## 1. 幻灯片基础规格

| 属性 | 值 |
|------|-----|
| 布局 | `LAYOUT_WIDE`（13.333 × 7.5 英寸）|
| 坐标系 | 1 pt = 127 EMU；1 英寸 = 914400 EMU |
| 内容安全区 | y: 0.1" – 7.1" |
| 顶部 header | y=0, h=0.78"（深海军蓝色）|
| 底部 footer | y=7.18", h=0.32"（浅灰，用于页码）|
| 正文内容区 | y 起点约 1.05"–1.2"，底部止于 7.05" |

**禁止项**：
- 任何形状的 x 坐标小于 0
- 任何形状的右边界超过 13.333"
- 任何形状的底部边界超过 7.5"
- 字号小于 10pt（脚注/页码除外）

---

## 2. 颜色面板

| 用途 | 变量名 | Hex 值 |
|------|--------|--------|
| 主背景（深色页）| navy | `13293D` |
| 右栏/侧栏 | navy2 | `1B3A57` |
| 主蓝色 | blue | `2A628F` |
| 主青绿 | teal | `2CA6A4` |
| 薄荷背景 | mint | `DDF4F1` |
| 浅灰背景 | light | `F5F8FC` |
| 白色 | white | `FFFFFF` |
| 深色文字 | dark | `18222D` |
| 中灰文字 | mid | `5C6773` |
| 边框/分隔线 | line | `D6E1EB` |
| 金色强调 | gold | `D5A44A` |
| 卡片浅金背景 | gold_bg | `FBF7EE` |
| 结果页强调 | gold2 | `ECD7A4` |

**禁止项**：不得在代码中使用未经本表定义的颜色值。

---

## 3. 字体规范

| 元素 | 字号（pt）| 字重 | 颜色 | 字体 |
|------|-----------|------|------|------|
| 幻灯片标题（header 内）| 25 | bold | `FFFFFF` | Aptos Display |
| 副标题（header 右）| 10.5 | normal | `DCE7F1` | Aptos |
| 页码（右下角）| 10 | normal | `6B7280` | Aptos |
| 卡片标题 | 20 | bold | navy | Aptos |
| 核心结论标签 | 19 | bold | navy | Aptos |
| 正文 bullet | 15–18 | normal | dark | Aptos |
| 图解标题 | 12 | bold | teal | Aptos |
| 图解正文 | 13.2 | normal | dark | Aptos |
| 标题页中文标题 | 27 | bold | white | Aptos Display |
| 标题页英文章名 | 17 | italic | `D5E4F0` | Aptos |
| 标题页期刊/年份 | 17.5 | bold | `C9DBEA` | Aptos |
| 标题页作者 | 14.5 | normal | white | Aptos |
| 结束页主标题 | 30 | bold | white | Aptos Display |
| 结束页副标题 | 21 | normal | `DDE9F2` | Aptos |

**字号安全规则**：bullet 正文字号不得小于 13pt；若内容过多，优先拆页，而不是缩小字号。

---

## 4. 阴影规范（全局统一）

```javascript
function softShadow() {
  return {
    type: 'outer',
    color: '000000',
    blur: 2,
    offset: 1,
    angle: 45,
    opacity: 0.12
  };
}
```

**禁止项**：
- 不得在多个 shape 上复用同一个 shadow 对象（shadow 对象会被 PptxGenJS 就地修改）
- 每次调用 `softShadow()` 必须返回新对象
- 不得在 shadow 中使用 8 位 hex 颜色

---

## 5. 布局模板（每种 slide_type 的精确坐标）

### 5.1 标题页（slide_type = title）

```
背景：整页 navy (#13293D)
右栏：x=8.7", w=4.633", h=7.5", fill=navy2
金色竖条：x=0.65", w=0.18", h=5.2", fill=gold

中文标题：x=1.06", y=1.02", w=7.05", h=1.55", sz=27, bold, white
英文章名：x=1.08", y=2.82", w=6.95", h=1.05", sz=17, italic, #D5E4F0
期刊年份：x=1.1",  y=4.15", w=4.4",  h=0.25", sz=17.5, bold, #C9DBEA
作者信息：x=1.1",  y=4.7",  w=5.7",  h=0.62", sz=14.5, white

研究主线卡片：x=8.95", y=1.15", w=3.45", h=1.05", rectRadius=0.08
关键词卡片：  x=8.95", y=2.62", w=3.45", h=2.4",  rectRadius=0.08

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

### 5.2 目录页（slide_type = 目录 / agenda）

```
背景：light + header navy + footer 浅灰
内容卡：x=0.7", y=1.2", w=12.0", h=5.55", white, shadow=softShadow()

编号圆：圆形, diam=0.5", fill=teal（或 gold 对应重点项）
编号文字：圆内居中, sz=14, bold, white

项目标题：x=1.82", sz=21, bold, dark
分隔线：x=1.82", y+0.57", w=9.8", h=0", #E8EEF4

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

### 5.3 全文概述页（slide_type = overview）

```
背景：light + header navy + footer 浅灰
左玻璃卡：x=0.7", y=1.18", w=7.1", h=5.56", white, left_accent=teal, shadow
  - "一句话概括" 标签：sz=18, bold, teal
  - 一句话核心：sz=20, bold, navy
  - bullet 列表：sz=15.8, dark

右玻璃卡（金色强调）：x=8.05", y=1.18", w=4.55", h=5.56", fill=gold_bg, left_accent=gold, shadow
  - "你可以这样理解" 标签：sz=18, bold, #A2721D
  - 分块卡片：rectRadius=0.05, fill=（交替 #FFF8E9 / #F2E4BF）

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

### 5.4 结果页（slide_type = result）

```
背景：light + header navy + footer 浅灰
左结论卡：x=0.48", y=1.03", w=4.04", h=5.9", white, left_accent=gold, shadow
  - "核心结论" 标签：x=0.82", y=1.22", sz=19, bold, navy
  - bullet: x=0.78", y=1.6", w=3.42", sz=15, dark

图区域：x=4.82", y=1.03", w=8.02", h=3.98", fill=#F4F7FA, border=#C8D5E1
  - 图片：x=4.91", y=1.12", w=7.84", h=3.8", sizing=contain

图解区域：x=4.82", y=5.2", w=8.02", h=1.55", white, border=#C8D5E1
  - "图解" 标签：背景 mint 圆角矩形, sz=12, bold, teal
  - 图解文字：x=5.08", y=5.7", w=7.1", sz=13.2, dark
  - Figure 编号：x=11.1", y=5.4", sz=10.5, mid, 右对齐

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

### 5.5 机制页（slide_type = mechanism）

```
背景：light + header navy + footer 浅灰
左侧路径卡：x=0.62", y=1.16", w=5.0", h=5.55", white, left_accent=teal, shadow
  - "路径主线" 标签：sz=18, bold, teal
  - 步骤条：rectRadius=0.06, h=0.34"
    - 第一个（药物）：fill=teal, white 文字
    - 中间步骤：fill=#EEF5FB, dark 文字
    - 最后（认知改善）：fill=gold, dark/bold 文字
  - 箭头：Chevron shape, fill=#B7CBDC

右侧图区域：x=5.95", y=1.16", w=6.6", h=3.6", #F4F7FA
  - 图片：x=6.05", y=1.28", w=6.4", h=3.35", sizing=contain

底部总结卡：x=5.95", y=5.0", w=6.6", h=1.7", fill=gold_bg, left_accent=gold, shadow

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

### 5.6 总结/优点/局限/意义页（多类型）

```
背景：light + header navy + footer 浅灰
主卡：x=0.72", y=1.18", w=12.0", h=5.56", white, left_accent=teal/gold, shadow

分栏布局（2 列）：
  - cardW = 5.55", gap = 0.28"
  - cardH = 1.72", row_gap = 0.2"
  - 编号圆：diam=0.36", fill=teal/gold, sz=10.5 bold white
  - 条目文字：sz=15.8, dark
```

### 5.7 Take-home message 页

```
背景：light + header navy + footer 浅灰
标签：x=1.05", y=1.45", sz=18, bold, teal

条目卡：x=1.02", y=1.95", w=11.2", h=0.92", rectRadius=0.05
  - 编号"01"/"02"/"03"：x=1.28", sz=18, bold, teal/gold
  - 条目文字：x=2.0", sz=18, dark
  - 交替背景：'EEF7F6' / 'F8FBFD'
```

### 5.8 结束页（slide_type = closing）

```
背景：整页 navy (#13293D)，右栏 navy2
主卡片：x=1.1", y=1.45", w=5.5", h=3.7", rectRadius=0.08, fill=#1D405D

"谢谢！"：x=1.55", y=2.1", w=3.2", h=0.7", sz=30, bold, white
"感谢批评指正！"：x=1.58", y=3.0", w=4.3", h=0.42", sz=21, #DDE9F2
"Questions & Discussion"：x=1.58", y=3.72", sz=14.5, italic, #BDD2E3

右侧装饰框：x=9.2", y=1.75", w=2.65", h=2.65", fill=#274967
金色十字线：水平线 x=9.52", y=3.08", w=2.0", fill=gold
           垂直线 x=10.52", y=2.08", w=0", h=2.0", fill=gold

AD Journal Club 标签：x=9.46", y=4.7", sz=13, #D0DFEB

页码：x=12.4", y=7.02", w=0.28", h=0.14", sz=10, #6B7280
```

---

## 6. 图片嵌入学规范

### 6.1 结果页图片

**规则**：使用 `sizing: { type: 'contain' }`，同时约束宽和高，不做 `cover` 或自由裁切。

```javascript
// 正确做法
slide.addImage({
  path: localImagePath,
  x: 4.91, y: 1.12, w: 7.84, h: 3.8,
  sizing: { type: 'contain', w: 7.84, h: 3.8 }
});
```

**禁止**：
- 直接设置固定的 w/h 而不做 aspect ratio 计算
- 使用 `cover` 模式（会裁切掉原图内容）
- 将图片放在没有任何边框或背景的裸位置

### 6.2 图片加载失败处理

渲染脚本必须在调用 `pptx.addImage()` 前检查图片文件是否存在且非空。若网络图片下载失败（如 HTTP 404），需捕获错误、记录日志，并输出不含该图片的占位页，不得让整个 PPT 生成失败。

---

## 7. 字体渲染规范

### 7.1 字体声明（必须在 PPTX 级别声明）

```javascript
pptx.theme = {
  headFontFace: 'Aptos Display',
  bodyFontFace: 'Aptos',
  lang: 'zh-CN'
};
```

### 7.2 每段文字必须显式指定 fontFace

禁止依赖 PptxGenJS 默认字体。所有 `addText()` 调用必须显式传入 `fontFace` 参数：

```javascript
slide.addText(text, {
  fontFace: 'Aptos Display',  // 标题类
  fontFace: 'Aptos',           // 正文类
  fontSize: 18,
  bold: true,
  color: '13293D',
  margin: 0
});
```

### 7.3 margin 规则

- 所有文字框默认 `margin: 0`（文字贴近边框）
- 只有在文字与相邻元素有明确距离时才设置非零 margin
- 正文 bullet 使用 `margin: 0.03` 以避免首字符贴近边框

---

## 8. 渲染脚本结构规范

每个渲染脚本必须遵循以下结构：

```javascript
// 1. 依赖声明
const PptxGenJS = require('.../pptxgenjs');
const fs = require('fs');
const path = require('path');

// 2. 颜色常量（全部来自本规范，不得自创）
const C = {
  navy: '13293D',
  navy2: '1B3A57',
  teal: '2CA6A4',
  // ... (完整列表见本规范第2节)
};

// 3. 工具函数
function softShadow() { /* 见本规范第4节 */ }
function sanitize(name) { /* 文件名清洗 */ }

// 4. 下载/资源函数
async function ensureAsset(url) { /* 图片下载 + 缓存 */ }

// 5. 布局组件函数（每个 slide_type 一个）
function addBg(slide, dark) { /* 背景 + header + footer */ }
function addTitleSlide(slide) { /* 见本规范5.1 */ }
function addAgendaSlide(slide, s) { /* 见本规范5.2 */ }
function addResultLayout(slide, s) { /* 见本规范5.4 */ }
// ... 以此类推

// 6. 主函数
async function build() {
  // 预下载所有图片
  for (const s of deck.slides) {
    if (s.image_paths_or_urls?.length) {
      s.localImagePath = await ensureAsset(s.image_paths_or_urls[0]);
    }
  }

  // 生成每一页
  deck.slides.forEach((s, i) => {
    const slide = pptx.addSlide();
    // 按 slide_type 分发到对应布局函数
  });

  // 输出
  await pptx.writeFile({ fileName: outputPath });
}
```

---

## 9. 输出文件名规范

见 `references/output-filename-policy.md`。

默认规则：**使用论文英文标题作为文件名**，清洗后附加 `.pptx`。

---

## 10. 质量自检清单（渲染层面）

渲染完成后，逐项确认：
- [ ] 所有 slide 的 header navy 条高度一致（h=0.78"）
- [ ] 所有 slide 的 footer 位置一致（y=7.02"）
- [ ] 所有文字显式指定 fontFace，无默认字体依赖
- [ ] 所有颜色值来自本规范的色板，无自创颜色
- [ ] 所有 shadow 调用使用 `softShadow()` 函数（每次新对象）
- [ ] 所有图片使用 `sizing: { type: 'contain' }`，不裁切
- [ ] 结果页图片区域：x=4.82", y=1.03", w=8.02", h=3.98"
- [ ] 机制页左侧路径卡：x=0.62", w=5.0"
- [ ] 关闭页使用深色背景（navy + navy2），不混用浅色
- [ ] 所有 bullet 正文字号 ≥ 13pt
- [ ] 输出文件名使用论文标题，无泛化名称


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)