512 lines
17 KiB
Go
512 lines
17 KiB
Go
package services
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
|
||
"github.com/drama-generator/backend/domain/models"
|
||
"github.com/drama-generator/backend/pkg/ai"
|
||
"github.com/drama-generator/backend/pkg/logger"
|
||
"github.com/drama-generator/backend/pkg/utils"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type ScriptGenerationService struct {
|
||
db *gorm.DB
|
||
aiService *AIService
|
||
log *logger.Logger
|
||
}
|
||
|
||
func NewScriptGenerationService(db *gorm.DB, log *logger.Logger) *ScriptGenerationService {
|
||
return &ScriptGenerationService{
|
||
db: db,
|
||
aiService: NewAIService(db, log),
|
||
log: log,
|
||
}
|
||
}
|
||
|
||
type GenerateOutlineRequest struct {
|
||
DramaID string `json:"drama_id" binding:"required"`
|
||
Theme string `json:"theme" binding:"required,min=2,max=500"`
|
||
Genre string `json:"genre"`
|
||
Style string `json:"style"`
|
||
Length int `json:"length"`
|
||
Temperature float64 `json:"temperature"`
|
||
}
|
||
|
||
type GenerateCharactersRequest struct {
|
||
DramaID string `json:"drama_id" binding:"required"`
|
||
Outline string `json:"outline"`
|
||
Count int `json:"count"`
|
||
Temperature float64 `json:"temperature"`
|
||
}
|
||
|
||
type GenerateEpisodesRequest struct {
|
||
DramaID string `json:"drama_id" binding:"required"`
|
||
Outline string `json:"outline"`
|
||
EpisodeCount int `json:"episode_count" binding:"required,min=1,max=100"`
|
||
Temperature float64 `json:"temperature"`
|
||
}
|
||
|
||
type OutlineResult struct {
|
||
Title string `json:"title"`
|
||
Summary string `json:"summary"`
|
||
Genre string `json:"genre"`
|
||
Tags []string `json:"tags"`
|
||
Characters []CharacterOutline `json:"characters"`
|
||
Episodes []EpisodeOutline `json:"episodes"`
|
||
KeyScenes []string `json:"key_scenes"`
|
||
}
|
||
|
||
type CharacterOutline struct {
|
||
Name string `json:"name"`
|
||
Role string `json:"role"`
|
||
Description string `json:"description"`
|
||
Personality string `json:"personality"`
|
||
Appearance string `json:"appearance"`
|
||
}
|
||
|
||
type EpisodeOutline struct {
|
||
EpisodeNumber int `json:"episode_number"`
|
||
Title string `json:"title"`
|
||
Summary string `json:"summary"`
|
||
Scenes []string `json:"scenes"`
|
||
Duration int `json:"duration"`
|
||
}
|
||
|
||
func (s *ScriptGenerationService) GenerateOutline(req *GenerateOutlineRequest) (*OutlineResult, error) {
|
||
var drama models.Drama
|
||
if err := s.db.Where("id = ?", req.DramaID).First(&drama).Error; err != nil {
|
||
return nil, fmt.Errorf("drama not found")
|
||
}
|
||
|
||
systemPrompt := `你是专业短剧编剧。根据主题和剧集数量,创作完整的短剧大纲,规划好每一集的剧情走向。
|
||
|
||
要求:
|
||
1. 剧情紧凑,矛盾冲突强烈,节奏快
|
||
2. 必须规划好每一集的核心剧情
|
||
3. 每集有明确冲突和转折点,集与集之间有连贯性和悬念
|
||
|
||
**重要:必须输出完整有效的JSON,确保所有字段完整,特别是episodes数组必须完整闭合!**
|
||
|
||
JSON格式(紧凑,summary和episodes字段必须完整):
|
||
{"title":"剧名","summary":"200-250字剧情概述,包含故事背景、主要矛盾、核心冲突、完整走向","genre":"类型","tags":["标签1","标签2","标签3"],"episodes":[{"episode_number":1,"title":"标题","summary":"80字剧情概要"},{"episode_number":2,"title":"标题","summary":"80字剧情概要"}],"key_scenes":["场景1","场景2","场景3"]}
|
||
|
||
关键要求:
|
||
- summary控制在200-250字,简洁清晰
|
||
- episodes必须生成用户要求的完整集数
|
||
- 每集summary控制在80字左右
|
||
- 确保JSON完整闭合,不要截断
|
||
- 不要添加任何JSON外的文字说明`
|
||
|
||
userPrompt := fmt.Sprintf(`请为以下主题创作短剧大纲:
|
||
|
||
主题:%s`, req.Theme)
|
||
|
||
if req.Genre != "" {
|
||
userPrompt += fmt.Sprintf("\n类型偏好:%s", req.Genre)
|
||
}
|
||
|
||
if req.Style != "" {
|
||
userPrompt += fmt.Sprintf("\n风格要求:%s", req.Style)
|
||
}
|
||
|
||
length := req.Length
|
||
if length == 0 {
|
||
length = 5
|
||
}
|
||
userPrompt += fmt.Sprintf("\n剧集数量:%d集", length)
|
||
userPrompt += fmt.Sprintf("\n\n**重要:必须在episodes数组中规划完整的%d集剧情,每集都要有明确的故事内容!**", length)
|
||
|
||
temperature := req.Temperature
|
||
if temperature == 0 {
|
||
temperature = 0.8
|
||
}
|
||
|
||
// 调整token限制:基础2000 + 每集约150 tokens(包含80-100字概要)
|
||
maxTokens := 2000 + (length * 150)
|
||
if maxTokens > 8000 {
|
||
maxTokens = 8000
|
||
}
|
||
|
||
s.log.Infow("Generating outline with episodes",
|
||
"episode_count", length,
|
||
"max_tokens", maxTokens)
|
||
|
||
text, err := s.aiService.GenerateText(
|
||
userPrompt,
|
||
systemPrompt,
|
||
ai.WithTemperature(temperature),
|
||
ai.WithMaxTokens(maxTokens),
|
||
)
|
||
|
||
if err != nil {
|
||
s.log.Errorw("Failed to generate outline", "error", err)
|
||
return nil, fmt.Errorf("生成失败: %w", err)
|
||
}
|
||
|
||
s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
|
||
|
||
var result OutlineResult
|
||
if err := utils.SafeParseAIJSON(text, &result); err != nil {
|
||
s.log.Errorw("Failed to parse outline JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
|
||
return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
|
||
}
|
||
|
||
// 将Tags转换为JSON格式存储
|
||
tagsJSON, err := json.Marshal(result.Tags)
|
||
if err != nil {
|
||
s.log.Errorw("Failed to marshal tags", "error", err)
|
||
tagsJSON = []byte("[]")
|
||
}
|
||
|
||
if err := s.db.Model(&drama).Updates(map[string]interface{}{
|
||
"title": result.Title,
|
||
"description": result.Summary,
|
||
"genre": result.Genre,
|
||
"tags": tagsJSON,
|
||
}).Error; err != nil {
|
||
s.log.Errorw("Failed to update drama", "error", err)
|
||
}
|
||
|
||
s.log.Infow("Outline generated", "drama_id", req.DramaID)
|
||
return &result, nil
|
||
}
|
||
|
||
func (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) ([]models.Character, error) {
|
||
var drama models.Drama
|
||
if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
|
||
return nil, fmt.Errorf("drama not found")
|
||
}
|
||
|
||
count := req.Count
|
||
if count == 0 {
|
||
count = 5
|
||
}
|
||
|
||
systemPrompt := `你是一个专业的角色分析师,擅长从剧本中提取和分析角色信息。
|
||
|
||
你的任务是根据提供的剧本内容,提取并整理剧中出现的所有角色的详细设定。
|
||
|
||
要求:
|
||
1. 仔细阅读剧本,识别所有出现的角色
|
||
2. 根据剧本中的对话、行为和描述,总结角色的性格特点
|
||
3. 提取角色在剧本中的关键信息:背景、动机、目标、关系等
|
||
4. 角色之间的关系必须基于剧本中的实际描述
|
||
5. 外貌描述必须极其详细,如果剧本中有描述则使用,如果没有则根据角色设定合理推断,便于AI绘画生成角色形象
|
||
6. 优先提取主要角色和重要配角,次要角色可以简略
|
||
|
||
请严格按照以下 JSON 格式输出,不要添加任何其他文字:
|
||
|
||
{
|
||
"characters": [
|
||
{
|
||
"name": "角色名",
|
||
"role": "主角/重要配角/配角",
|
||
"description": "角色背景和简介(200-300字,包括:出身背景、成长经历、核心动机、与其他角色的关系、在故事中的作用)",
|
||
"personality": "性格特点(详细描述,100-150字,包括:主要性格特征、行为习惯、价值观、优点缺点、情绪表达方式、对待他人的态度等)",
|
||
"appearance": "外貌描述(极其详细,150-200字,必须包括:确切年龄、精确身高、体型身材、肤色质感、发型发色发长、眼睛颜色形状、面部特征(如眉毛、鼻子、嘴唇)、着装风格、服装颜色材质、配饰细节、标志性特征、整体气质风格等,描述要具体到可以直接用于AI绘画)",
|
||
"voice_style": "说话风格和语气特点(详细描述,50-80字,包括:语速语调、用词习惯、口头禅、说话时的情绪特征等)"
|
||
}
|
||
]
|
||
}
|
||
|
||
注意:
|
||
- 必须基于剧本内容提取角色,不要凭空创作
|
||
- 优先提取主要角色和重要配角,数量根据剧本实际情况确定
|
||
- description、personality、appearance、voice_style都必须详细描述,字数要充足
|
||
- appearance外貌描述是重中之重,必须极其详细具体,要能让AI准确生成角色形象
|
||
- 如果剧本中角色信息不完整,可以根据角色设定合理补充,但要符合剧本整体风格`
|
||
|
||
outlineText := req.Outline
|
||
if outlineText == "" {
|
||
outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre)
|
||
}
|
||
|
||
userPrompt := fmt.Sprintf(`剧本内容:
|
||
%s
|
||
|
||
请从剧本中提取并整理最多 %d 个主要角色的详细设定。`, outlineText, count)
|
||
|
||
temperature := req.Temperature
|
||
if temperature == 0 {
|
||
temperature = 0.7
|
||
}
|
||
|
||
text, err := s.aiService.GenerateText(
|
||
userPrompt,
|
||
systemPrompt,
|
||
ai.WithTemperature(temperature),
|
||
ai.WithMaxTokens(3000),
|
||
)
|
||
|
||
if err != nil {
|
||
s.log.Errorw("Failed to generate characters", "error", err)
|
||
return nil, fmt.Errorf("生成失败: %w", err)
|
||
}
|
||
|
||
s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
|
||
|
||
var result struct {
|
||
Characters []struct {
|
||
Name string `json:"name"`
|
||
Role string `json:"role"`
|
||
Description string `json:"description"`
|
||
Personality string `json:"personality"`
|
||
Appearance string `json:"appearance"`
|
||
VoiceStyle string `json:"voice_style"`
|
||
} `json:"characters"`
|
||
}
|
||
|
||
if err := utils.SafeParseAIJSON(text, &result); err != nil {
|
||
s.log.Errorw("Failed to parse characters JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
|
||
return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
|
||
}
|
||
|
||
var characters []models.Character
|
||
for _, char := range result.Characters {
|
||
// 检查角色是否已存在
|
||
var existingChar models.Character
|
||
err := s.db.Where("drama_id = ? AND name = ?", req.DramaID, char.Name).First(&existingChar).Error
|
||
if err == nil {
|
||
// 角色已存在,直接使用已存在的角色,不覆盖
|
||
s.log.Infow("Character already exists, skipping", "drama_id", req.DramaID, "name", char.Name)
|
||
characters = append(characters, existingChar)
|
||
continue
|
||
}
|
||
|
||
// 角色不存在,创建新角色
|
||
dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32)
|
||
character := models.Character{
|
||
DramaID: uint(dramaID),
|
||
Name: char.Name,
|
||
Role: &char.Role,
|
||
Description: &char.Description,
|
||
Personality: &char.Personality,
|
||
Appearance: &char.Appearance,
|
||
VoiceStyle: &char.VoiceStyle,
|
||
}
|
||
|
||
if err := s.db.Create(&character).Error; err != nil {
|
||
s.log.Errorw("Failed to create character", "error", err)
|
||
continue
|
||
}
|
||
|
||
characters = append(characters, character)
|
||
}
|
||
|
||
s.log.Infow("Characters generated", "drama_id", req.DramaID, "total_count", len(characters), "new_count", len(characters))
|
||
return characters, nil
|
||
}
|
||
|
||
func (s *ScriptGenerationService) GenerateEpisodes(req *GenerateEpisodesRequest) ([]models.Episode, error) {
|
||
var drama models.Drama
|
||
if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
|
||
return nil, fmt.Errorf("drama not found")
|
||
}
|
||
|
||
// 获取角色信息
|
||
var characters []models.Character
|
||
s.db.Where("drama_id = ?", req.DramaID).Find(&characters)
|
||
|
||
var characterList string
|
||
if len(characters) > 0 {
|
||
characterList = "\n角色设定:\n"
|
||
for _, char := range characters {
|
||
characterList += fmt.Sprintf("- %s", char.Name)
|
||
if char.Role != nil {
|
||
characterList += fmt.Sprintf("(%s)", *char.Role)
|
||
}
|
||
if char.Description != nil {
|
||
characterList += fmt.Sprintf(":%s", *char.Description)
|
||
}
|
||
if char.Personality != nil {
|
||
characterList += fmt.Sprintf(" | 性格:%s", *char.Personality)
|
||
}
|
||
characterList += "\n"
|
||
}
|
||
} else {
|
||
characterList = "\n(注意:尚未设定角色,请根据大纲创作合理的角色出场)\n"
|
||
}
|
||
|
||
systemPrompt := `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。
|
||
|
||
你的任务是根据大纲中的分集规划,将每一集的概要扩展为详细的剧情叙述。每集约180秒(3分钟),需要充实的内容。
|
||
|
||
工作流程:
|
||
1. 大纲中已提供每集的剧情规划(80-100字概要)
|
||
2. 你需要将每集概要扩展为400-500字的详细剧情叙述
|
||
3. 严格按照分集规划的数量和走向展开,不能遗漏任何一集
|
||
|
||
详细要求:
|
||
1. script_content用400-500字详细叙述,包括:
|
||
- 具体场景和环境描写
|
||
- 角色的行动、对话要点、情绪变化
|
||
- 冲突的产生过程和激化细节
|
||
- 关键情节点和转折
|
||
- 为下一集埋下的伏笔
|
||
2. 每集有明确的冲突和转折点
|
||
3. 集与集之间有连贯性和悬念
|
||
4. 充分展现角色性格和关系演变
|
||
5. 内容详实,足以支撑180秒时长
|
||
|
||
JSON格式(紧凑):
|
||
{"episodes":[{"episode_number":1,"title":"标题","description":"简短梗概","script_content":"400-500字详细剧情叙述","duration":210}]}
|
||
|
||
格式说明:
|
||
1. script_content为叙述文,不是场景对话格式
|
||
2. 每集包含开场铺垫、冲突发展、高潮转折、结局悬念
|
||
3. duration根据剧情复杂度设置在150-300秒
|
||
|
||
关键要求:
|
||
- 大纲规划了几集就必须生成几集
|
||
- 严格按照分集规划的故事线展开
|
||
- 每一集都要有完整的400-500字详细内容
|
||
- 绝对不能遗漏任何一集`
|
||
|
||
outlineText := req.Outline
|
||
if outlineText == "" {
|
||
outlineText = fmt.Sprintf("剧名:%s\n简介:%s\n类型:%s", drama.Title, drama.Description, drama.Genre)
|
||
}
|
||
|
||
userPrompt := fmt.Sprintf(`剧本大纲:
|
||
%s
|
||
%s
|
||
请基于以上大纲和角色,创作 %d 集的详细剧本。
|
||
|
||
**重要要求:**
|
||
- 必须生成完整的 %d 集,从第1集到第%d集,不能遗漏
|
||
- 每集约3-5分钟(150-300秒)
|
||
- 每集的duration字段要根据剧本内容长度合理设置,不要都设置为同一个值
|
||
- 返回的JSON中episodes数组必须包含 %d 个元素`, outlineText, characterList, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount, req.EpisodeCount)
|
||
|
||
temperature := req.Temperature
|
||
if temperature == 0 {
|
||
temperature = 0.7
|
||
}
|
||
|
||
// 根据剧集数量调整token限制
|
||
// 模型支持128k上下文,每集400-500字约需800-1000 tokens(包含JSON结构)
|
||
baseTokens := 3000 // 基础(系统提示+角色列表+大纲)
|
||
perEpisodeTokens := 900 // 每集约900 tokens(支持400-500字详细内容)
|
||
maxTokens := baseTokens + (req.EpisodeCount * perEpisodeTokens)
|
||
|
||
// 128k上下文,可以设置较大的token限制
|
||
// 10集约12000 tokens,20集约21000 tokens,都在安全范围内
|
||
if maxTokens > 32000 {
|
||
maxTokens = 32000 // 保守限制在32k,留足够空间
|
||
}
|
||
|
||
s.log.Infow("Generating episodes with token limit",
|
||
"episode_count", req.EpisodeCount,
|
||
"max_tokens", maxTokens,
|
||
"estimated_per_episode", perEpisodeTokens)
|
||
|
||
text, err := s.aiService.GenerateText(
|
||
userPrompt,
|
||
systemPrompt,
|
||
ai.WithTemperature(0.8),
|
||
ai.WithMaxTokens(maxTokens),
|
||
)
|
||
|
||
if err != nil {
|
||
s.log.Errorw("Failed to generate episodes", "error", err)
|
||
return nil, fmt.Errorf("生成失败: %w", err)
|
||
}
|
||
|
||
s.log.Infow("AI response received", "length", len(text), "preview", text[:minInt(200, len(text))])
|
||
|
||
var result struct {
|
||
Episodes []struct {
|
||
EpisodeNumber int `json:"episode_number"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
ScriptContent string `json:"script_content"`
|
||
Duration int `json:"duration"`
|
||
} `json:"episodes"`
|
||
}
|
||
|
||
if err := utils.SafeParseAIJSON(text, &result); err != nil {
|
||
s.log.Errorw("Failed to parse episodes JSON", "error", err, "raw_response", text[:minInt(500, len(text))])
|
||
return nil, fmt.Errorf("解析 AI 返回结果失败: %w", err)
|
||
}
|
||
|
||
// 检查生成的集数是否符合要求
|
||
if len(result.Episodes) < req.EpisodeCount {
|
||
s.log.Warnw("AI generated fewer episodes than requested",
|
||
"requested", req.EpisodeCount,
|
||
"generated", len(result.Episodes))
|
||
}
|
||
|
||
// 记录每集的详细信息
|
||
for i, ep := range result.Episodes {
|
||
s.log.Infow("Episode parsed from AI",
|
||
"index", i,
|
||
"episode_number", ep.EpisodeNumber,
|
||
"title", ep.Title,
|
||
"description_length", len(ep.Description),
|
||
"script_content_length", len(ep.ScriptContent),
|
||
"duration", ep.Duration)
|
||
}
|
||
|
||
var episodes []models.Episode
|
||
for _, ep := range result.Episodes {
|
||
duration := ep.Duration
|
||
if duration == 0 {
|
||
// AI未返回时长时使用默认值
|
||
duration = 180
|
||
s.log.Warnw("Episode duration not provided by AI, using default",
|
||
"episode_number", ep.EpisodeNumber,
|
||
"default_duration", 180)
|
||
} else {
|
||
s.log.Infow("Episode duration from AI",
|
||
"episode_number", ep.EpisodeNumber,
|
||
"duration", duration)
|
||
}
|
||
|
||
// 记录即将保存的数据
|
||
s.log.Infow("Creating episode in database",
|
||
"episode_number", ep.EpisodeNumber,
|
||
"title", ep.Title,
|
||
"script_content_length", len(ep.ScriptContent),
|
||
"script_content_empty", ep.ScriptContent == "")
|
||
|
||
dramaID, err := strconv.ParseUint(req.DramaID, 10, 32)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid drama ID")
|
||
}
|
||
|
||
episode := models.Episode{
|
||
DramaID: uint(dramaID),
|
||
EpisodeNum: ep.EpisodeNumber,
|
||
Title: ep.Title,
|
||
Description: &ep.Description,
|
||
ScriptContent: &ep.ScriptContent,
|
||
Duration: duration,
|
||
Status: "draft",
|
||
}
|
||
|
||
if err := s.db.Create(&episode).Error; err != nil {
|
||
s.log.Errorw("Failed to create episode", "error", err)
|
||
continue
|
||
}
|
||
|
||
episodes = append(episodes, episode)
|
||
}
|
||
|
||
s.log.Infow("Episodes generated", "drama_id", req.DramaID, "count", len(episodes))
|
||
return episodes, nil
|
||
}
|
||
|
||
// GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代
|
||
// ParseScript 已废弃,使用 GenerateCharacters 替代
|
||
|
||
// minInt 返回两个整数中较小的一个
|
||
func minInt(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|