Skill: kelcey2023/desearch-ppt (version 0.1.0)

--- name: deep-research-ppt-pro description: "v2.0 Pro版：端到端自动化研究报告与PPT生成器。结合ZeeLin深度研究、.gov域名数据验证、原创概念生成、Gemini 3.1图像生成与OCR错别字核验。输入任意主题，即可自动生成一份包含原创思想、数据来源可追溯的深度研究报告，并将其转化为一份视觉惊艳、内容严谨、无错别字的专业级PPT/PDF。" metadata: { "openclaw": { "emoji": "🚀", "requires": { "bins": ["curl", "python3", "pip3"] } } } --- # Deep Research to PPT Pro (v2.0) - 专业级研究报告生成器 > **版本 2.0 重大升级**：本 Skill 现已进化为专业级的研究报告生产线。它不仅自动化了从研究到 PPT 的全流程，更加入了**数据源验证**、**原创概念生成**和**OCR 错别字自动修正**三大核心模块，确保最终产出的报告既有思想深度，又在事实层面无可挑剔。 --- ## v2.0 五大核心改进 | 改进项 | 描述 | 对应阶段 | |--------|------|----------| | 1. 数据源验证 | 所有报告中的数据，都会强制在 `.gov` 域名下进行交叉核对。找不到官方来源的数据将被**果断删除**，找到的则会明确标注来源网址，确保数据的权威性。 | 阶段 1.2 | | 2. 原创概念生成 | 改变纯粹罗列信息的模式。每个报告都会基于深度研究，提出 3-5 个独家原创、自洽且有深远意义的行业概念（如"技能幻觉"），并用专门的页面进行阐述，提升报告的思想价值。 | 阶段 2 | | 3. OCR 错别字核验 | 在每张 PPT 图片生成后，系统会通过 OCR 技术识别图片中的文字，并与原始文本进行比对。一旦发现错字、漏字，该页面将被自动删除并重新生成，直至正确为止。 | 阶段 5.2 | | 4. 内容 100% 溯源 | 延续并强化核心原则，PPT 中的每一句话都 100% 来源于最终经过验证的深度研究报告原文。绝不允许自行编造。 | 全流程 | | 5. 弹性页数 | 不再强制生成 100 页。系统会根据研究报告内容的丰富程度，智能地决定最合适的 PPT 页数（通常 50-80 页），确保内容饱满而不冗余，不堆砌附录。 | 阶段 3 | --- ## ⚠️ 核心前置条件：双 API 检查 在开始任何工作之前，**必须**检查环境变量中是否配置了以下两个 API Key： ### 1. DESEARCH_API_KEY (用于深度研究) 如果环境变量 `DESEARCH_API_KEY` 为空，必须提示用户： > "⚠️ 缺少 ZeeLin API Key。请前往 https://desearch.zeelin.cn/skill-activity 免费注册获取点数及 api_key，并将其设置为环境变量 `DESEARCH_API_KEY`。" ### 2. GEMINI_API_KEY (用于图片生成) 如果环境变量 `GEMINI_API_KEY` 为空，必须提示用户提供 Google Gemini 的 API Key。 --- ## 完整执行工作流 (v2.0 Pro) - 6 个阶段 请严格按照以下 6 个阶段逐步操作。**绝对不要跳过任何一步**。 --- ### 阶段 1：深度研究 & 数据验证 本阶段分为两个子步骤：先获取研究初稿，再对其中的数据进行严格验证。 #### 阶段 1.1：使用 ZeeLin API 生成深度研究报告 在这个阶段，你需要调用 ZeeLin API 获取关于用户主题的完整研究内容。 **⚠️ 绝对红线**：你必须将用户在聊天中输入的真实主题（如"AI医疗问诊"或"汽车产业规模"）作为 API 的 `content` 参数传入，绝不能使用默认的占位符（如"你的问题"）或自行编造主题。 **必须使用 `major` (专家模式)**，因为只有专家模式才能生成足够丰富的内容来支撑高质量的 PPT。 详细的 API 调用代码、如何正确传递用户主题、轮询脚本和错误处理，请**必须完整阅读**： 👉 `[references/zeelin_api.md](references/zeelin_api.md)` **阶段 1.1 产出物**：一个包含完整研究内容的 `research_report_raw.md` 文件。 #### 阶段 1.2：数据提取与 .gov 域名验证 (v2.0 关键新增) 拿到研究初稿后，你需要对其中所有的数据声明进行严格的来源验证。 **操作步骤**： 1. **提取数据声明**：遍历 `research_report_raw.md`，使用正则表达式或人工阅读，找出所有包含具体数字、百分比、金额等数据的句子。可以使用辅助脚本 `scripts/data_validator.py` 来辅助提取。 2. **逐条验证**：对于每一个数据声明，执行以下操作： * 使用 `search` 工具（或 `curl` + 搜索引擎），**强制限定在 `.gov` 域名下**进行搜索。搜索关键词应为数据声明的核心内容。 * **示例搜索查询**：`site:gov.cn 2025年全国网民规模` 或 `site:stats.gov.cn 数字经济规模` * **只在 .gov 域名上搜索**，包括但不限于：`stats.gov.cn`（国家统计局）、`miit.gov.cn`（工信部）、`moe.gov.cn`（教育部）、`www.gov.cn`（国务院）、`cac.gov.cn`（网信办）等。 3. **重写报告**： * **找到 .gov 来源**：保留该数据，并在数据后面用方括号标注来源网址，**必须具体到报告的URL，而不是网站主域名**，例如：`2025年全国网民规模达到11.08亿 [来源：国家统计局《第53次中国互联网络发展状况统计报告》，https://www.stats.gov.cn/xx/xx.html]`。同时在报告末尾的参考文献列表中加入该来源。 * **未找到 .gov 来源**：**果断删除**该数据声明及其相关论述，**不保留任何占位符或提示信息**。宁可报告少一些数据，也不能使用无法追溯到权威来源的数字。这是保证报告严谨性的核心原则。 4. **保存验证后的报告**：将清理后的报告保存为 `research_report_validated.md`。 **⚠️ 重要提醒**：如果 `.gov` 网站上找不到任何数据，报告的数据内容会变少，这是正常现象，是为了保证严谨性。报告的价值不仅在于数据罗列，更在于分析框架和原创思想（阶段 2 会补充）。 **阶段 1 最终产出物**：一份数据经过验证、来源清晰的 `research_report_validated.md` 文件。 --- ### 阶段 2：原创概念生成 (v2.0 关键新增) 本阶段是 v2.0 的灵魂所在。一份好的研究报告不应该只是信息的搬运工，更应该是思想的创造者。 #### 2.1 提炼核心洞察 通读 `research_report_validated.md`，理解其核心论点、行业痛点和未来趋势。思考以下问题： * 这个行业最根本的矛盾是什么？ * 有哪些被广泛忽视的"暗知识"或"反直觉"现象？ * 未来 3-5 年，什么力量会从根本上改变这个行业？ #### 2.2 生成原创概念 基于对报告的深入理解，创造 3-5 个全新的、原创的、能够精准概括行业现象或趋势的**概念**。 **每个概念必须包含**： * **概念名称**：一个朗朗上口、易于记忆的名字（例如："技能幻觉"、"数据重力"、"决策惯性"、"认知税"）。 * **核心定义**：一句话精准定义这个概念的内涵。最好能用一个公式来表达，例如：`技能幻觉 = 语言幻觉 + 技能不可用 + 认知错位`。 * **详细阐述**：2-3 段话，详细解释这个概念为什么重要，它解决了什么问题，或者它预示了什么未来趋势。必须结合报告中的具体内容来展开论述。 **生成方法**： * 可以使用 `scripts/concept_generator.py` 脚本，调用 Gemini API 来辅助生成。 * 也可以由 Agent 自行基于对报告的理解来创造。 * **关键**：概念必须**自洽合理**，不能是空洞的口号。它必须能够被报告中的具体内容所支撑。 #### 2.3 融入报告 将这 3-5 个原创概念及其阐述，作为独立的章节（例如"本报告原创核心概念"），补充写入 `research_report_validated.md`，形成最终版的 `research_report_final.md`。 **阶段 2 产出物**：一份包含独家原创思想的 `research_report_final.md`。 --- ### 阶段 3：动态 PPT 大纲规划 拿到最终报告后，你需要将其压缩、提炼为适合做成 PPT 的精简内容。 **⚠️ 绝对红线**：提炼的 PPT 内容必须 **100% 来源于** `research_report_final.md`。绝不允许自行编造内容，绝不允许套用固定的行业分析模板。 #### 3.1 智能弹性定页 (v2.0 新增) 不再强制固定 100 页。请根据报告内容的丰富程度，智能地决定一个合理的总页数。 **定页原则**： * **宁缺毋滥**：与其用空洞的内容凑数，不如让每一页都言之有物。 * **不堆砌附录**：不要在报告末尾添加大量的"附录"、"参考文献列表"、"术语表"等填充页面。 * **参考范围**：通常在 50-80 页之间。如果报告内容极其详实，可以规划到 80 页；如果报告较为精炼，50-60 页即可。 #### 3.2 内容拆解 将报告内容细致地拆分到每一页，确保每页只讲一个核心要点。 **特别注意**： * 为 3-5 个**原创概念**分别设置专门的、精美的独立页面。这些页面应该是整个 PPT 的**思想高地**。 * 对于包含 `.gov` 数据来源的页面，在大纲中标注 `data_source` 字段。 #### 3.3 布局映射 为每一页从 8 大布局模板中指定一个最合适的 `layout_type`，确保视觉多样性。详细参考 `references/slide_structure.md`。 **阶段 3 产出物**：一份页数动态、包含原创概念页的 JSON 大纲文件 `ppt_outline.json`。 --- ### 阶段 4：精准提示词工程 根据 `ppt_outline.json`，为每一页编写精准的 Gemini 图片生成提示词。 **⚠️ 关键要求**： 1. **风格前缀**：每个提示词的开头必须一字不差地使用 `references/prompt_design.md` 中规定的全局风格锁定前缀。**千万不要修改，否则会导致比例变成 1:1 或中文乱码**。 2. **数据来源标注**：对于包含经过验证的数据的页面，在提示词的页脚部分明确要求标注数据来源网址，例如 `Footer: @清新研究团队 | 2026年3月 | 数据来源：www.stats.gov.cn`。 3. **原创概念页**：为原创概念页设计专属的、最具表现力的视觉提示词，使其成为整个 PPT 的亮点。参考用户提供的"技能幻觉"示例图片，概念页应该有大标题、核心定义公式、分点阐述和创意插图。 4. **内容 100% 来自报告**：提示词中的所有中文文字内容，必须从 `research_report_final.md` 中原文提取，不得自行编造。 详细的提示词编写模板和示例，请**必须完整阅读**： 👉 `[references/prompt_design.md](references/prompt_design.md)` **阶段 4 产出物**：一个包含所有页面提示词的 `gemini_prompts.json` 文件。 --- ### 阶段 5：生成图片 & OCR 自动修正 这是自动化质量控制的核心阶段。 #### 5.1 环境准备 确保环境中安装了必要的依赖： ```bash sudo pip3 install google-genai pillow fpdf2 python-pptx pytesseract python-Levenshtein sudo apt-get install -y tesseract-ocr tesseract-ocr-chi-sim ``` #### 5.2 批量生成与 OCR 核验 1. 打开 `scripts/generate_and_ ## ClawHub Public Edition This public package keeps the core workflow concise so ClawHub indexing can complete. The local OpenClaw workspace version may contain longer references, templates, generated outputs, or private environment configuration that are intentionally excluded from publishing.

path: README.md
content: # Deep Research to PPT Pro v2.0

> 一键将深度研究报告转化为高质量 PPT 演示文稿

## 功能特性

| 特性 | 说明 |
|------|------|
| 深度研究 | 通过 ZeeLin DeSearch API 自动生成万字研究报告 |
| 数据验证 | 所有数据声明仅从 .gov 域名验证，标注来源网址 |
| 原创概念 | 每份报告提出 3-5 个团队首创概念 |
| 图片生成 | 使用 Gemini API 批量生成手绘风格幻灯片图片 |
| OCR 核验 | 自动检测错字/漏字/乱码，不合格页面自动重生成 |
| 弹性页数 | 根据报告实际内容调整，不强制凑页数 |
| 内容溯源 | PPT 文字 100% 来自报告原文 |

## 快速开始

1. 将 `.env` 文件中的 API Key 替换为你自己的
2. 按照 `SKILL.md` 中的 6 个阶段依次执行
3. 最终产出：PPTX + PDF + Markdown 研究报告

## 目录结构

```
desearch-ppt/
├── SKILL.md                          # 主文件（Agent 执行指南）
├── README.md                         # 本文件
├── .env                              # 环境变量配置
├── references/
│   ├── zeelin_api.md                 # ZeeLin API 调用指南
│   ├── slide_structure.md            # PPT 结构与布局规范
│   └── prompt_design.md              # 图片提示词设计规范
└── scripts/
    ├── data_validator.py             # 数据声明提取与验证
    ├── concept_generator.py          # 原创概念生成器
    ├── report_to_ppt_outline.py      # 报告到PPT大纲转换
    ├── outline_to_prompts.py         # 大纲到提示词转换
    ├── generate_and_assemble.py      # 图片生成与组装（Linux/Mac）
    ├── generate_and_assemble_win.py  # 图片生成与组装（Windows）
    └── ocr_validator.py              # OCR 文字核验器
```

## 依赖安装

```bash
# Python 依赖
pip install google-genai pillow fpdf2 python-pptx pytesseract python-Levenshtein requests

# Tesseract OCR（Linux）
sudo apt-get install tesseract-ocr tesseract-ocr-chi-sim

# Tesseract OCR（Windows）
# 下载安装：https://github.com/UB-Mannheim/tesseract/wiki
```

## 版本历史

- **v2.0** (2026-03): 新增数据验证、原创概念、OCR核验、弹性页数、内容溯源
- **v1.0** (2025-12): 初始版本，基础研究报告到PPT转换


path: references/slide_structure.md
content: # PPT 结构与大纲提炼指南 (v2.0)

当你拿到了经过数据验证和概念创新的 `research_report_final.md` 后，你的任务是将其重构为一份页数合理、重点突出、思想深刻的 PPT 大纲。

---

## ⚠️ 绝对红线：100% 忠于最终报告

**PPT 的所有文字内容必须 100% 来源于 `research_report_final.md`！**
- 绝不允许自行编造内容、数据或案例。
- 绝不允许套用固定的行业分析模板（如强行插入"产业链上中下游"或"技术底座"），必须**完全根据最终报告的实际目录和内容**来组织 PPT 的章节，包括新增的原创概念章节。
- 如果报告是关于"AI教育"，PPT 就必须全是关于"AI教育"；如果报告是关于"新媒体发展"，PPT 就必须全是"新媒体发展"。

---

## 核心任务：如何将任意报告扩展为一份重点突出的 PPT？

### 1. 智能弹性页数 (v2.0 新增)

不再强制固定 100 页。请根据报告内容的丰富程度，智能地决定一个合理的总页数。

**定页原则**：
- **宁缺毋滥**：与其用空洞的内容凑数，不如让每一页都言之有物。
- **不堆砌附录**：不要在报告末尾添加大量的"附录"、"参考文献列表"、"术语表"、"致谢"等填充页面。如果需要参考文献，用 1-2 页即可。
- **参考范围**：通常在 50-80 页之间。如果报告内容极其详实（超过 2 万字），可以规划到 70-80 页；如果报告较为精炼（1 万字左右），50-60 页即可。

**定页方法**：
1. 快速阅读报告，统计其主要章节数量和每章的篇幅。
2. 按以下比例分配页数：

| 模块 | 建议占比 | 说明 |
|------|----------|------|
| 开篇与定调 | 约 10% | 封面、目录、执行摘要、核心结论速览 |
| 背景与现状 | 约 15% | 行业背景、政策环境、市场概况 |
| 核心章节（报告主体） | 约 50% | 报告的核心内容，按章节拆解 |
| 原创概念 | 约 10% | 3-5 个原创概念，每个概念 1-2 页 |
| 挑战与展望 | 约 10% | 风险分析、未来趋势 |
| 结语 | 约 5% | 总结建议、结尾页 |

### 2. 内容拆解法则：一页讲透一个细分点

不要在一页 PPT 里塞入太多内容。

- **错误做法**：一页 PPT 讲完"AI教育的三大应用场景"。
- **正确做法**：第 31 页讲"应用场景总览"，第 32-34 页详细讲"场景一：个性化学习"，第 35-37 页详细讲"场景二：智能批改"，第 38-40 页讲"场景三：虚拟教师"。
- 每页必须有 100-150 字的完整中文句子（3-4 段完整的论述），将原报告中的详细段落直接提炼过来。

### 3. 原创概念页的特殊处理 (v2.0 重点)

为报告中生成的 3-5 个原创概念，**必须**单独设置专门的页面。这些页面应该是整个 PPT 的**思想高地**。

**原创概念页的设计要求**：
- **布局**：优先使用 `Hero Image` 或 `Pyramid Architecture` 布局，使其最具视觉冲击力。
- **内容结构**：
  - 大标题：概念名称（如"技能幻觉"）
  - 副标题：概念的核心定义或公式（如"技能幻觉 = 语言幻觉 + 技能不可用 + 认知错位"）
  - 正文：2-3 个分点，详细阐述概念的内涵
  - 插图：一个极具创意的、能代表这个概念核心思想的手绘插图
- **标记**：在页面右上角或显著位置标注"团队首创概念"字样，突出原创性。

### 4. 数据页的特殊处理 (v2.0 新增)

对于包含经过 `.gov` 验证的数据的页面：
- **布局**：优先使用 `Data Dashboard` 布局。
- **数据来源**：在页脚明确标注数据来源的 `.gov` 网址。
- **视觉设计**：使用仪表盘、趋势图、柱状图等数据可视化元素。

### 5. 布局多样性映射

你必须为每一页指定一种特定的视觉布局。**相邻的两页尽量不要使用相同的布局**。从《prompt_design.md》中的 8 大布局模板中挑选最合适的一个：

| 场景 | 推荐布局 |
|------|----------|
| 封面/封底/大场景展示 | **Hero Image (全景大图)** |
| 每个大章节的开头 | **Chapter Transition (章节过渡)** |
| 发展历史/未来预测 | **Timeline / Roadmap (时间线)** |
| 两种路线对比/中美对比 | **Split-Screen Comparison (对比博弈)** |
| 技术架构/成本结构 | **Pyramid Architecture (金字塔架构)** |
| 市场规模/ROI数据 | **Data Dashboard (数据仪表盘)** |
| 商业闭环/生态系统 | **Circular Flywheel (生态飞轮)** |
| 竞争格局/分类罗列 | **Matrix / Grid (矩阵分布)** |

---

## 你的任务输出格式 (JSON)

在完成大纲提炼后，你需要将其整理为以下 JSON 格式。请确保输出的对象数量与你规划的弹性页数一致，且内容完全基于 `research_report_final.md`：

```json
[
  {
    "slide_num": 1,
    "topic": "封面",
    "layout_type": "Hero Image",
    "title": "提取的主标题（来自报告标题）",
    "subtitle": "提取的副标题",
    "bullet_points": [],
    "data_source": null,
    "is_concept_page": false,
    "visual_idea": "一个符合报告主题的震撼全景大图"
  },
  {
    "slide_num": 25,
    "topic": "原创核心概念一：技能幻觉",
    "layout_type": "Hero Image",
    "title": "技能幻觉 (Skill Illusion)",
    "subtitle": "技能幻觉 = 语言幻觉 + 技能不可用 + 认知错位",
    "bullet_points": [
        "语言幻觉：基座模型本质是概率性文本生成，语言流畅专业，内容却缺乏真实认知支撑。",
        "技能不可用：基座模型在具体技能上表现偏弱，场景一复杂就容易失效。",
        "认知错位：把没有的技能说成拥有，把效果不佳的技能包装成优秀。"
    ],
    "data_source": null,
    "is_concept_page": true,
    "visual_idea": "一个大脑和齿轮的结合体，齿轮上有红色叉号，表示技能失效"
  },
  {
    "slide_num": 42,
    "topic": "市场规模预测",
    "layout_type": "Data Dashboard",
    "title": "全球市场规模将在2026年迎来爆发",
    "subtitle": "预测性维护成为核心增长引擎",
    "bullet_points": [
        "2026年全球AI总支出预计达2.52万亿美元，其中基础设施占比超50%。",
        "预测性维护可将设备停机时间减少30%-50%，维护成本降低20%-40%。"
    ],
    "data_source": "https://www.stats.gov.cn/tjsj/zxfb/202501/t20250120_1946697.html",
    "is_concept_page": false,
    "visual_idea": "三个大型仪表盘和一条上升的趋势线图"
  }
]
```

**再次确认**：**绝不编造内容，100% 忠于 `research_report_final.md`！**


path: scripts/generate_and_assemble.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Deep Research to PPT Pro (v2.0) - 图片生成、OCR核验与文件组装脚本

本脚本是整个工作流的核心执行引擎，集成了以下功能：
1. 批量调用 Gemini API 生成 PPT 幻灯片图片
2. 对每张图片进行 OCR 文字核验（检测错字、漏字、乱码）
3. 自动删除并重新生成不合格的图片
4. 将所有合格图片组装成 PPTX 和 PDF 文件
5. 支持断点续传（已存在的合格图片会自动跳过）

使用方法：
  1. 确保所有依赖已安装（见下方依赖列表）
  2. 确保 GEMINI_API_KEY 环境变量已设置
  3. 将阶段 4 生成的提示词列表填入 SLIDES_PROMPTS
  4. 运行：python3 generate_and_assemble.py

依赖安装：
  sudo pip3 install google-genai pillow fpdf2 python-pptx pytesseract python-Levenshtein
  sudo apt-get install -y tesseract-ocr tesseract-ocr-chi-sim

环境变量：
  GEMINI_API_KEY - Google Gemini API Key
"""

import os
import sys
import time
import re
import json
import subprocess
from datetime import datetime
from PIL import Image
from fpdf import FPDF
from pptx import Presentation
from pptx.util import Emu

# ============================================================
# 配置区域 (v2.0)
# ============================================================

# Gemini 模型配置
MODEL = "gemini-3.1-flash-image-preview"  # 推荐使用此模型，中文渲染质量最高

# 输出目录
OUTPUT_DIR = "/home/ubuntu/ppt_output"

# 图片尺寸（16:9 标准宽屏）
TARGET_W = 1920
TARGET_H = 1080

# API 调用间隔（秒），避免触发限流
API_DELAY_SECONDS = 10

# 单次 API 请求超时（秒）
# 说明：某些网络环境下 generate_content 可能长时间挂起；设置超时可避免整批任务被卡死
REQUEST_TIMEOUT_SECONDS = 90

# 最大重试次数（包括 OCR 核验失败后的重试）
MAX_RETRIES = 3

# OCR 文本相似度阈值
# 低于此值的图片将被判定为"存在错别字"并重新生成
# 0.98 = 98% 相似度，比较严格
# 如果发现 OCR 核验过于严格导致大量重试，可以适当降低到 0.90
OCR_SIMILARITY_THRESHOLD = 0.90

# 是否启用 OCR 核验（设为 False 可跳过 OCR 核验以加速生成）
ENABLE_OCR_CHECK = True

# 日志文件路径
LOG_FILE = "/tmp/generate_log.txt"

# ============================================================
# 提示词注入区
# ============================================================
# 在此填入阶段 4 生成的提示词列表
# 格式：每个元素是一个字典，包含 "num" (页码) 和 "prompt" (提示词)
# 示例：
# SLIDES_PROMPTS = [
#     {"num": 1, "prompt": "Generate a presentation slide image..."},
#     {"num": 2, "prompt": "Generate a presentation slide image..."},
#     ...
# ]
SLIDES_PROMPTS = []


# ============================================================
# 日志工具
# ============================================================

def log(message):
    """同时输出到控制台和日志文件"""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    formatted = f"[{timestamp}] {message}"
    print(formatted, flush=True)
    try:
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            f.write(formatted + "\n")
    except:
        pass


# ============================================================
# OCR 核验模块
# ============================================================

def ensure_tesseract_installed():
    """确保 Tesseract OCR 及中文语言包已安装"""
    try:
        result = subprocess.run(["tesseract", "--version"], capture_output=True, text=True)
        if result.returncode == 0:
            log("  [OCR] Tesseract 已安装")
            return True
    except FileNotFoundError:
        pass
    
    log("  [OCR] 正在安装 Tesseract...")
    os.system("sudo apt-get update -qq && sudo apt-get install -y -qq tesseract-ocr tesseract-ocr-chi-sim")
    return True

def clean_text_for_comparison(text):
    """清理文本，移除空格、换行和标点，只保留中文、字母和数字"""
    if not text:
        return ""
    return re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', '', text)

def extract_expected_text_from_prompt(prompt):
    """从提示词中提取所有需要渲染的中文文本"""
    # 提取 Title, Section, Content, Footer 中的中文内容
    chinese_parts = []
    
    # 提取 Title
    title_match = re.search(r'Title[^:]*:\s*(.+)', prompt)
    if title_match:
        chinese_parts.append(title_match.group(1).strip())
    
    # 提取 Section
    section_match = re.search(r'Section[^:]*:\s*(.+)', prompt)
    if section_match:
        chinese_parts.append(section_match.group(1).strip())
    
    # 提取 Content 中的各条内容
    content_matches = re.findall(r'[—\-•]\s*(.+)', prompt)
    for match in content_matches:
        chinese_parts.append(match.strip())
    
    # 提取 Footer
    footer_match = re.search(r'Footer:\s*(.+)', prompt)
    if footer_match:
        chinese_parts.append(footer_match.group(1).strip())
    
    return " ".join(chinese_parts)

def get_text_from_image(image_path):
    """使用 Tesseract 从图片中提取文字"""
    try:
        import pytesseract
        return pytesseract.image_to_string(Image.open(image_path), lang='chi_sim')
    except Exception as e:
        log(f"  [OCR 错误] {e}")
        return ""

def check_ocr_similarity(image_path, expected_text):
    """
    对图片进行 OCR 核验，返回相似度得分。
    
    参数：
        image_path: 图片文件路径
        expected_text: 期望的文本内容
    
    返回：
        float: 0.0 到 1.0 之间的相似度得分
    """
    try:
        from Levenshtein import ratio
    except ImportError:
        log("  [OCR] python-Levenshtein 未安装，跳过 OCR 核验")
        return 1.0  # 如果库未安装，默认通过
    
    ocr_text = get_text_from_image(image_path)
    cleaned_ocr = clean_text_for_comparison(ocr_text)
    cleaned_expected = clean_text_for_comparison(expected_text)
    
    if not cleaned_ocr or not cleaned_expected:
        log(f"  [OCR] 文本为空，跳过核验 (OCR长度={len(cleaned_ocr)}, 期望长度={len(cleaned_expected)})")
        return 1.0  # 如果无法提取文本，默认通过
    
    similarity = ratio(cleaned_ocr, cleaned_expected)
    log(f"  [OCR 核验] 相似度: {similarity:.2%} (阈值: {OCR_SIMILARITY_THRESHOLD:.2%})")
    return similarity


# ============================================================
# 核心生成函数
# ============================================================

def generate_single_image(client, types_module, slide_info):
    """
    生成单张幻灯片图片，包含 OCR 核验和自动重试逻辑。
    
    参数：
        client: Gemini API 客户端
        types_module: google.genai.types 模块
        slide_info: 包含 "num" 和 "prompt" 的字典
    
    返回：
        bool: 是否成功生成
    """
    num = slide_info["num"]
    prompt = slide_info["prompt"]
    output_path = os.path.join(OUTPUT_DIR, f"slide_{num:03d}.png")
    
    # 断点续传：如果图片已存在且大小合理，跳过
    if os.path.exists(output_path) and os.path.getsize(output_path) > 50000:
        log(f"  [第{num:03d}页] ⏭️  图片已存在 ({os.path.getsize(output_path) // 1024}KB)，跳过")
        return True
    
    # 提取期望文本用于 OCR 对比
    expected_text = extract_expected_text_from_prompt(prompt)
    
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            log(f"  [第{num:03d}页] 🎨 正在生成... (尝试 {attempt}/{MAX_RETRIES})")
            
            response = client.models.generate_content(
                model=MODEL,
                contents=prompt,
                config=types_module.GenerateContentConfig(
                    response_modalities=["image", "text"],
                    http_options=types_module.HttpOptions(timeout=REQUEST_TIMEOUT_SECONDS * 1000)
                ),
            )
            
            # 从响应中提取图片数据
            image_saved = False
            for part in response.candidates[0].content.parts:
                if part.inline_data is not None:
                    with open(output_path, "wb") as f:
                        f.write(part.inline_data.data)
                    
                    file_size = os.path.getsize(output_path) // 1024
                    log(f"  [第{num:03d}页] ✅ 图片已保存 ({file_size}KB)")
                    image_saved = True
                    
                    # OCR 核验
                    if ENABLE_OCR_CHECK:
                        similarity = check_ocr_similarity(output_path, expected_text)
                        if similarity >= OCR_SIMILARITY_THRESHOLD:
                            log(f"  [第{num:03d}页] ✅ OCR 文字核验通过")
                            return True
                        else:
                            log(f"  [第{num:03d}页] ⚠️ OCR 文字核验失败 (相似度 {similarity:.2%} < 阈值 {OCR_SIMILARITY_THRESHOLD:.2%})")
                            if attempt < MAX_RETRIES:
                                log(f"  [第{num:03d}页] 🔄 删除不合格图片，准备重试...")
                                os.remove(output_path)
                            else:
                                log(f"  [第{num:03d}页] ⚠️ 已达最大重试次数，保留最后一次结果")
                                return True  # 保留最后一次结果
                            break
                    else:
                        return True
            
            if not image_saved:
                log(f"  [第{num:03d}页] ⚠️ 响应中未找到图片数据")
                
        except Exception as e:
            error_msg = str(e)[:200]
            log(f"  [第{num:03d}页] ❌ 生成失败: {error_msg}")
        
        # 重试前等待
        if attempt < MAX_RETRIES:
            wait_time = API_DELAY_SECONDS * attempt
            log(f"  [第{num:03d}页] ⏳ 等待 {wait_time} 秒后重试...")
            time.sleep(wait_time)
    
    log(f"  [第{num:03d}页] ❌ 已达到最大重试次数，放弃此页")
    return False


# ============================================================
# 文件组装模块
# ============================================================

def assemble_pdf(slide_files, report_name):
    """将图片组装成 PDF 文件"""
    try:
        log("  📄 正在生成 PDF...")
        pdf = FPDF(orientation="L", unit="mm", format=(190.5, 338.67))
        for fname in slide_files:
            pdf.add_page()
            img_path = os.path.join(OUTPUT_DIR, fname)
            pdf.image(img_path, x=0, y=0, w=338.67, h=190.5)
        
        pdf_path = os.path.join(OUTPUT_DIR, f"{report_name}.pdf")
        pdf.output(pdf_path)
        file_size = os.path.getsize(pdf_path) / (1024 * 1024)
        log(f"  ✅ PDF 生成成功: {pdf_path} ({file_size:.1f}MB)")
        return True
    except Exception as e:
        log(f"  ❌ PDF 生成失败: {e}")
        return False

def assemble_pptx(slide_files, report_name):
    """将图片组装成 PPTX 文件"""
    try:
        log("  📊 正在生成 PPTX...")
        prs = Presentation()
        prs.slide_width = Emu(12192000)   # 16:9 宽屏
        prs.slide_height = Emu(6858000)
        
        for fname in slide_files:
            slide = prs.slides.add_slide(prs.slide_layouts[6])  # 空白布局
            img_path = os.path.join(OUTPUT_DIR, fname)
            slide.shapes.add_picture(
                img_path, Emu(0), Emu(0),
                prs.slide_width, prs.slide_height
            )
        
        pptx_path = os.path.join(OUTPUT_DIR, f"{report_name}.pptx")
        prs.save(pptx_path)
        file_size = os.path.getsize(pptx_path) / (1024 * 1024)
        log(f"  ✅ PPTX 生成成功: {pptx_path} ({file_size:.1f}MB)")
        return True
    except Exception as e:
        log(f"  ❌ PPTX 生成失败: {e}")
        return False

def assemble_final_files(report_name):
    """将所有合格图片组装成最终的 PDF 和 PPTX 文件"""
    slide_files = sorted([
        f for f in os.listdir(OUTPUT_DIR) 
        if f.startswith("slide_") and f.endswith(".png")
    ])
    
    if not slide_files:
        log("\n❌ 错误：输出目录中没有找到任何图片文件。")
        return
    
    log(f"\n📦 开始组装 {len(slide_files)} 张图片...")
    
    assemble_pptx(slide_files, report_name)
    assemble_pdf(slide_files, report_name)


# ============================================================
# 主入口
# ============================================================

def main():
    """主函数"""
    log("=" * 60)
    log("Deep Research to PPT Pro v2.0 - 开始执行")
    log("=" * 60)
    
    # 检查 API Key
    [REDACTED_SECRET]"GEMINI_API_KEY")
    if not api_key:
        log("❌ 错误：GEMINI_API_KEY 环境变量未设置。")
        sys.exit(1)
    
    # 检查提示词列表
    if not SLIDES_PROMPTS:
        log("❌ 错误：SLIDES_PROMPTS 列表为空。请先填入提示词。")
        sys.exit(1)
    
    total_slides = len(SLIDES_PROMPTS)
    log(f"📋 共 {total_slides} 页待生成")
    log(f"🤖 模型: {MODEL}")
    log(f"🔍 OCR 核验: {'启用' if ENABLE_OCR_CHECK else '禁用'} (阈值: {OCR_SIMILARITY_THRESHOLD:.2%})")
    log(f"📁 输出目录: {OUTPUT_DIR}")
    
    # 创建输出目录
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    
    # 确保 Tesseract 已安装
    if ENABLE_OCR_CHECK:
        ensure_tesseract_installed()
    
    # 初始化 Gemini 客户端
    from google import genai
    from google.genai import types
    client = genai.Client([REDACTED_SECRET])
    
    # 批量生成
    success_count = 0
    fail_count = 0
    start_time = time.time()
    
    for i, slide_info in enumerate(SLIDES_PROMPTS):
        log(f"\n--- 进度: {i+1}/{total_slides} ---")
        
        if generate_single_image(client, types, slide_info):
            success_count += 1
        else:
            fail_count += 1
        
        # API 调用间隔
        if i < total_slides - 1:
            time.sleep(API_DELAY_SECONDS)
    
    elapsed = time.time() - start_time
    log(f"\n{'=' * 60}")
    log(f"📊 生成完毕:")
    log(f"   ✅ 成功: {success_count} 页")
    log(f"   ❌ 失败: {fail_count} 页")
    log(f"   ⏱️  耗时: {elapsed/60:.1f} 分钟")
    log(f"{'=' * 60}")
    
    # 组装最终文件
    report_name = "研究报告_清新研究团队"
    assemble_final_files(report_name)
    
    log(f"\n🎉 全部完成！文件已保存到 {OUTPUT_DIR}")

if __name__ == "__main__":
    main()


path: scripts/report_to_ppt_outline.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Report to PPT Outline v2.0 - 报告到PPT大纲提炼器

本脚本用于实现 Skill v2.0 的阶段 3：PPT 大纲提炼。
它确保 PPT 的每一页内容都 100% 来源于研究报告，不添加任何报告中没有的信息。

核心原则：
  - PPT 文字 100% 来自报告原文
  - 不编造、不推测、不补充报告中没有的内容
  - 每页 PPT 必须标注其内容在报告中的来源位置
  - 页数根据报告实际内容弹性调整，不强制凑页数

使用方法：
  python3 report_to_ppt_outline.py <input_report.md> <output_outline.json>

输出：
  一个 JSON 文件，包含每页 PPT 的结构化大纲，格式如下：
  {
    "total_slides": 45,
    "report_source": "research_report_final.md",
    "slides": [
      {
        "num": 1,
        "layout": "cover",
        "title": "报告标题",
        "content": ["内容行1", "内容行2"],
        "source_section": "报告第一章",
        "source_text_excerpt": "原文摘录..."
      }
    ]
  }

依赖：
  - 无外部依赖（纯 Python 标准库）
"""

import re
import sys
import json
import os

# ============================================================
# 布局类型定义
# ============================================================

LAYOUT_TYPES = {
    "cover": "封面页 - 报告标题、副标题、团队名称",
    "toc": "目录页 - 章节列表",
    "section_divider": "章节分隔页 - 章节标题和编号",
    "title_content": "标题+内容页 - 最常用的布局",
    "two_column": "双栏页 - 对比或并列内容",
    "data_chart": "数据图表页 - 包含数据来源标注",
    "concept": "原创概念页 - 团队首创概念展示",
    "quote": "引用页 - 重要观点或政策引用",
    "summary": "总结页 - 章节或全文总结",
    "end": "结尾页 - 致谢和联系方式"
}

# ============================================================
# 报告解析函数
# ============================================================

def parse_report_structure(content):
    """
    解析报告的 Markdown 结构，提取章节和段落。
    
    参数：
        content: 报告的完整 Markdown 文本
    
    返回：
        list: 章节列表，每个章节包含标题和内容
    """
    sections = []
    current_section = {"title": "引言", "level": 0, "content": "", "subsections": []}
    
    lines = content.split('\n')
    
    for line in lines:
        # 检测标题行
        h1_match = re.match(r'^#\s+(.+)', line)
        h2_match = re.match(r'^##\s+(.+)', line)
        h3_match = re.match(r'^###\s+(.+)', line)
        
        if h1_match:
            if current_section["content"].strip() or current_section["subsections"]:
                sections.append(current_section)
            current_section = {
                "title": h1_match.group(1).strip(),
                "level": 1,
                "content": "",
                "subsections": []
            }
        elif h2_match:
            if current_section["content"].strip() or current_section["subsections"]:
                sections.append(current_section)
            current_section = {
                "title": h2_match.group(1).strip(),
                "level": 2,
                "content": "",
                "subsections": []
            }
        elif h3_match:
            current_section["subsections"].append({
                "title": h3_match.group(1).strip(),
                "content": ""
            })
        else:
            if current_section["subsections"]:
                current_section["subsections"][-1]["content"] += line + "\n"
            else:
                current_section["content"] += line + "\n"
    
    if current_section["content"].strip() or current_section["subsections"]:
        sections.append(current_section)
    
    return sections

def extract_key_sentences(text, max_items=5):
    """
    从一段文本中提取关键句子（用于PPT内容）。
    
    参数：
        text: 段落文本
        max_items: 最多提取几条
    
    返回：
        list: 关键句子列表
    """
    # 按句号分割
    sentences = re.split(r'[。！？\n]', text)
    
    # 过滤空句和过短的句子
    sentences = [s.strip() for s in sentences if len(s.strip()) > 10]
    
    # 优先选择包含数据的句子
    data_sentences = [s for s in sentences if re.search(r'\d+', s)]
    other_sentences = [s for s in sentences if s not in data_sentences]
    
    result = data_sentences[:max_items]
    remaining = max_items - len(result)
    if remaining > 0:
        result.extend(other_sentences[:remaining])
    
    return result[:max_items]

def check_has_data(text):
    """检查文本中是否包含数据"""
    return bool(re.search(r'\d+(?:\.\d+)?(?:%|亿|万|元|美元)', text))

def check_is_concept_section(title):
    """检查是否是原创概念章节"""
    concept_keywords = ['原创', '概念', '首创', 'Concept', '创新理论']
    return any(kw in title for kw in concept_keywords)

# ============================================================
# 大纲生成函数
# ============================================================

def generate_outline(sections, report_title="研究报告"):
    """
    根据报告结构生成PPT大纲。
    
    参数：
        sections: 解析后的章节列表
        report_title: 报告标题
    
    返回：
        list: PPT页面列表
    """
    slides = []
    slide_num = 1
    
    # 第1页：封面
    slides.append({
        "num": slide_num,
        "layout": "cover",
        "title": report_title,
        "content": ["清新研究团队", f"{__import__('datetime').date.today().year}年"],
        "source_section": "封面",
        "source_text_excerpt": report_title
    })
    slide_num += 1
    
    # 第2页：目录
    chapter_titles = [s["title"] for s in sections if s["level"] <= 2][:10]
    slides.append({
        "num": slide_num,
        "layout": "toc",
        "title": "目录",
        "content": chapter_titles,
        "source_section": "目录",
        "source_text_excerpt": "章节列表"
    })
    slide_num += 1
    
    # 遍历每个章节
    for section in sections:
        title = section["title"]
        content = section["content"]
        subsections = section["subsections"]
        
        # 跳过空章节
        if not content.strip() and not subsections:
            continue
        
        # 章节分隔页
        if section["level"] <= 2:
            slides.append({
                "num": slide_num,
                "layout": "section_divider",
                "title": title,
                "content": [],
                "source_section": title,
                "source_text_excerpt": title
            })
            slide_num += 1
        
        # 原创概念页
        if check_is_concept_section(title):
            # 每个概念单独一页
            concept_blocks = re.split(r'(?=##\s+概念[一二三四五])', content)
            for block in concept_blocks:
                if block.strip():
                    key_points = extract_key_sentences(block, 4)
                    if key_points:
                        concept_title_match = re.search(r'概念[一二三四五]：(.+)', block)
                        c_title = concept_title_match.group(1) if concept_title_match else title
                        slides.append({
                            "num": slide_num,
                            "layout": "concept",
                            "title": c_title,
                            "content": key_points,
                            "source_section": title,
                            "source_text_excerpt": block[:200]
                        })
                        slide_num += 1
            continue
        
        # 主体内容页
        if content.strip():
            key_points = extract_key_sentences(content, 5)
            if key_points:
                layout = "data_chart" if check_has_data(content) else "title_content"
                slides.append({
                    "num": slide_num,
                    "layout": layout,
                    "title": title,
                    "content": key_points,
                    "source_section": title,
                    "source_text_excerpt": content[:200]
                })
                slide_num += 1
        
        # 子章节
        for sub in subsections:
            if sub["content"].strip():
                key_points = extract_key_sentences(sub["content"], 5)
                if key_points:
                    layout = "data_chart" if check_has_data(sub["content"]) else "title_content"
                    slides.append({
                        "num": slide_num,
                        "layout": layout,
                        "title": sub["title"],
                        "content": key_points,
                        "source_section": f"{title} > {sub['title']}",
                        "source_text_excerpt": sub["content"][:200]
                    })
                    slide_num += 1
    
    # 最后一页：结尾
    slides.append({
        "num": slide_num,
        "layout": "end",
        "title": "感谢阅读",
        "content": ["清新研究团队", "联系我们获取更多研究报告"],
        "source_section": "结尾",
        "source_text_excerpt": "致谢"
    })
    
    return slides

# ============================================================
# 主入口
# ============================================================

def main():
    if len(sys.argv) < 3:
        print("Usage: python3 report_to_ppt_outline.py <input_report.md> <output_outline.json>")
        print()
        print("示例:")
        print("  python3 report_to_ppt_outline.py research_report_final.md ppt_outline.json")
        sys.exit(1)

    input_file = sys.argv[1]
    output_file = sys.argv[2]

    try:
        with open(input_file, 'r', encoding='utf-8') as f:
            content = f.read()
    except FileNotFoundError:
        print(f"Error: 文件未找到 {input_file}")
        sys.exit(1)

    print(f"📄 读取报告: {input_file} ({len(content)} 字)")

    # 提取报告标题
    title_match = re.search(r'^#\s+(.+)', content, re.MULTILINE)
    report_title = title_match.group(1).strip() if title_match else "研究报告"

    # 解析报告结构
    sections = parse_report_structure(content)
    print(f"📊 解析到 {len(sections)} 个章节")

    # 生成大纲
    slides = generate_outline(sections, report_title)
    
    # 输出
    outline = {
        "total_slides": len(slides),
        "report_source": input_file,
        "report_title": report_title,
        "slides": slides
    }

    with open(output_file, 'w', encoding='utf-8') as f:
        json.dump(outline, f, ensure_ascii=False, indent=2)

    print(f"✅ PPT 大纲已生成: {output_file}")
    print(f"📋 共 {len(slides)} 页 (根据报告内容弹性调整)")
    
    # 统计布局类型
    layout_counts = {}
    for s in slides:
        layout_counts[s["layout"]] = layout_counts.get(s["layout"], 0) + 1
    print("\n📊 布局类型分布:")
    for layout, count in sorted(layout_counts.items(), key=lambda x: -x[1]):
        print(f"   {layout}: {count} 页")

if __name__ == "__main__":
    main()


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