This commit is contained in:
Connor
2026-01-12 13:17:11 +08:00
parent 95851f8e69
commit 9600fc542c
132 changed files with 35734 additions and 5 deletions

View File

@@ -0,0 +1,511 @@
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. 根据大纲的关键场景合理设置角色数量通常3-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 tokens20集约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
}