fonchain-fiee/pkg/service/ai/video_vl.go

515 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ai
import (
"encoding/json"
"errors"
"fmt"
"fonchain-fiee/pkg/common/qwen"
"fonchain-fiee/pkg/service"
"strings"
"github.com/gin-gonic/gin"
)
// VideoVLRequest 视频/图片理解请求参数
type VideoVLRequest struct {
Videos []string `json:"videos"` // 视频URL列表
Images []string `json:"images"` // 图片URL列表
Text string `json:"text"` // 可选的文本提示
Model string `json:"model"` // 可选的模型名称,默认使用 qwen3-vl-plus
}
// AIVideoVL AI理解视频/图片接口
func AIVideoVL(ctx *gin.Context) {
var req VideoVLRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, errors.New("参数错误"))
return
}
// 检查是否至少提供了视频或图片
if len(req.Videos) == 0 && len(req.Images) == 0 {
service.Error(ctx, errors.New("至少需要提供一个视频或图片"))
return
}
if len(req.Videos) > 1 {
service.Error(ctx, errors.New("当前只能选一个视频"))
return
}
Prompt := "请你详细描述视频和图片中的内容分别是什么,包括画面内容、人物动作、场景等。如果有配乐或背景音乐,请详细描述配乐的节奏、风格和情感特点"
// 调用VL函数进行AI理解
result, err := qwen.VL(req.Videos, req.Images, Prompt, req.Model)
if err != nil {
// 检查是否是文件下载超时错误(内容过大)
errMsg := err.Error()
if contains(errMsg, "Download multimodal file timed out") || contains(errMsg, "timed out") {
service.Error(ctx, errors.New("内容过大,请重新选择"))
} else {
service.Error(ctx, errors.New("ai分析帖子内容失败"))
}
return
}
// 返回AI返回的数据
service.Success(ctx, result)
}
// contains 检查字符串是否包含子字符串(不区分大小写)
func contains(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// CompetitorReportRequest 竞品报告请求参数
type CompetitorReportRequest struct {
Videos []string `json:"videos"` // 视频URL列表
Images []string `json:"images"` // 图片URL列表
TextPrompt string `json:"textPrompt"` // 竞品报告要求文本
ImagePrompt string `json:"imagePrompt"` // 图片URL
Model string `json:"model"` // 可选的模型名称,默认使用 qwen3-vl-plus
}
// CompetitorReportData 竞品报告数据(用于返回给前端)
type CompetitorReportData struct {
HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"`
DataPerformance DataPerformanceData `json:"data_performance_analysis"`
OverallSummary string `json:"overall_summary_and_optimization"`
}
type HighlightAnalysisData struct {
Summary string `json:"summary"`
Points PointsData `json:"points"`
}
type PointsData struct {
Theme string `json:"theme"`
Narrative string `json:"narrative"`
Content string `json:"content"`
Copywriting string `json:"copywriting"`
Data string `json:"data"`
Music string `json:"music,omitempty"`
}
type DataPerformanceData struct {
Views string `json:"views"`
Completion string `json:"completion_rate,omitempty"`
Engagement string `json:"engagement"`
}
// CompetitorReportResponse 竞品报告响应数据
type CompetitorReportResponse struct {
ImageURL string `json:"image_url,omitempty"` // 生成的图片URL1024*1024非必须返回
Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回
JsonData *CompetitorReportData `json:"json_data,omitempty"` // 竞品报告JSON数据
}
// CompetitorReportJSON AI返回的JSON结构
type CompetitorReportJSON struct {
HighlightAnalysis HighlightAnalysis `json:"highlight_analysis"`
DataPerformance DataPerformance `json:"data_performance_analysis"`
OverallSummary string `json:"overall_summary_and_optimization"`
}
type HighlightAnalysis struct {
Summary string `json:"summary"`
Points Points `json:"points"`
}
type Points struct {
Theme string `json:"theme"`
Narrative string `json:"narrative"`
Content string `json:"content"`
Copywriting string `json:"copywriting"`
Data string `json:"data"`
Music string `json:"music,omitempty"`
}
type DataPerformance struct {
Views string `json:"views"`
Completion string `json:"completion_rate,omitempty"`
Engagement string `json:"engagement"`
}
// convertJSONToText 将 JSON 转换为纯文本格式
func convertJSONToText(data CompetitorReportJSON, isVideo bool) string {
var sb strings.Builder
// 一、亮点表现分析
sb.WriteString("一、亮点表现分析\n")
sb.WriteString(data.HighlightAnalysis.Summary)
sb.WriteString("\n\n")
sb.WriteString("1. 标题亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Theme)
sb.WriteString("\n")
sb.WriteString("2. 题材亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
sb.WriteString("\n")
sb.WriteString("3. 内容亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Content)
sb.WriteString("\n")
sb.WriteString("4. 文案亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
sb.WriteString("\n")
sb.WriteString("5. 数据亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Data)
sb.WriteString("\n")
if isVideo && data.HighlightAnalysis.Points.Music != "" {
sb.WriteString("6. 配乐亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Music)
sb.WriteString("\n")
}
// 二、数据表现分析
sb.WriteString("\n二、数据表现分析\n")
sb.WriteString("1. 浏览量表现:")
sb.WriteString(data.DataPerformance.Views)
sb.WriteString("\n")
if isVideo && data.DataPerformance.Completion != "" {
sb.WriteString("2. 完播率表现:")
sb.WriteString(data.DataPerformance.Completion)
sb.WriteString("\n")
sb.WriteString("3. 点赞/分享/评论表现:")
} else {
sb.WriteString("2. 点赞/分享/评论表现:")
}
sb.WriteString(data.DataPerformance.Engagement)
sb.WriteString("\n")
// 三、整体总结及可优化建议
sb.WriteString("\n三、整体总结及可优化建议\n")
sb.WriteString(data.OverallSummary)
return sb.String()
}
// convertJSONToTextFromData 将 JSON 转换为纯文本格式(使用新的 CompetitorReportData 结构)
func convertJSONToTextFromData(data CompetitorReportData, isVideo bool) string {
var sb strings.Builder
// 一、亮点表现分析
sb.WriteString("一、亮点表现分析\n")
sb.WriteString(data.HighlightAnalysis.Summary)
sb.WriteString("\n\n")
sb.WriteString("1. 标题亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Theme)
sb.WriteString("\n")
sb.WriteString("2. 题材亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
sb.WriteString("\n")
sb.WriteString("3. 内容亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Content)
sb.WriteString("\n")
sb.WriteString("4. 文案亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
sb.WriteString("\n")
sb.WriteString("5. 数据亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Data)
sb.WriteString("\n")
if isVideo && data.HighlightAnalysis.Points.Music != "" {
sb.WriteString("6. 配乐亮点:")
sb.WriteString(data.HighlightAnalysis.Points.Music)
sb.WriteString("\n")
}
// 二、数据表现分析
sb.WriteString("\n二、数据表现分析\n")
sb.WriteString("1. 浏览量表现:")
sb.WriteString(data.DataPerformance.Views)
sb.WriteString("\n")
if isVideo && data.DataPerformance.Completion != "" {
sb.WriteString("2. 完播率表现:")
sb.WriteString(data.DataPerformance.Completion)
sb.WriteString("\n")
sb.WriteString("3. 点赞/分享/评论表现:")
} else {
sb.WriteString("2. 点赞/分享/评论表现:")
}
sb.WriteString(data.DataPerformance.Engagement)
sb.WriteString("\n")
// 三、整体总结及可优化建议
sb.WriteString("\n三、整体总结及可优化建议\n")
sb.WriteString(data.OverallSummary)
return sb.String()
}
// AICompetitorReport 生成竞品报告接口
func AICompetitorReport(ctx *gin.Context) {
var req CompetitorReportRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, errors.New("参数错误"))
return
}
if req.TextPrompt == "" && req.ImagePrompt == "" {
service.Error(ctx, errors.New("文本和图片提示词不能同时为空"))
return
}
// 检查是否至少提供了视频或图片
if len(req.Videos) == 0 && len(req.Images) == 0 {
service.Error(ctx, errors.New("至少需要提供一个视频或图片"))
return
}
if len(req.Videos) > 1 {
service.Error(ctx, errors.New("当前只能选一个视频"))
return
}
// 第一步调用AI理解视频/图片内容
vlPrompt := "请你详细描述这些视频或者这些图片中的内容分别是什么,请详细描述,不要遗漏任何细节。如果有配乐或背景音乐,请详细描述配乐的节奏、风格和情感特点"
vlResult, err := qwen.VL(req.Videos, req.Images, vlPrompt, req.Model)
if err != nil {
// 检查是否是文件下载超时错误(内容过大)
errMsg := err.Error()
if contains(errMsg, "Download multimodal file timed out") || contains(errMsg, "timed out") {
service.Error(ctx, errors.New("内容过大,请重新选择"))
} else {
service.Error(ctx, fmt.Errorf("AI理解视频图片失败: %v", err))
}
return
}
// 获取理解后的内容
if len(vlResult.Choices) == 0 {
service.Error(ctx, errors.New("AI理解返回结果为空"))
return
}
vlContent := vlResult.Choices[0].Message.Content
// 定义协程结果结构
type textResult struct {
text string
err error
}
type imageResult struct {
imageURL string
err error
}
// 根据 TextPrompt 和 ImagePrompt 是否为空决定启动哪些协程
needText := req.TextPrompt != ""
needImage := req.ImagePrompt != ""
var textChan chan textResult
var imageChan chan imageResult
// 如果需要生成文本,启动文本生成协程
if needText {
textChan = make(chan textResult, 1)
go func() {
// 根据是否有视频来判断作品类型
isVideo := len(req.Videos) > 0
// 构建文本生成提示词:理解内容 + 用户要求JSON格式
// 重要必须明确要求使用英文标点符号确保返回的JSON符合规范
// 重要:必须基于内容给出分析性回复,即使没有提供具体数据
var textPrompt string
if isVideo {
textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式不要输出任何其他内容。输出必须以 { 开头并以 } 结束。
重要提示:
1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。
2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据时长分析完播率潜力、根据内容质量分析互动潜力等)。
3. 配乐亮点(music字段):禁止回复"未提供配乐信息"、"没有配乐信息"等类似内容。即使没有识别到配乐,也要根据视频整体风格和内容特点编写合理的配乐分析(如:根据视频风格推断适合的配乐类型、根据内容节奏分析配乐潜力等)。
基于以下视频和图片的内容描述:
%s
用户要求仅作为内容参考不会改变JSON结构
%s
JSON结构是固定的请将内容填充到对应的value中禁止修改key禁止添加额外字段禁止输出任何说明文字
{"highlight_analysis":{"summary":"[78字以内的概述]","points":{"theme":"[标题亮点最多60字]","narrative":"[题材亮点最多60字]","content":"[内容亮点最多60字]","copywriting":"[文案亮点最多60字]","data":"[数据亮点最多60字]","music":"[配乐亮点仅视频最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现最多60字]","completion_rate":"[完播率表现仅视频最多60字]","engagement":"[点赞/分享/评论表现最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议最多300字]"}`, vlContent, req.TextPrompt)
} else {
textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式不要输出任何其他内容。输出开头并以 }必须以 { 结束。
重要提示:
1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。
2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据内容质量分析互动潜力等)。
基于以下视频和图片的内容描述:
%s
用户要求仅作为内容参考不会改变JSON结构
%s
JSON结构是固定的请将内容填充到对应的value中禁止修改key禁止添加额外字段禁止输出任何说明文字
{"highlight_analysis":{"summary":"[100字以内的概述]","points":{"theme":"[标题亮点最多60字]","narrative":"[题材亮点最多60字]","content":"[内容亮点最多60字]","copywriting":"[文案亮点最多60字]","data":"[数据亮点最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现最多60字]","engagement":"[点赞/分享/评论表现最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议最多300字]"}`, vlContent, req.TextPrompt)
}
chatReq, err := buildChatRequest(textPrompt, nil)
if err != nil {
textChan <- textResult{err: err}
return
}
chatResp, err := qwen.Chat(*chatReq)
if err != nil {
textChan <- textResult{err: err}
return
}
if len(chatResp.Choices) == 0 {
textChan <- textResult{err: errors.New("文本生成返回结果为空")}
return
}
// 打印 AI 返回的原始内容(用于调试)
aiText := chatResp.Choices[0].Message.Content
fmt.Println("========== AI 返回的原始内容 ==========")
fmt.Println(aiText)
fmt.Println("=========================================")
textChan <- textResult{text: aiText}
}()
}
// 如果需要生成图片,启动图片生成协程
if needImage {
imageChan = make(chan imageResult, 1)
go func() {
// 先请求聊天获取图片提示词
imagePromptText := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告图片的提示词\n%s\n\n重要提示生成的图片内容中不要包含任何文字仅仅是根据内容生成一张配图即可", vlContent, req.ImagePrompt)
chatReq, err := buildChatRequest(imagePromptText, nil)
if err != nil {
imageChan <- imageResult{err: err}
return
}
chatResp, err := qwen.Chat(*chatReq)
if err != nil {
imageChan <- imageResult{err: err}
return
}
if len(chatResp.Choices) == 0 {
imageChan <- imageResult{err: errors.New("图片提示词生成返回结果为空")}
return
}
imagePrompt := chatResp.Choices[0].Message.Content
// 生成图片1024*1024基于理解后的内容使用文生图
size := "1024*1024"
resultTask, err := qwen.GenerateTextImage(imagePrompt, size)
if err != nil {
imageChan <- imageResult{err: err}
return
}
if resultTask.Code != "" {
imageChan <- imageResult{err: errors.New("文生图失败: " + resultTask.Message)}
return
}
// 等待图片生成完成
result, err := qwen.ImgTaskResult(resultTask.Output.TaskID)
if err != nil {
imageChan <- imageResult{err: err}
return
}
if result == nil || len(result.Output.Results) == 0 {
imageChan <- imageResult{err: errors.New("图片生成失败")}
return
}
// 返回第一张图片的URL
imageChan <- imageResult{imageURL: result.Output.Results[0].URL}
}()
}
// 等待所有启动的协程完成
var textRes textResult
var imageRes imageResult
// 根据实际启动的协程数量等待结果
if needText && needImage {
// 两个协程都启动了,使用循环等待两个都完成
completed := 0
for completed < 2 {
select {
case textRes = <-textChan:
completed++
case imageRes = <-imageChan:
completed++
}
}
} else if needText {
// 只启动文本生成协程
textRes = <-textChan
} else if needImage {
// 只启动图片生成协程
imageRes = <-imageChan
}
// 处理文本结果(如果生成了文本)
if needText {
if textRes.err != nil {
service.Error(ctx, fmt.Errorf("生成竞品报告文本失败: %v", textRes.err))
return
}
}
// 处理图片结果(如果生成了图片)
if needImage {
if imageRes.err != nil {
service.Error(ctx, fmt.Errorf("生成竞品报告图片失败: %v", imageRes.err))
return
}
}
// 返回结果(只返回实际生成的内容)
result := CompetitorReportResponse{}
if needText {
// 将 JSON 解析为结构化数据
fmt.Println("========== 开始解析 JSON ==========")
fmt.Println("原始内容是否以 { 开头:", strings.HasPrefix(strings.TrimSpace(textRes.text), "{"))
fmt.Println("原始内容前100字符:", strings.TrimSpace(textRes.text)[:min(100, len(strings.TrimSpace(textRes.text)))])
var jsonData CompetitorReportData
if err := json.Unmarshal([]byte(textRes.text), &jsonData); err != nil {
// 如果解析失败,直接报错
fmt.Println("========== JSON 解析失败 ==========")
fmt.Println("解析错误:", err)
fmt.Println("===================================")
service.Error(ctx, errors.New("AI生成格式错误请重试"))
return
} else {
fmt.Println("========== JSON 解析成功 ==========")
fmt.Println("Summary:", jsonData.HighlightAnalysis.Summary)
fmt.Println("==================================")
// 赋值结构体到 JsonData 中
result.JsonData = &jsonData
result.Text = convertJSONToTextFromData(jsonData, len(req.Videos) > 0)
}
}
if needImage {
result.ImageURL = imageRes.imageURL
}
service.Success(ctx, result)
}