PPT与播客智能生成系统设计与实现
本文详细介绍了创意工坊(Creative Workshop)模块中PPT演示文稿和播客音频的智能生成系统,从需求分析、架构设计到核心实现,全面记录了整个开发过程。
目录
- 一、项目背景与需求分析
- 二、系统架构设计
- 三、数据库设计
- 四、API接口设计
- 五、核心功能实现:PPT生成
- 六、核心功能实现:播客生成
- 七、AI提示词工程
- 八、SSE流式响应实现
- 九、错误处理与重试机制
- 十、总结与最佳实践
一、项目背景与需求分析
1.1 项目背景
在信息爆炸的时代,用户面临着大量的学习资料、会议记录、文档等内容需要消化和整理。传统的内容消费方式效率低下,用户希望能够:
- 快速理解内容:通过AI生成的概览快速把握核心要点
- 多形式输出:将同一份内容转换为不同的媒体形式(PPT、播客、视频等)
- 个性化定制:根据不同场景选择不同的风格和形式
基于以上需求,我们设计并实现了**创意工坊(Creative Workshop)**模块,这是一个多模态内容生成平台,支持将用户上传的文档、笔记等内容智能转换为多种媒体形式。
1.2 功能需求
创意工坊支持以下媒体类型的生成:
| 媒体类型 | 代码 | 图标 | 文件格式 | 描述 |
|---|---|---|---|---|
| 播客音频 | audio | 🎧 | mp3/wav | 生成对话式播客音频 |
| 视频 | video | 🎬 | mp4 | 生成讲解视频 |
| PPT演示 | ppt | 📊 | pptx | 生成演示文稿 |
| 测验题目 | quiz | 📝 | json | 生成测验题目 |
| 思维导图 | mindmap | 🧠 | md | 生成思维导图 |
1.3 核心用户流程
创建工坊 → 添加来源 → 生成概览 → 选择媒体类型 → 配置参数 → 生成脚本(SSE) → 预览 → 创建任务(SSE) → 完成
详细流程说明:
- 创建工坊:用户创建一个新的创意工坊,作为内容创作的容器
- 添加来源:支持上传文件(PDF、Word、Markdown等)或关联已有笔记
- 生成概览:AI分析所有来源内容,生成工坊概览和推荐问题
- 选择媒体类型:用户选择要生成的媒体类型(PPT、播客等)
- 配置参数:根据媒体类型配置相应参数(如播客风格、PPT详细度等)
- 生成脚本:AI生成大纲和脚本,通过SSE流式返回
- 预览确认:用户预览生成的大纲和脚本
- 创建任务:确认后创建生成任务,通过SSE实时推送进度
- 完成下载:任务完成后可下载或在线预览生成的媒体文件
二、系统架构设计
2.1 整体架构
创意工坊采用分层架构设计,主要包含以下层次:
┌─────────────────────────────────────────────────────────────┐
│ 前端应用层 │
│ (Flutter/Web Client) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ API网关层 │
│ (Spring Boot Controller) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CreativeWorkshopController │ │
│ │ - 工坊管理 API │ │
│ │ - 来源管理 API │ │
│ │ - 脚本生成 API (SSE) │ │
│ │ - 任务管理 API (SSE) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 业务服务层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CreativeWorkshopServiceImpl │ │
│ │ - 工坊/来源/任务管理 │ │
│ │ - AI大纲/脚本生成 │ │
│ │ - 媒体生成调度 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PptEnhanced │ │ PodcastService │ │
│ │ SlideStructure │ │ Impl │ │
│ │ Generator │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 外部服务层 │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ AI Model API │ │ SoulX-Podcast │ │
│ │ (yantronic-m1) │ │ API │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ S3 Storage │ │ FeiShu Alert │ │
│ │ (EBCloud S3) │ │ Service │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 数据持久层 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ MySQL Database │ │
│ │ - creative_workshop (工坊表) │ │
│ │ - creative_source (来源表) │ │
│ │ - creative_task (任务表) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 核心组件说明
2.2.1 CreativeWorkshopServiceImpl
这是创意工坊的核心服务实现类,负责:
- 工坊管理:创建、查询、删除工坊
- 来源管理:添加、删除、排序来源文件
- 概览生成:调用AI生成工坊概览
- 脚本生成:生成大纲和脚本
- 任务调度:根据媒体类型调度不同的生成服务
java
// 核心配置
private static final String AI_MODEL = ""; // AI模型(128k上下文)
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
private static final long RETRY_DELAY_MS = 2000; // 重试延迟
2.2.2 PptEnhancedSlideStructureGenerator
PPT页面结构生成器,负责:
- AI布局选择:使用AI智能选择每页的布局类型
- 规则回退:AI失败时使用规则模式生成
- 配图提示词:为需要配图的页面生成英文提示词
2.2.3 PodcastServiceImpl
播客服务实现,负责:
- 脚本转换:将脚本转换为对话格式
- 异步任务提交:调用SoulX-Podcast API
- 状态轮询:轮询任务状态直到完成
- 音频上传:将生成的音频上传到S3
2.3 技术栈
| 层次 | 技术选型 |
|---|---|
| 后端框架 | Spring Boot 3.x |
| ORM框架 | MyBatis-Plus |
| 数据库 | MySQL 8.0 |
| 对象存储 | EBCloud S3 |
| AI模型 | deepseek (128k context) |
| 播客服务 | SoulX-Podcast (Python) |
| 实时通信 | Server-Sent Events (SSE) |
| HTTP客户端 | WebClient (Reactive) |
三、数据库设计
3.1 ER图
┌─────────────────────┐ ┌─────────────────────┐
│ creative_workshop │ │ creative_source │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │───┐ │ id (PK) │
│ uid │ │ │ source_id (UK) │
│ title │ │ │ workshop_id (FK)────┼───┐
│ icon │ │ │ uid │ │
│ summary │ │ │ source_type │ │
│ suggested_questions │ │ │ file_key │ │
│ cover_image │ │ │ note_id │ │
│ source_count │ │ │ content │ │
│ total_word_count │ │ │ selected │ │
│ summary_generate_ │ │ │ sort_order │ │
│ time │ │ │ status │ │
│ status │ │ │ create_time │ │
│ create_time │ │ │ update_time │ │
│ update_time │ │ └─────────────────────┘ │
└─────────────────────┘ │ │
│ ┌─────────────────────┐ │
│ │ creative_task │ │
│ ├─────────────────────┤ │
│ │ id (PK) │ │
│ │ task_id (UK) │ │
└───┼─workshop_id (FK) │ │
│ uid │ │
│ title │ │
│ media_type │ │
│ audio_style │ │
│ voice_preference │ │
│ video_style │ │
│ ppt_style │ │
│ outline_json │ │
│ script_json │ │
│ status │ │
│ progress │ │
│ file_key │ │
│ duration │ │
│ slide_count │ │
│ error_message │ │
│ create_time │ │
│ update_time │ │
│ complete_time │ │
└─────────────────────┘
3.2 表结构详解
3.2.1 creative_workshop(工坊表)
sql
CREATE TABLE `creative_workshop` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID(同时作为工坊ID)',
`uid` varchar(64) NOT NULL COMMENT '用户ID',
`title` varchar(255) DEFAULT '未命名工坊' COMMENT '工坊标题',
`icon` varchar(32) DEFAULT NULL COMMENT '工坊图标(emoji)',
`summary` text COMMENT '工坊概览/摘要',
`suggested_questions` text COMMENT 'AI生成的推荐问题(JSON数组)',
`cover_image` varchar(255) DEFAULT NULL COMMENT '封面图片URL',
`source_count` int DEFAULT NULL COMMENT '生成概览时的来源文件数量',
`total_word_count` int DEFAULT NULL COMMENT '生成概览时的总字数',
`summary_generate_time` datetime DEFAULT NULL COMMENT '概览生成时间',
`summary_like_status` int DEFAULT 0 COMMENT '概览点赞状态:0-未操作,1-点赞,-1-踩',
`summary_feedback` text COMMENT '概览反馈内容',
`status` int DEFAULT 1 COMMENT '状态:0-已删除,1-正常',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意工坊表';
3.2.2 creative_source(来源表)
sql
CREATE TABLE `creative_source` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`source_id` varchar(64) NOT NULL COMMENT '来源ID(业务主键)',
`workshop_id` bigint NOT NULL COMMENT '工坊ID',
`uid` varchar(64) NOT NULL COMMENT '用户ID',
`source_type` varchar(32) NOT NULL COMMENT '来源类型:file/note/text',
`source_name` varchar(255) DEFAULT NULL COMMENT '来源名称',
`file_key` varchar(255) DEFAULT NULL COMMENT '文件Key(S3存储路径)',
`note_id` int DEFAULT NULL COMMENT '笔记ID(关联note表)',
`content` text COMMENT '文本内容(text类型使用)',
`selected` int DEFAULT 1 COMMENT '是否选中:0-未选中,1-选中',
`sort_order` int DEFAULT 0 COMMENT '排序顺序',
`status` int DEFAULT 1 COMMENT '状态:0-已删除,1-正常',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_source_id` (`source_id`),
KEY `idx_workshop_id` (`workshop_id`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意工坊来源表';
3.2.3 creative_task(任务表)
sql
CREATE TABLE `creative_task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`task_id` varchar(64) NOT NULL COMMENT '任务ID(业务主键)',
`workshop_id` bigint NOT NULL COMMENT '工坊ID',
`uid` varchar(64) NOT NULL COMMENT '用户ID',
`title` varchar(255) DEFAULT NULL COMMENT '作品标题',
`media_type` varchar(32) NOT NULL COMMENT '媒体类型:audio/video/ppt/quiz/mindmap',
`audio_style` varchar(32) DEFAULT NULL COMMENT '音频风格:qa/debate/overview/review',
`voice_preference` varchar(32) DEFAULT NULL COMMENT '声音偏好',
`video_style` varchar(32) DEFAULT NULL COMMENT '视频风格:detailed/simple',
`ppt_style` varchar(32) DEFAULT NULL COMMENT 'PPT风格:detailed/simple',
`mindmap_style` varchar(32) DEFAULT NULL COMMENT '思维导图风格',
`quiz_count` int DEFAULT NULL COMMENT '测验题目数量',
`quiz_difficulty` varchar(32) DEFAULT NULL COMMENT '测验难度',
`ai_instructions` text COMMENT 'AI指令/自定义要求',
`outline_json` text COMMENT '大纲JSON',
`outline_markdown` text COMMENT '大纲Markdown',
`script_json` text COMMENT '脚本JSON',
`source_count` int DEFAULT NULL COMMENT '使用的来源数量',
`status` int DEFAULT 0 COMMENT '任务状态:0-待处理,1-处理中,2-已完成,3-失败',
`progress` int DEFAULT 0 COMMENT '处理进度(0-100)',
`file_key` varchar(255) DEFAULT NULL COMMENT '结果文件Key',
`duration` int DEFAULT NULL COMMENT '时长(秒)',
`slide_count` int DEFAULT NULL COMMENT '幻灯片数量',
`error_message` text COMMENT '错误信息',
`deleted` int DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`complete_time` datetime DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_id` (`task_id`),
KEY `idx_workshop_id` (`workshop_id`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意工坊任务表';
3.3 状态枚举定义
任务状态(WorkshopTaskStatusEnum)
| 状态码 | 名称 | 说明 |
|---|---|---|
| 0 | PENDING | 待处理(脚本已生成,等待用户确认) |
| 1 | PROCESSING | 处理中(正在生成媒体文件) |
| 2 | COMPLETED | 已完成 |
| 3 | FAILED | 失败 |
媒体类型(WorkshopMediaTypeEnum)
| 代码 | 名称 | 图标 | 文件扩展名 |
|---|---|---|---|
| audio | 播客音频 | 🎧 | mp3/wav |
| video | 视频 | 🎬 | mp4 |
| ppt | PPT演示 | 📊 | pptx |
| quiz | 测验题目 | 📝 | json |
| mindmap | 思维导图 | 🧠 | md |
四、API接口设计
4.1 接口概览
创意工坊API采用RESTful风格设计,所有接口以 /workshop 为前缀。
/workshop
├── /create POST 创建工坊
├── /list GET 获取工坊列表
├── /{workshopId} GET 获取工坊详情
├── /{workshopId} DELETE 删除工坊
├── /{workshopId}/rename PUT 重命名工坊
├── /{workshopId}/source POST 添加来源
├── /{workshopId}/source/list GET 获取来源列表
├── /{workshopId}/source/select PUT 更新来源选中状态
├── /{workshopId}/source/{sourceId} DELETE 删除来源
├── /{workshopId}/summary/generate POST 生成概览 (SSE)
├── /{workshopId}/summary GET 获取概览
├── /{workshopId}/script/generate POST 生成脚本 (SSE)
├── /{workshopId}/script/{scriptId} GET 获取脚本详情
├── /{workshopId}/task/create POST 创建任务 (SSE)
├── /{workshopId}/task/list GET 获取任务列表
├── /{workshopId}/task/{taskId} GET 获取任务详情
├── /{workshopId}/task/{taskId}/status GET 获取任务状态
└── /media/options GET 获取媒体类型选项
4.2 核心接口详解
4.2.1 生成脚本接口(SSE)
请求
http
POST /workshop/{workshopId}/script/generate
Content-Type: application/json
Accept: text/event-stream
{
"mediaType": "audio",
"audioStyle": "qa",
"voicePreference": "professional",
"scriptId": null // 重新生成时传入已有scriptId
}
SSE事件流
event: status
data: {"id":123,"type":"status","phase":"outline","message":"正在分析来源内容...","progress":5}
event: status
data: {"id":123,"type":"status","phase":"outline","message":"正在生成大纲...","progress":15}
event: content
data: {"id":123,"type":"content","text":"# 大纲标题\n\n## 第一章节\n..."}
event: status
data: {"id":123,"type":"status","phase":"script","message":"正在生成脚本...","progress":60}
event: complete
data: {"id":123,"type":"complete","scriptId":"script_xxx","segments":[...]}
data: [DONE]
4.2.2 创建任务接口(SSE)
请求
http
POST /workshop/{workshopId}/task/create
Content-Type: application/json
Accept: text/event-stream
{
"scriptId": "script_xxx"
}
SSE事件流
event: progress
data: {"taskId":"script_xxx","type":"progress","message":"正在准备生成...","progress":5}
event: progress
data: {"taskId":"script_xxx","type":"progress","message":"正在生成PPT...","progress":20}
event: progress
data: {"taskId":"script_xxx","type":"progress","message":"正在生成第3页...","progress":45}
event: complete
data: {"taskId":"script_xxx","type":"complete","progress":100,"message":"生成完成","resultFileKey":"xxx","slideCount":15}
data: [DONE]
4.3 音频风格参数说明
| 风格代码 | 名称 | 说明 | 说话人数 |
|---|---|---|---|
| qa | 问答访谈 | 主持人提问,嘉宾回答 | 2人 |
| debate | 辩论讨论 | 双方观点交锋 | 2人 |
| overview | 概述讲解 | 单人清晰概括内容 | 1人 |
| review | 评价分析 | 单人深度分析评价 | 1人 |
4.4 PPT风格参数说明
| 风格代码 | 名称 | 说明 |
|---|---|---|
| detailed | 详细版 | 内容丰富,每页4-6个要点,适合深度学习 |
| simple | 简洁版 | 精简核心,每页2-3个要点,适合快速浏览 |
五、核心功能实现:PPT生成
5.1 PPT生成流程
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 生成大纲 │───▶│ 生成页面 │───▶│ 渲染PPT │───▶│ 上传S3 │
│ (AI) │ │ 结构(AI) │ │ (Apache │ │ │
│ │ │ │ │ POI) │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
详细流程说明:
- 生成大纲(AI):根据来源内容,使用AI生成结构化的PPT大纲,包含章节、要点和详细说明
- 生成页面结构(AI):使用AI智能选择每页的布局类型,生成完整的页面结构JSON
- 渲染PPT(Apache POI):根据页面结构,使用Apache POI库渲染实际的PPTX文件
- 上传S3:将生成的PPT文件上传到对象存储
5.2 大纲数据结构
json
{
"title": "PPT标题",
"subtitle": "副标题",
"slideCount": 15,
"nodes": [
{
"id": "1",
"title": "章节标题",
"keyPoints": [
"要点1标题",
"要点2标题"
],
"details": [
["要点1的详细说明1", "要点1的详细说明2"],
["要点2的详细说明1", "要点2的详细说明2"]
]
}
]
}
5.3 AI驱动的页面结构生成
PptEnhancedSlideStructureGenerator 是PPT生成的核心组件,采用AI优先、规则兜底的策略,负责将大纲转换为具体的页面结构。
5.3.1 生成策略
java
@Value("${ppt.generation.enable-ai-layout:true}")
private boolean enableAILayout;
public List<PptSlideStructure> generateSlideStructures(String title, String subtitle,
List<Map<String, Object>> nodes,
boolean enableImages,
ProgressCallback progressCallback) {
if (enableAILayout) {
try {
// 使用AI生成完整的PPT结构
callback.onProgress("正在使用AI生成页面结构...", 18);
return generateSlideStructuresWithAI(title, subtitle, nodes, enableImages, callback);
} catch (Exception e) {
log.error("AI生成PPT结构失败,回退到规则模式", e);
callback.onProgress("AI生成失败,使用规则模式...", 20);
return generateSlideStructuresWithRules(title, subtitle, nodes, enableImages);
}
} else {
// 使用规则模式
return generateSlideStructuresWithRules(title, subtitle, nodes, enableImages);
}
}
策略说明:
- AI模式(默认):调用大模型智能选择每页的布局类型,生成更专业的PPT结构
- 规则模式(兜底):当AI调用失败时,使用预定义的规则生成布局
- 可配置 :通过配置项
ppt.generation.enable-ai-layout控制是否启用AI模式
5.3.2 支持的布局类型
封面布局
cover_diagonal- 斜切设计cover_centered- 居中设计cover_bottom_bar- 底部色条cover_gradient_side- 渐变侧边cover_minimal- 极简设计cover_circles- 圆形装饰cover_geometric- 几何图形
目录布局
toc_numbered- 编号列表toc_left_block- 左侧色块toc_grid_cards- 网格卡片toc_timeline- 时间线toc_icons- 图标列表
内容布局
text_only- 纯文本numbered_list- 编号列表icon_list- 图标列表three_cards- 三卡片four_cards_grid- 四卡片网格comparison_cards- 对比卡片timeline_horizontal- 水平时间线flow_chart- 流程图pyramid- 金字塔left_image_right_text- 左图右文tech_side_bar- 科技侧边栏rich_content_split- 富内容分割
章节分隔布局
section_numbered- 编号章节section_centered- 居中章节section_gradient- 渐变章节section_left_block- 左侧色块
结尾布局
ending_centered- 居中结尾ending_contact- 联系方式ending_summary- 总结回顾
5.3.3 AI布局生成系统提示词
AI布局生成使用专门设计的系统提示词,指导大模型生成符合要求的页面结构:
你是一个专业的PPT页面结构设计专家。你的任务是根据给定的PPT大纲,为每一页选择最合适的布局类型,并生成完整的页面结构JSON。
## 你的能力
1. 根据内容特点选择最佳布局(图表、卡片、时间线、对比等)
2. 确保布局多样性,避免连续使用相同布局
3. 为需要配图的页面生成英文配图提示词
4. 保证内容完整性,不遗漏任何大纲内容
## 可用的布局类型
### 封面布局
- cover_diagonal: 斜切设计,现代感强
- cover_centered: 居中设计,简洁大方
- cover_bottom_bar: 底部色条,商务风格
- cover_gradient_side: 渐变侧边,创意风格
- cover_minimal: 极简设计,专业感
- cover_circles: 圆形装饰,活泼风格
- cover_geometric: 几何图形,科技感
### 目录布局
- toc_numbered: 编号列表,清晰有序
- toc_left_block: 左侧色块,层次分明
- toc_grid_cards: 网格卡片,适合3-4项
- toc_timeline: 时间线,适合流程
- toc_icons: 图标列表,视觉丰富
### 内容布局
- text_only: 纯文本,适合大段文字
- numbered_list: 编号列表,适合步骤
- icon_list: 图标列表,视觉引导
- three_cards: 三卡片,适合3个要点
- four_cards_grid: 四卡片网格,适合4个要点
- comparison_cards: 对比卡片,适合优劣对比
- timeline_horizontal: 水平时间线,适合发展历程
- flow_chart: 流程图,适合步骤流程
- pyramid: 金字塔,适合层级关系
- left_image_right_text: 左图右文,图文结合
- tech_side_bar: 科技侧边栏,适合技术内容
- rich_content_split: 富内容分割,适合深度分析
## 输出格式
返回JSON对象,包含cover、toc、sections、ending四个部分...
5.3.4 用户提示词构建
java
private String buildUserPromptForAI(String title, String subtitle,
List<Map<String, Object>> nodes, boolean enableImages) {
StringBuilder sb = new StringBuilder();
sb.append("请为以下PPT生成完整的页面结构:\n\n");
sb.append("## PPT信息\n");
sb.append("- 标题:").append(title).append("\n");
sb.append("- 是否启用配图:").append(enableImages ? "是" : "否").append("\n\n");
sb.append("## 大纲内容\n\n");
// 输出每个章节的keyPoints和details
for (int i = 0; i < nodes.size(); i++) {
Map<String, Object> node = nodes.get(i);
String sectionTitle = (String) node.getOrDefault("title", "章节 " + (i + 1));
sb.append("### 章节 ").append(i + 1).append(": ").append(sectionTitle).append("\n");
List<String> keyPoints = (List<String>) node.get("keyPoints");
List<List<String>> details = (List<List<String>>) node.get("details");
if (keyPoints != null) {
for (int j = 0; j < keyPoints.size(); j++) {
sb.append("- **").append(keyPoints.get(j)).append("**\n");
if (details != null && j < details.size()) {
for (String detail : details.get(j)) {
sb.append(" - ").append(detail).append("\n");
}
}
}
}
}
sb.append("\n## 内容完整性要求(最高优先级!)\n");
sb.append("1. 大纲中的每个keyPoint都必须出现在PPT中\n");
sb.append("2. 每个keyPoint对应的所有details都必须完整保留\n");
sb.append("3. 禁止简化、合并或省略任何内容\n");
sb.append("4. 如果一个章节内容太多,必须拆分为多个内容页\n");
return sb.toString();
}
5.3.5 AI响应解析
AI返回的JSON结构示例:
json
{
"cover": {
"title": "人工智能发展趋势",
"subtitle": "2024年度技术报告",
"layout": "cover_geometric",
"image_needed": false
},
"toc": {
"layout": "toc_grid_cards",
"items": ["技术背景", "核心突破", "应用场景", "未来展望"]
},
"sections": [
{
"section_page": {
"title": "技术背景",
"layout": "section_numbered"
},
"content_pages": [
{
"title": "大语言模型的演进",
"layout": "timeline_horizontal",
"points": [
{
"point": "GPT系列的发展",
"details": ["从GPT-1到GPT-4的技术演进", "参数规模从1.17亿到1.76万亿"]
},
{
"point": "开源模型的崛起",
"details": ["LLaMA、Mistral等开源模型", "推动了AI民主化进程"]
}
],
"image_needed": false
}
]
}
],
"ending": {
"title": "感谢观看",
"subtitle": "由y笔记智能生成",
"layout": "ending_centered"
}
}
5.3.6 智能布局选择算法(规则模式)
java
private String selectSmartLayout(List<String> points, int index, boolean enableImages,
String[] chartLayouts, String[] infoLayouts,
String[] cardLayouts, String[] textLayouts,
String[] imageLayouts) {
int pointCount = points.size();
// 检查内容特点
boolean hasNumbers = points.stream().anyMatch(p -> p.matches(".*\\d+.*"));
boolean hasPercentage = points.stream().anyMatch(p -> p.contains("%"));
boolean hasSteps = points.stream().anyMatch(p ->
p.contains("步骤") || p.contains("流程"));
boolean hasComparison = points.stream().anyMatch(p ->
p.contains("对比") || p.contains("优势") || p.contains("劣势"));
boolean hasTimeline = points.stream().anyMatch(p ->
p.contains("年") || p.contains("阶段"));
boolean isTechnical = points.stream().anyMatch(p ->
p.contains("技术") || p.contains("架构") || p.contains("API"));
// 根据特点选择布局
if (hasPercentage && pointCount <= 5) {
return "progress_bars"; // 有百分比,使用进度条
} else if (hasTimeline) {
return "timeline_horizontal"; // 有时间线特征
} else if (hasSteps) {
return "flow_chart"; // 有步骤,使用流程图
} else if (hasComparison) {
return "comparison_cards"; // 有对比,使用对比卡片
} else if (isTechnical && pointCount >= 3) {
return "tech_side_bar"; // 技术内容,使用科技侧边栏
} else if (pointCount == 3) {
return "three_cards"; // 3个要点,使用三卡片
} else if (pointCount == 4) {
return "four_cards_grid"; // 4个要点,使用四卡片
} else {
return "numbered_list"; // 默认使用编号列表
}
}
5.3.7 章节布局多样化
为确保PPT视觉效果的多样性,系统会强制对章节分隔页使用不同的布局:
java
/**
* 强制章节布局多样化
* 根据章节编号轮换使用不同的布局
*/
private String enforceSectionLayoutDiversity(String originalLayout, int sectionNumber) {
String[] sectionLayouts = {"section_numbered", "section_centered",
"section_gradient", "section_left_block"};
// 如果是全图背景布局,保持不变
if ("section_full_image".equals(originalLayout)) {
return originalLayout;
}
// 根据章节编号轮换使用不同布局
int layoutIndex = (sectionNumber - 1) % sectionLayouts.length;
return sectionLayouts[layoutIndex];
}
5.3.8 配图提示词生成
当页面需要配图时,系统会根据页面内容生成英文配图提示词:
java
private String generateImagePromptWithDetails(String title, List<String> points,
List<DetailedPoint> detailedPoints) {
StringBuilder sb = new StringBuilder();
sb.append("Professional business presentation slide about ");
sb.append(cleanTextForPrompt(title));
// 优先使用 detailedPoints 中的内容
if (detailedPoints != null && !detailedPoints.isEmpty()) {
sb.append(", featuring ");
List<String> allContent = new ArrayList<>();
for (DetailedPoint dp : detailedPoints) {
if (dp.getPoint() != null) {
allContent.add(cleanTextForPrompt(dp.getPoint()));
}
if (dp.getDetails() != null) {
for (String detail : dp.getDetails()) {
allContent.add(cleanTextForPrompt(detail));
}
}
}
// 取前3个内容项,限制提示词长度
int maxItems = Math.min(3, allContent.size());
for (int i = 0; i < maxItems; i++) {
if (i > 0) sb.append(", ");
String content = allContent.get(i);
if (content.length() > 50) {
content = content.substring(0, 50);
}
sb.append(content);
}
}
sb.append(", modern minimalist style, clean design, corporate colors, no text");
return sb.toString();
}
5.4 PPT渲染实现
使用Apache POI库进行PPT文件的生成:
java
// 创建PPT文档
XMLSlideShow ppt = new XMLSlideShow();
// 设置幻灯片尺寸(16:9)
ppt.setPageSize(new Dimension(1280, 720));
// 创建幻灯片
XSLFSlide slide = ppt.createSlide();
// 添加标题
XSLFTextBox titleBox = slide.createTextBox();
titleBox.setAnchor(new Rectangle(50, 50, 1180, 100));
XSLFTextParagraph titlePara = titleBox.addNewTextParagraph();
XSLFTextRun titleRun = titlePara.addNewTextRun();
titleRun.setText("幻灯片标题");
titleRun.setFontSize(36.0);
titleRun.setFontColor(Color.BLACK);
// 保存文件
ByteArrayOutputStream out = new ByteArrayOutputStream();
ppt.write(out);
byte[] pptBytes = out.toByteArray();
六、核心功能实现:播客生成
6.1 SoulX-Podcast 开源服务介绍
播客生成功能基于 SoulX-Podcast 开源项目实现,这是一个基于 Python 的语音克隆和多人对话生成服务。
6.1.1 服务特点
| 特性 | 说明 |
|---|---|
| 语音克隆 | 支持通过参考音频克隆说话人声音 |
| 多人对话 | 支持最多4个说话人的对话生成 |
| 异步处理 | 采用异步任务模式,适合长音频生成 |
| 高质量输出 | 支持24000Hz采样率的WAV格式输出 |
6.1.2 API接口规范
SoulX-Podcast 提供以下核心API接口:
1. 异步生成接口
http
POST /generate-async
Content-Type: multipart/form-data
# 请求参数
prompt_audio: 参考音频文件(WAV格式,可多个)
prompt_texts: 角色描述JSON数组
dialogue_text: 对话文本(特定格式)
seed: 随机种子(可选,默认1988)
temperature: 温度参数(可选,默认0.6)
top_k: Top-K采样(可选,默认100)
top_p: Top-P采样(可选,默认0.9)
repetition_penalty: 重复惩罚(可选,默认1.25)
# 响应
{
"task_id": "uuid-string",
"status": "pending",
"message": "Task submitted successfully"
}
2. 任务状态查询接口
http
GET /task/{task_id}
# 响应
{
"task_id": "uuid-string",
"status": "completed|processing|failed",
"progress": 75,
"duration": 180,
"result_url": "/download/output_xxx.wav",
"error": null
}
3. 音频下载接口
http
GET /download/{filename}
# 响应
Binary audio data (audio/wav)
4. 健康检查接口
http
GET /health
# 响应
{
"status": "healthy",
"version": "1.0.0"
}
6.1.3 对话文本格式规范
SoulX-Podcast 使用特定的对话文本格式:
[S1]说话人1的内容<|breathing|>[S2]说话人2的内容<|breathing|>[S1]说话人1继续说...
格式说明:
[S1]、[S2]、[S3]、[S4]:说话人标识符,最多支持4个说话人<|breathing|>:停顿标记,在说话人切换处添加,产生自然的呼吸停顿效果- 整个对话是单行格式,不使用换行符分隔
示例:
[S1]欢迎收听本期播客,今天我们来聊聊人工智能的发展。<|breathing|>[S2]是的,AI技术近年来发展非常迅速,特别是大语言模型的突破。<|breathing|>[S1]那您能给我们介绍一下大语言模型的核心原理吗?
6.2 播客生成架构
┌─────────────────────────────────────────────────────────────────────┐
│ CreativeWorkshopServiceImpl │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ generateAudioContent() │ │
│ │ 1. 获取脚本JSON │ │
│ │ 2. 调用PodcastService │ │
│ │ 3. 更新任务状态 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PodcastServiceImpl │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ generatePodcast() │ │
│ │ 1. 转换对话格式 │ │
│ │ 2. 生成角色描述 │ │
│ │ 3. 提交异步任务 │ │
│ │ 4. 轮询任务状态 │ │
│ │ 5. 下载音频文件 │ │
│ │ 6. 上传到S3 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SoulX-Podcast API (Python) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ POST /generate-async 异步生成语音 │ │
│ │ GET /task/{task_id} 查询任务状态 │ │
│ │ GET /download/{file} 下载音频文件 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
6.3 对话格式转换
将脚本段落转换为SoulX-Podcast API所需的对话格式:
java
/**
* 格式: [S1]内容<|breathing|>[S2]内容...
*
* 停顿标记说明:
* - 在角色切换处添加 <|breathing|> 标记,产生自然的停顿效果
*/
private String convertToDialogueFormat(List<Map<String, Object>> segments, String dialect) {
StringBuilder sb = new StringBuilder();
Map<String, String> speakerMap = new HashMap<>();
int speakerIndex = 1;
String lastSpeakerId = null;
for (Map<String, Object> segment : segments) {
String speaker = (String) segment.get("speaker");
String text = (String) segment.get("text");
if (text == null || text.trim().isEmpty()) {
continue;
}
// 分配说话人标识
if (!speakerMap.containsKey(speaker)) {
speakerMap.put(speaker, "S" + speakerIndex++);
}
String speakerId = speakerMap.get(speaker);
// 在角色切换处添加停顿标记
if (!speakerId.equals(lastSpeakerId)) {
if (lastSpeakerId != null) {
sb.append("<|breathing|>");
}
sb.append("[").append(speakerId).append("]");
lastSpeakerId = speakerId;
}
sb.append(text.trim());
}
return sb.toString();
}
转换示例:
输入脚本:
json
[
{"speaker": "host", "text": "欢迎收听本期播客"},
{"speaker": "guest", "text": "大家好,很高兴来到这里"},
{"speaker": "host", "text": "今天我们来聊聊AI技术"}
]
输出对话格式:
[S1]欢迎收听本期播客<|breathing|>[S2]大家好,很高兴来到这里<|breathing|>[S1]今天我们来聊聊AI技术
6.4 角色描述生成
根据音频风格生成合适的角色描述:
java
private String generatePromptTexts(int speakerCount, String audioStyle) {
List<String> promptTexts = new ArrayList<>();
switch (audioStyle != null ? audioStyle.toLowerCase() : "default") {
case "qa":
// 问答风格 - 2人对话
promptTexts.add("资深播客主持人,声音亲切自然,善于提问和引导话题。");
if (speakerCount > 1) {
promptTexts.add("专业嘉宾,表达清晰有见地,善于解答问题。");
}
break;
case "debate":
// 辩论风格 - 2人对话
promptTexts.add("资深辩论主持人,声音有力量感,善于引导讨论。");
if (speakerCount > 1) {
promptTexts.add("专业辩手,逻辑清晰,善于表达不同观点。");
}
break;
case "overview":
// 概述风格 - 1人讲解
promptTexts.add("专业讲解员,声音清晰有条理,善于概括和总结。");
break;
case "review":
// 评价风格 - 1人讲解
promptTexts.add("资深评论员,声音沉稳有深度,善于分析和评价。");
break;
default:
promptTexts.add("专业播客主持人,声音清晰有感染力。");
break;
}
return JSON.toJSONString(promptTexts);
}
6.5 异步任务提交
使用WebClient提交multipart/form-data请求到SoulX-Podcast服务:
6.5.1 请求构建
java
private JSONObject submitAsyncTask(String dialogueText, String promptTexts) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
// 检查对话中有几个说话人
boolean hasMultipleSpeakers = dialogueText.contains("[S2]");
// 加载参考音频
byte[] promptAudioBytes1 = loadDefaultPromptAudio(1);
builder.part("prompt_audio", new ByteArrayResource(promptAudioBytes1) {
@Override
public String getFilename() {
return "prompt1.wav";
}
}).contentType(MediaType.parseMediaType("audio/wav"));
// 如果有多个说话人,加载第二个参考音频
if (hasMultipleSpeakers) {
byte[] promptAudioBytes2 = loadDefaultPromptAudio(2);
builder.part("prompt_audio", new ByteArrayResource(promptAudioBytes2) {
@Override
public String getFilename() {
return "prompt2.wav";
}
}).contentType(MediaType.parseMediaType("audio/wav"));
}
// 添加其他参数
builder.part("prompt_texts", promptTexts);
builder.part("dialogue_text", dialogueText);
builder.part("seed", "1988");
builder.part("temperature", "0.6");
builder.part("top_k", "100");
builder.part("top_p", "0.9");
builder.part("repetition_penalty", "1.25");
// 发送请求
String response = webClient.post()
.uri("/generate-async")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(30))
.block();
return JSON.parseObject(response);
}
6.5.2 生成参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| seed | int | 1988 | 随机种子,相同种子可复现结果 |
| temperature | float | 0.6 | 控制生成的随机性,越高越随机 |
| top_k | int | 100 | Top-K采样,限制候选token数量 |
| top_p | float | 0.9 | Top-P采样,累积概率阈值 |
| repetition_penalty | float | 1.25 | 重复惩罚,避免重复生成 |
6.5.3 参考音频要求
- 格式:WAV格式,16位PCM编码
- 采样率:建议24000Hz或44100Hz
- 时长:建议5-15秒的清晰语音
- 内容:包含说话人典型的语音特征
- 数量:与说话人数量匹配(1-4个)
6.6 任务状态轮询
java
private JSONObject pollTaskStatus(String podcastTaskId, String uid,
String taskId, ProgressCallback callback) {
int attempts = 0;
int consecutiveErrors = 0;
final int MAX_CONSECUTIVE_ERRORS = 10;
while (attempts < podcastConfig.getMaxPollingAttempts()) {
try {
Thread.sleep(podcastConfig.getPollingInterval()); // 默认5秒
String response = webClient.get()
.uri("/task/{taskId}", podcastTaskId)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(30))
.block();
if (response == null) {
consecutiveErrors++;
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
return null; // 连续错误过多,放弃
}
continue;
}
consecutiveErrors = 0;
JSONObject status = JSON.parseObject(response);
// 回调进度
if (callback != null) {
Integer progress = status.getInteger("progress");
String statusStr = status.getString("status");
callback.onProgress(progress != null ? progress : 0, statusStr);
}
String statusStr = status.getString("status");
if ("completed".equals(statusStr)) {
return status; // 任务完成
}
if ("failed".equals(statusStr)) {
return status; // 任务失败
}
} catch (Exception e) {
consecutiveErrors++;
}
attempts++;
}
return null; // 超时
}
6.7 音频下载与上传
java
/**
* 下载音频文件(带重试机制)
*/
private byte[] downloadAudioFile(String filename) {
int maxRetries = 3;
Exception lastException = null;
for (int retry = 0; retry < maxRetries; retry++) {
try {
if (retry > 0) {
log.info("下载Podcast音频第{}次重试: filename={}", retry + 1, filename);
Thread.sleep(2000); // 重试前等待2秒
}
byte[] audioData = webClient.get()
.uri("/download/{filename}", filename)
.retrieve()
.bodyToMono(byte[].class)
.timeout(Duration.ofSeconds(120)) // 增加超时时间到120秒
.block();
if (audioData != null && audioData.length > 0) {
// 验证音频数据的基本完整性
if (audioData.length < 1000) {
log.warn("下载的音频数据过小,可能不完整: filename={}, size={}bytes",
filename, audioData.length);
}
log.info("下载Podcast音频成功: filename={}, size={}bytes", filename, audioData.length);
return audioData;
}
} catch (Exception e) {
lastException = e;
log.warn("下载Podcast音频第{}次尝试失败: filename={}, error={}",
retry + 1, filename, e.getMessage());
}
}
log.error("下载Podcast音频失败(重试{}次后): filename={}", maxRetries, filename, lastException);
return null;
}
6.8 服务配置说明
播客生成服务通过Spring Boot的配置属性进行管理,主要配置项包括:
| 配置项 | 说明 | 建议值 |
|---|---|---|
| 服务地址 | SoulX-Podcast服务的内网地址 | 根据部署环境配置 |
| 启用开关 | 是否启用播客服务 | true/false |
| 轮询间隔 | 异步任务状态轮询间隔 | 5000ms |
| 最大轮询次数 | 防止无限等待的上限 | 720次(约1小时) |
| 请求超时 | HTTP请求超时时间 | 30秒 |
| 音频格式 | 输出音频格式 | wav |
| 采样率 | 音频采样率 | 24000Hz |
| 参考音频 | 语音克隆的参考音频文件 | S3存储路径 |
配置类定义:
java
@Data
@Configuration
@ConfigurationProperties(prefix = "podcast")
public class PodcastConfig {
private String baseUrl; // 服务地址
private boolean enabled; // 启用开关
private long pollingInterval; // 轮询间隔
private int maxPollingAttempts; // 最大轮询次数
private int requestTimeout; // 请求超时
private String audioFormat; // 音频格式
private int sampleRate; // 采样率
private String defaultPromptAudio; // 参考音频1
private String defaultPromptAudio2; // 参考音频2
}
注意:具体的服务地址、S3存储路径等敏感配置信息应通过环境变量或配置中心管理,不应硬编码在代码或配置文件中。
七、AI提示词工程
7.1 概览生成提示词
java
private String buildSummaryPrompt(String title, String content) {
StringBuilder sb = new StringBuilder();
sb.append("你是一个专业的内容分析助手。请根据以下内容生成概览和推荐问题。\n\n");
sb.append("【原始内容】\n").append(content).append("\n\n");
// 检测是否有多个来源
boolean hasMultipleSources = content.contains("【来源概览】");
if (hasMultipleSources) {
sb.append("【多来源均衡覆盖要求 - 极其重要】\n");
sb.append("1. 【均衡性】概览必须涵盖所有来源的核心内容\n");
sb.append("2. 【完整性】每个来源的主要观点都应该在概览中有所体现\n");
sb.append("3. 【融合性】将多个来源的内容有机整合\n");
sb.append("4. 【检查】生成后请自我检查:是否每个来源都被提及?\n\n");
}
sb.append("【输出要求】\n");
sb.append("请生成以下内容:\n");
sb.append("1. icon: 一个最能代表这个内容主题的emoji表情\n");
sb.append("2. title: 一个简洁有力的标题(5-15字)\n");
sb.append("3. summary: 内容概览(100-300字)\n");
sb.append("4. suggestedQuestions: 3个推荐问题\n\n");
sb.append("请以JSON格式返回:\n");
sb.append("{\n");
sb.append(" \"icon\": \"📚\",\n");
sb.append(" \"title\": \"内容标题\",\n");
sb.append(" \"summary\": \"内容概览...\",\n");
sb.append(" \"suggestedQuestions\": [\"问题1?\", \"问题2?\", \"问题3?\"]\n");
sb.append("}\n");
return sb.toString();
}
7.2 音频大纲生成提示词
针对不同的音频风格,生成差异化的大纲结构:
7.2.1 问答风格(qa)
java
sb.append("🎤【输出要求 - 问答访谈风格】\n");
sb.append("请生成一个【问答访谈】形式的音频大纲,模拟主持人与专家嘉宾的深度对话。\n\n");
sb.append("⚠️【问答风格核心特征 - 必须严格遵守】\n");
sb.append("1. 【双人对话】必须是主持人和嘉宾两个角色的对话形式\n");
sb.append("2. 【问题驱动】每个章节标题必须是疑问句形式(以?结尾)\n");
sb.append("3. 【互动性强】主持人提问→嘉宾回答→主持人追问/总结\n");
sb.append("4. 【由浅入深】问题从基础概念到深入分析,循序渐进\n");
sb.append("5. 【教育导向】目的是让听众通过问答学习知识\n\n");
sb.append("【章节标题格式验证 - 极其重要】\n");
sb.append("✅ 正确格式(必须是问句):\n");
sb.append(" - \"什么是人工智能?\"\n");
sb.append(" - \"如何入门机器学习?\"\n");
sb.append("❌ 错误格式(禁止使用陈述句):\n");
sb.append(" - \"人工智能概述\" ← 错误!必须改为问句\n");
7.2.2 辩论风格(debate)
java
sb.append("⚔️【输出要求 - 辩论讨论风格】\n");
sb.append("请生成一个【辩论讨论】形式的音频大纲,模拟两位专家的观点交锋。\n\n");
sb.append("⚠️【辩论风格核心特征 - 必须严格遵守】\n");
sb.append("1. 【双方对立】必须有正方和反方两个明确的立场\n");
sb.append("2. 【观点交锋】每个章节都要有观点的碰撞和讨论\n");
sb.append("3. 【论据支撑】每个观点都要有具体的论据和案例\n");
sb.append("4. 【逻辑严密】论证过程要有逻辑性\n");
sb.append("5. 【开放结论】不强制得出统一结论,允许保留分歧\n\n");
7.2.3 概述风格(overview)
java
sb.append("📖【输出要求 - 概述讲解风格】\n");
sb.append("请生成一个【概述讲解】形式的音频大纲,由一位专业讲解员清晰概括内容。\n\n");
sb.append("⚠️【概述风格核心特征 - 必须严格遵守】\n");
sb.append("1. 【单人讲解】只有一位讲解员,不需要对话\n");
sb.append("2. 【结构清晰】内容按逻辑顺序组织,层次分明\n");
sb.append("3. 【重点突出】突出核心要点,避免冗余\n");
sb.append("4. 【通俗易懂】语言简洁明了,易于理解\n");
sb.append("5. 【总结归纳】每个章节结束时有简短总结\n\n");
7.3 音频脚本生成提示词
java
private String buildAudioScriptPrompt(String audioStyle, String voicePreference,
String content, Map<String, Object> outline) {
StringBuilder sb = new StringBuilder();
// 确定语言风格
boolean isProfessional = "professional".equals(voicePreference);
String toneDescription = isProfessional
? "专业严谨、学术化、使用专业术语、逻辑清晰"
: "轻松通俗、口语化、易于理解、生动有趣";
sb.append("你是一个专业的播客脚本撰写专家。\n\n");
sb.append("【语言风格】").append(toneDescription).append("\n\n");
sb.append("【大纲结构】\n").append(JSON.toJSONString(outline)).append("\n\n");
sb.append("【原始内容】\n").append(content).append("\n\n");
// 根据音频风格添加特定要求
switch (audioStyle) {
case "qa":
sb.append("【问答脚本要求】\n");
sb.append("1. 主持人用[S1]标记,嘉宾用[S2]标记\n");
sb.append("2. 主持人负责提问和引导,嘉宾负责回答和解释\n");
sb.append("3. 对话要自然流畅,有互动感\n");
sb.append("4. 可以添加情感标记如[笑]、[思考]等\n");
break;
case "overview":
sb.append("【概述脚本要求】\n");
sb.append("1. 只使用[S1]标记(单人讲解)\n");
sb.append("2. 语言要清晰、有条理\n");
sb.append("3. 适当使用过渡语句连接各部分\n");
sb.append("4. 每个章节结束时简短总结\n");
break;
}
sb.append("\n【输出格式】\n");
sb.append("返回JSON数组,每个元素包含:\n");
sb.append("- speaker: 说话人标识(host/guest)\n");
sb.append("- text: 说话内容\n");
sb.append("- emotion: 情感标记(可选)\n");
return sb.toString();
}
7.4 PPT大纲生成提示词
java
private String buildOutlinePrompt(String mediaType, String title, String content,
String pptStyle, String audioStyle, String voicePreference) {
StringBuilder sb = new StringBuilder();
// 128k上下文模型,content限制调整为60000字符
final int MAX_CONTENT_LENGTH = 60000;
sb.append("你是一个专业的内容创作助手。请根据以下内容生成一个结构化的大纲。\n\n");
sb.append("【内容标题】\n").append(title).append("\n\n");
sb.append("【原始内容】\n").append(
content.length() > MAX_CONTENT_LENGTH ? content.substring(0, MAX_CONTENT_LENGTH) : content
).append("\n\n");
// 多来源均衡覆盖要求
boolean hasMultipleSources = content.contains("【来源概览】");
if (hasMultipleSources) {
sb.append("【多来源均衡覆盖要求 - 极其重要】\n");
sb.append("1. 【均衡性】每个来源的内容在大纲中的占比应该大致相当\n");
sb.append("2. 【完整性】每个来源的核心要点都必须在大纲中有所体现\n");
sb.append("3. 【融合性】如果多个来源有相关联的内容,可以进行有机整合\n");
sb.append("4. 【检查】生成完成后,请自我检查:是否每个来源都有足够的内容体现?\n\n");
}
// 根据PPT风格调整详细度
boolean isSimple = "simple".equalsIgnoreCase(pptStyle);
sb.append("【输出要求】\n");
if (isSimple) {
sb.append("请生成一个简洁精炼的PPT演示文稿大纲,要求结构清晰、重点突出。\n\n");
sb.append("【结构要求】\n");
sb.append("1. 3-5个主要章节(精简核心内容)\n");
sb.append("2. 每个章节2-3个要点\n");
} else {
sb.append("请生成一个专业、内容丰富的PPT演示文稿大纲。\n\n");
sb.append("【结构要求】\n");
sb.append("1. 6-10个主要章节(根据内容深度调整)\n");
sb.append("2. 每个章节4-6个要点\n");
sb.append("3. 每个要点对应的详细说明(60-120字)\n");
}
return sb.toString();
}
八、SSE流式响应实现
8.1 SSE基础概念
Server-Sent Events (SSE) 是一种服务器向客户端推送数据的技术,特点:
- 单向通信:服务器到客户端
- 自动重连:连接断开后自动重连
- 文本协议:基于HTTP,数据格式简单
- 浏览器原生支持:无需额外库
8.2 Spring Boot SSE实现
8.2.1 创建SseEmitter
java
@PostMapping(value = "/{workshopId}/script/generate",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter generateScript(@PathVariable Long workshopId,
@RequestBody ScriptGenerateParam param) {
String uid = SessionLocal.getLoginUser().getUid();
return workshopService.generateScript(uid, workshopId, param);
}
8.2.2 服务层实现
java
@Override
public SseEmitter generateScript(String uid, Long workshopId, ScriptGenerateParam param) {
// 创建SseEmitter,设置超时时间
SseEmitter emitter = new SseEmitter(1800000L); // 30分钟超时
// 异步执行生成任务
executorService.execute(() -> {
try {
// 发送进度
sendSseStatus(emitter, workshopId, "outline", "正在分析来源内容...", 5);
// 生成大纲
Map<String, Object> outline = generateOutlineWithAI(...);
sendSseStatus(emitter, workshopId, "outline", "大纲生成完成", 40);
// 流式输出大纲Markdown
String outlineMarkdown = generateOutlineMarkdown(outline, mediaType);
sendSseContent(emitter, workshopId, outlineMarkdown);
// 生成脚本(如果需要)
if (needsScript) {
sendSseStatus(emitter, workshopId, "script", "正在生成脚本...", 60);
List<Map<String, Object>> segments = generateScriptWithAI(...);
sendSseStatus(emitter, workshopId, "script", "脚本生成完成", 100);
}
// 发送完成事件
sendSseComplete(emitter, workshopId, scriptId, segments);
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
} catch (Exception e) {
log.error("生成脚本失败", e);
sendSseError(emitter, workshopId, "script", "生成失败: " + e.getMessage(), "WORKSHOP_500");
}
});
return emitter;
}
8.3 SSE事件类型
8.3.1 状态事件(status)
java
private void sendSseStatus(SseEmitter emitter, Long id, String phase,
String message, int progress) throws Exception {
Map<String, Object> data = new HashMap<>();
data.put("id", id);
data.put("type", "status");
data.put("phase", phase); // outline / script
data.put("message", message);
data.put("progress", progress);
emitter.send(SseEmitter.event().data(JSON.toJSONString(data)));
}
8.3.2 内容事件(content)
java
private void sendSseContent(SseEmitter emitter, Long id, String text) throws Exception {
Map<String, Object> data = new HashMap<>();
data.put("id", id);
data.put("type", "content");
data.put("text", text);
emitter.send(SseEmitter.event().data(JSON.toJSONString(data)));
}
8.3.3 完成事件(complete)
java
private void sendSseComplete(SseEmitter emitter, Long id, String scriptId,
List<Map<String, Object>> segments) throws Exception {
Map<String, Object> data = new HashMap<>();
data.put("id", id);
data.put("type", "complete");
data.put("scriptId", scriptId);
data.put("segments", segments);
emitter.send(SseEmitter.event().data(JSON.toJSONString(data)));
}
8.3.4 错误事件(error)
java
private void sendSseError(SseEmitter emitter, Long id, String phase,
String message, String errorCode) throws Exception {
Map<String, Object> data = new HashMap<>();
data.put("id", id);
data.put("type", "error");
data.put("phase", phase);
data.put("message", message);
data.put("errorCode", errorCode);
emitter.send(SseEmitter.event().data(JSON.toJSONString(data)));
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
}
8.4 前端SSE处理
javascript
// Flutter/Dart 示例
final client = http.Client();
final request = http.Request('POST', Uri.parse('$baseUrl/workshop/$workshopId/script/generate'));
request.headers['Content-Type'] = 'application/json';
request.headers['Accept'] = 'text/event-stream';
request.body = jsonEncode(param);
final response = await client.send(request);
final stream = response.stream.transform(utf8.decoder);
await for (final chunk in stream) {
final lines = chunk.split('\n');
for (final line in lines) {
if (line.startsWith('data: ')) {
final data = line.substring(6);
if (data == '[DONE]') {
// 完成
break;
}
final json = jsonDecode(data);
switch (json['type']) {
case 'status':
// 更新进度
onProgress(json['progress'], json['message']);
break;
case 'content':
// 显示内容
onContent(json['text']);
break;
case 'complete':
// 完成处理
onComplete(json['scriptId'], json['segments']);
break;
case 'error':
// 错误处理
onError(json['message']);
break;
}
}
}
}
九、错误处理与重试机制
9.1 重试配置
java
// 重试配置
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
private static final long RETRY_DELAY_MS = 2000; // 重试延迟(毫秒)
9.2 带重试的播客生成
java
@Override
public PodcastResult generatePodcast(List<Map<String, Object>> segments,
String dialect, String title,
String uid, String taskId,
String audioStyle,
ProgressCallback progressCallback) {
Exception lastException = null;
String lastError = null;
for (int retry = 0; retry < MAX_RETRY_COUNT; retry++) {
try {
if (retry > 0) {
log.info("[uid:{}] Podcast生成第{}次重试, taskId: {}", uid, retry + 1, taskId);
if (progressCallback != null) {
progressCallback.onProgress(0, "重试中 (" + (retry + 1) + "/" + MAX_RETRY_COUNT + ")...");
}
Thread.sleep(RETRY_DELAY_MS);
}
PodcastResult result = doGeneratePodcast(segments, dialect, title,
uid, taskId, audioStyle, progressCallback);
if (result.isSuccess()) {
return result;
} else {
lastError = result.getErrorMessage();
log.warn("[uid:{}] Podcast生成第{}次尝试失败, error: {}", uid, retry + 1, lastError);
// 发送飞书告警(非最后一次重试)
if (retry < MAX_RETRY_COUNT - 1) {
sendFeiShuRetryAlert(uid, taskId, lastError, retry + 1, MAX_RETRY_COUNT);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return PodcastResult.failure("生成被中断");
} catch (Exception e) {
lastException = e;
lastError = e.getMessage();
log.error("[uid:{}] Podcast生成第{}次尝试异常", uid, retry + 1, e);
if (retry < MAX_RETRY_COUNT - 1) {
sendFeiShuRetryAlert(uid, taskId, e.getMessage(), retry + 1, MAX_RETRY_COUNT);
}
}
}
// 所有重试都失败,发送最终失败告警
sendFeiShuFinalFailureAlert(uid, taskId, lastError);
return PodcastResult.failure(lastError);
}
9.3 飞书告警集成
java
// 飞书告警消息模板
private static final String FEISHU_ALERT_TEMPLATE = "【创意工坊告警】\n" +
"任务类型: %s\n" +
"任务ID: %s\n" +
"用户ID: %s\n" +
"错误信息: %s\n" +
"重试次数: %d/%d\n" +
"时间: %s";
/**
* 发送飞书重试告警
*/
private void sendFeiShuRetryAlert(String uid, String taskId, String error,
int retryCount, int maxRetry) {
String message = String.format(
"⚠️ Podcast生成重试告警\n" +
"用户ID: %s\n" +
"任务ID: %s\n" +
"重试次数: %d/%d\n" +
"错误信息: %s",
uid, taskId, retryCount, maxRetry, error
);
feiShuService.sendMessageToRobotAsync(message);
}
/**
* 发送飞书最终失败告警
*/
private void sendFeiShuFinalFailureAlert(String uid, String taskId, String error) {
String message = String.format(
"❌ Podcast生成最终失败\n" +
"用户ID: %s\n" +
"任务ID: %s\n" +
"错误信息: %s\n" +
"已重试 %d 次均失败",
uid, taskId, error, MAX_RETRY_COUNT
);
feiShuService.sendMessageToRobotAsync(message);
}
9.4 任务状态管理
java
@Override
public SseEmitter createTask(String uid, Long workshopId, TaskCreateParam param) {
CreativeTask task = taskMapper.selectByTaskId(param.getScriptId());
// 检查任务状态,防止重复触发
if (WorkshopTaskStatusEnum.PROCESSING.getCode().equals(task.getStatus())) {
throw new ServiceException("-1", "该任务正在生成中,请勿重复操作");
}
final String taskId = param.getScriptId();
SseEmitter emitter = new SseEmitter(1800000L);
executorService.execute(() -> {
try {
// 双重检查,防止并发
CreativeTask latestTask = taskMapper.selectByTaskId(taskId);
if (WorkshopTaskStatusEnum.PROCESSING.getCode().equals(latestTask.getStatus())) {
sendSseError(emitter, workshopId, "task", "任务正在生成中", "-1");
return;
}
// 更新任务状态为处理中
taskMapper.updateStatusAndProgress(taskId, WorkshopTaskStatusEnum.PROCESSING.getCode(), 0);
// 执行生成任务...
// 更新任务完成
taskMapper.updateComplete(taskId, resultFileKey, duration, slideCount);
} catch (Exception e) {
log.error("创建任务失败, taskId: {}", taskId, e);
// 确保异常时更新任务状态为失败
try {
taskMapper.updateFailed(taskId, e.getMessage());
} catch (Exception updateEx) {
log.error("更新任务失败状态异常", updateEx);
}
// 发送飞书告警
sendFeiShuFinalFailureAlert("任务创建", taskId, uid, e.getMessage());
// 发送错误事件
sendSseError(emitter, workshopId, "task", "生成失败: " + e.getMessage(), "WORKSHOP_500");
}
});
return emitter;
}
9.5 音频数据完整性验证
java
// 验证音频数据完整性
// WAV 格式:每秒约 176KB (44100Hz * 16bit * 2channels / 8 / 1024)
// 预期最小大小:duration * 100KB(考虑压缩和格式差异)
long expectedMinSize = duration != null ? duration * 100 * 1024L : 0;
if (duration != null && audioData.length < expectedMinSize) {
log.warn("[uid:{}] Podcast音频数据可能不完整: " +
"actualSize={}bytes, expectedMinSize={}bytes, duration={}秒",
uid, audioData.length, expectedMinSize, duration);
// 发送飞书告警
feiShuService.sendMessageToRobotAsync(String.format(
"⚠️ Podcast音频可能不完整\n用户ID: %s\n任务ID: %s\n" +
"实际大小: %d bytes\n预期最小: %d bytes\n时长: %d秒",
uid, taskId, audioData.length, expectedMinSize, duration));
}
十、总结与最佳实践
10.1 架构设计要点
- 分层架构:Controller → Service → External Service → Data Layer,职责清晰
- 异步处理:使用ExecutorService处理耗时任务,避免阻塞主线程
- SSE流式响应:实时推送进度,提升用户体验
- 重试机制:关键操作支持自动重试,提高系统可靠性
- 告警集成:异常情况及时通知,便于问题排查
10.2 AI提示词工程要点
- 结构化输出:要求AI返回JSON格式,便于解析
- 多来源均衡:检测多来源内容,要求AI均衡覆盖
- 风格差异化:根据不同风格生成差异化的提示词
- 内容完整性:强调不能遗漏或简化内容
- 自我检查:要求AI生成后自我检查内容完整性
10.3 性能优化建议
- 内容长度限制:128k上下文模型,内容限制在60000字符
- 本地缓存:参考音频使用本地文件缓存,减少S3访问
- 连接池复用:WebClient配置连接池,复用HTTP连接
- 异步上传:生成完成后异步上传到S3
10.4 错误处理最佳实践
- 双重检查:防止并发导致的重复执行
- 状态管理:任务状态及时更新,避免卡在"处理中"
- 优雅降级:AI生成失败时回退到规则模式
- 日志分级:网络偶发错误使用DEBUG级别,避免日志过多
- 告警分级:重试告警和最终失败告警分开处理
10.5 扩展性设计
- 媒体类型枚举:新增媒体类型只需添加枚举值
- 布局类型扩展:PPT布局类型可灵活扩展
- 音频风格扩展:支持添加新的音频风格
- 外部服务抽象:Podcast服务通过接口抽象,便于替换实现