Files
hr_data_analyzer/controllers/ai.go
T
2026-05-04 16:20:46 +08:00

634 lines
22 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.
// controllers/ai.go
package controllers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sashabaranov/go-openai"
"hr_receiver/config"
"hr_receiver/models"
"hr_receiver/util"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
const (
analysisTypeHeartRateOnly = "heart_rate_only"
analysisTypeHeartRateWithSteps = "heart_rate_with_steps"
sourceUpload = "upload"
sourceCloud = "cloud"
sourceWechat = "wechat"
)
func readDocxContent(fileHeader *multipart.FileHeader) (string, error) {
tempFile, err := os.CreateTemp("", "upload_*.docx")
if err != nil {
return "", fmt.Errorf("failed to create temporary file: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
src, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
_, err = io.Copy(tempFile, src)
if err != nil {
return "", fmt.Errorf("failed to copy file to temporary location: %w", err)
}
tempFilePath := tempFile.Name()
str, err := util.DocxToStructuredPrompt(tempFilePath)
if err != nil {
return "", fmt.Errorf("failed to parse docx with go-docx: %w", err)
}
return str, nil
}
func readDocxContentFromPath(filePath string) (string, error) {
str, err := util.DocxToStructuredPrompt(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse docx with go-docx: %w", err)
}
return str, nil
}
func readCSVContent(fileHeader *multipart.FileHeader) (string, error) {
tempFile, err := os.CreateTemp("", "upload_*.csv")
if err != nil {
return "", fmt.Errorf("failed to create temporary file: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
src, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
_, err = io.Copy(tempFile, src)
if err != nil {
return "", fmt.Errorf("failed to copy file to temporary location: %w", err)
}
content, err := ioutil.ReadFile(tempFile.Name())
if err != nil {
return "", fmt.Errorf("failed to read CSV content from temporary file: %w", err)
}
lines := strings.Split(string(content), "\n")
var compressedLines []string
for i, line := range lines {
if i == 0 {
compressedLines = append(compressedLines, line)
continue
}
if strings.TrimSpace(line) == "" {
continue
}
if (i-1)%4 == 0 {
compressedLines = append(compressedLines, line)
}
}
resultContent := strings.Join(compressedLines, "\n")
return resultContent, nil
}
func buildAnalysisPrompt(teachingPlanContent, heartRateContent, analysisType, stepContent string) string {
if analysisType == analysisTypeHeartRateWithSteps {
return fmt.Sprintf(`请根据以下体育课堂的教案、心率监测数据和训练结束步数汇总,生成一份详细的课堂分析报告:
## 教案内容:
%s
## 心率监测数据:
%s
## 训练结束步数汇总:
%s
这是一份幼儿园体育课的教案、课程心率监测数据和训练结束步数汇总。请结合三类信息分析课程教学效果、运动量和运动负荷情况是否科学,并提出课程设计的优化方案。
分析要求:
1. 步数只作为移动量、活动密度和参与度的辅助参考,不能替代心率负荷判断。
2. 请判断步数与心率是否一致。例如高步数高心率通常说明移动量较大;低步数高心率则可能是力量、支撑、跳跃、对抗或其他无氧/原地高强度活动。
3. 不要简单以步数高低判断运动量是否合理,必须结合教案内容、动作形式和心率变化综合判断。
4. 在教学建议中明确说明本节课是否适合继续使用步数作为辅助分析指标。
优化方案参考如下格式,教学过程需要详细一些:
# 幼儿体育教案(华侨大学版本)
| 项目 | 内容 |
| ------------ | -------------------------------- |
| **课程名** | |
| **年段** | 小 中 大 |
| **教师姓名** | |
| **时间** | 年 月 日 |
| **地点** | |
| **人数** | 男: 女: |
| **时长** | 分钟 |
| **天气预报** | 晴 雨 阴;温度 ℃ |
| **器材准备** | |
## 教学目标
| 类型 | 目标 |
| -------- | ------------ |
| **体能目标** | |
| **技能目标** | |
| **情感目标** | |
## 教学过程
| 阶段 | 阶段 | 项目名称 | 引导语及教学方法 | 队形/站位/留意点 | 目标心率区间 | 时间(分) |
| ---------- | -------- | ----------------------------- | ------------------------ | --------------------- | ------------ | ---------- |
| **准备部分** | 热身 | | | | | 3 |
| | 注意力游戏 | | | | | 3 |
| **正课部分** | 基本素质练习及常规意识培养环节 | | | | | 5 |
| | 复习环节 | | | | | 5 |
| | 新授环节 | | | | | 8 |
| **结束部分** | 社会性及情感目标游戏 | | | | | 4 |
| | 整理放松 | | | | | 2 |
请以专业体育教师的视角,提供详细的数据分析和教学建议。请直接输出报告内容,不要包含"好的"、"收到"、"作为一名..."等任何开场白或客套话。`, teachingPlanContent, heartRateContent, stepContent)
}
return fmt.Sprintf(`请根据以下体育课堂的教案和心率监测数据,生成一份详细的课堂分析报告:
## 教案内容:
%s
## 心率监测数据:
%s
这是一份幼儿园体育课的教案和课程心率监测数据,请帮对照分析课程教学效果,运动量和运动负荷情况是否科学,并提出课程设计的优化方案。
优化方案参考如下格式,教学过程需要详细一些:
# 幼儿体育教案(华侨大学版本)
| 项目 | 内容 |
| ------------ | -------------------------------- |
| **课程名** | |
| **年段** | 小 中 大 |
| **教师姓名** | |
| **时间** | 年 月 日 |
| **地点** | |
| **人数** | 男: 女: |
| **时长** | 分钟 |
| **天气预报** | 晴 雨 阴;温度 ℃ |
| **器材准备** | |
## 教学目标
| 类型 | 目标 |
| -------- | ------------ |
| **体能目标** | |
| **技能目标** | |
| **情感目标** | |
## 教学过程
| 阶段 | 阶段 | 项目名称 | 引导语及教学方法 | 队形/站位/留意点 | 目标心率区间 | 时间(分) |
| ---------- | -------- | ----------------------------- | ------------------------ | --------------------- | ------------ | ---------- |
| **准备部分** | 热身 | | | | | 3 |
| | 注意力游戏 | | | | | 3 |
| **正课部分** | 基本素质练习及常规意识培养环节 | | | | | 5 |
| | 复习环节 | | | | | 5 |
| | 新授环节 | | | | | 8 |
| **结束部分** | 社会性及情感目标游戏 | | | | | 4 |
| | 整理放松 | | | | | 2 |
请以专业体育教师的视角,提供详细的数据分析和教学建议。请直接输出报告内容,不要包含"好的"、"收到"、"作为一名..."等任何开场白或客套话。`, teachingPlanContent, heartRateContent)
}
type aiAnalysisResult struct {
Content string
InputTokens int
OutputTokens int
CacheHitTokens int
CacheMissTokens int
InputSizeBytes int
OutputSizeBytes int
}
func callAIForAnalysis(prompt string) (*aiAnalysisResult, error) {
sizeInBytes := len(prompt)
sizeInKB := float64(sizeInBytes) / 1024.0
log.Printf("=== 发送给 AI 的内容大小: %.2f KB (%d 字节) ===", sizeInKB, sizeInBytes)
baseURL, apiKey, model, err := config.GetAIConfig()
if err != nil {
return nil, err
}
clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = baseURL
client := openai.NewClientWithConfig(clientConfig)
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0.6,
TopP: 0.6,
MaxCompletionTokens: 4000,
},
)
if err != nil {
return nil, fmt.Errorf("API call failed: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("no choices returned from API")
}
content := resp.Choices[0].Message.Content
cacheHitTokens := 0
if resp.Usage.PromptTokensDetails != nil {
cacheHitTokens = resp.Usage.PromptTokensDetails.CachedTokens
}
return &aiAnalysisResult{
Content: content,
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
CacheHitTokens: cacheHitTokens,
CacheMissTokens: resp.Usage.PromptTokens - cacheHitTokens,
InputSizeBytes: len(prompt),
OutputSizeBytes: len(content),
}, nil
}
// @Summary AI分析
// @Description 上传心率CSV和教案文件通过AI生成课堂分析报告支持流式和非流式输出
// @Tags AI分析
// @Accept multipart/form-data
// @Produce json
// @Param heart_rate_data formData file true "心率数据CSV文件"
// @Param step_data formData file false "步数数据CSV文件(analysis_type为heart_rate_with_steps时必填)"
// @Param teaching_plan formData file false "教案DOCX文件(teaching_plan_source为upload/wechat时必填)"
// @Param analysis_type formData string false "分析类型: heart_rate_only(默认) | heart_rate_with_steps"
// @Param teaching_plan_source formData string false "教案来源: upload(默认) | cloud | wechat"
// @Param regionid formData string false "区域ID"
// @Param trainid formData string false "训练ID"
// @Param lesson_plan_id formData string false "云端教案ID(teaching_plan_source=cloud时必填)"
// @Param stream formData string false "是否流式输出: true | false"
// @Success 200 {object} SwagAPIResponse "分析成功"
// @Failure 400 {object} SwagAPIResponse "请求参数错误"
// @Router /train-records/analysis-by-ai [post]
func (tc *TrainingController) AnalyzeByAI(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
log.Printf("Error parsing multipart form: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to parse form: %v", err)})
return
}
csvFiles := form.File["heart_rate_data"]
stepFiles := form.File["step_data"]
analysisType := c.PostForm("analysis_type")
teachingPlanSource := c.PostForm("teaching_plan_source")
regionIDStr := c.PostForm("regionid")
trainID := c.PostForm("trainid")
streamStr := c.PostForm("stream")
useStream := streamStr == "true"
if analysisType == "" {
analysisType = analysisTypeHeartRateOnly
}
if teachingPlanSource == "" {
teachingPlanSource = sourceUpload
}
if len(csvFiles) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required file: heart_rate_data (.csv)"})
return
}
if analysisType == analysisTypeHeartRateWithSteps && len(stepFiles) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required file: step_data (.csv) for heart_rate_with_steps"})
return
}
uploadTime := time.Now().UnixMilli()
heartRateFileHeader := csvFiles[0]
teachingPlanContent, teachingPlanSize, err := resolveTeachingPlanContent(c, form, teachingPlanSource)
if err != nil {
log.Printf("Error resolving teaching plan: %v", err)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Cloud teaching plan file not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
heartRateContent, err := readCSVContent(heartRateFileHeader)
if err != nil {
log.Printf("Error reading heart rate file (%s): %v", heartRateFileHeader.Filename, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to process heart rate file: %v", err)})
return
}
stepContent := ""
var stepFileSize int64 = 0
if analysisType == analysisTypeHeartRateWithSteps {
stepFileHeader := stepFiles[0]
stepFileSize = stepFileHeader.Size
stepContent, err = readCSVContent(stepFileHeader)
if err != nil {
log.Printf("Error reading step file (%s): %v", stepFileHeader.Filename, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to process step file: %v", err)})
return
}
}
originalFileSize := heartRateFileHeader.Size + teachingPlanSize + stepFileSize
compressedContentSize := int64(len(heartRateContent)) + int64(len(teachingPlanContent)) + int64(len(stepContent))
prompt := buildAnalysisPrompt(teachingPlanContent, heartRateContent, analysisType, stepContent)
startTime := time.Now()
var regionID *uint32
if regionIDStr != "" {
if parsed, err := strconv.ParseUint(regionIDStr, 10, 32); err == nil {
id := uint32(parsed)
regionID = &id
}
}
if useStream {
tc.streamAIAnalysis(c, prompt, regionID, trainID, teachingPlanSource, analysisType,
originalFileSize, compressedContentSize, uploadTime)
return
}
analysisResult, err := callAIForAnalysis(prompt)
if err != nil {
log.Printf("Error calling AI for analysis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("AI analysis failed: %v", err)})
return
}
durationMs := time.Since(startTime).Milliseconds()
saveAnalysisRecord(analysisResult.Content, analysisResult.InputTokens, analysisResult.OutputTokens,
analysisResult.CacheHitTokens, analysisResult.CacheMissTokens,
analysisResult.InputSizeBytes, analysisResult.OutputSizeBytes,
regionID, trainID, teachingPlanSource, analysisType,
originalFileSize, compressedContentSize, uploadTime, durationMs)
c.JSON(http.StatusOK, gin.H{
"status": "success",
"data": analysisResult.Content,
})
}
type streamCollector struct {
fullContent string
inputTokens int
outputTokens int
cacheHitTokens int
cacheMissTokens int
}
func newStreamCollector() *streamCollector {
return &streamCollector{}
}
func (sc *streamCollector) add(delta string) {
sc.fullContent += delta
}
func (sc *streamCollector) updateUsage(usage *openai.Usage) {
sc.inputTokens = usage.PromptTokens
sc.outputTokens = usage.CompletionTokens
if usage.PromptTokensDetails != nil {
sc.cacheHitTokens = usage.PromptTokensDetails.CachedTokens
}
sc.cacheMissTokens = sc.inputTokens - sc.cacheHitTokens
}
func (tc *TrainingController) streamAIAnalysis(c *gin.Context, prompt string,
regionID *uint32, trainID, sourceType, analysisType string,
originalFileSize, compressedContentSize int64, uploadTime int64) {
sizeInBytes := len(prompt)
sizeInKB := float64(sizeInBytes) / 1024.0
log.Printf("=== 发送给流式 AI 的内容大小: %.2f KB (%d 字节) ===", sizeInKB, sizeInBytes)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.WriteHeader(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
log.Printf("streaming not supported")
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
baseURL, apiKey, model, err := config.GetAIConfig()
if err != nil {
sendSSEError(c, err.Error())
return
}
clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = baseURL
client := openai.NewClientWithConfig(clientConfig)
stream, err := client.CreateChatCompletionStream(
c.Request.Context(),
openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
Temperature: 0.6,
TopP: 0.6,
MaxCompletionTokens: 4000,
Stream: true,
StreamOptions: &openai.StreamOptions{
IncludeUsage: true,
},
},
)
if err != nil {
sendSSEError(c, fmt.Sprintf("stream failed: %v", err))
return
}
defer stream.Close()
startTime := time.Now()
collector := newStreamCollector()
for {
response, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
sendSSEError(c, fmt.Sprintf("stream recv error: %v", recvErr))
return
}
if len(response.Choices) > 0 {
delta := response.Choices[0].Delta.Content
collector.add(delta)
sendSSEData(c, map[string]interface{}{"content": delta})
flusher.Flush()
}
if response.Usage != nil {
collector.updateUsage(response.Usage)
}
}
durationMs := time.Since(startTime).Milliseconds()
saveAnalysisRecord(collector.fullContent, collector.inputTokens, collector.outputTokens,
collector.cacheHitTokens, collector.cacheMissTokens,
len(prompt), len(collector.fullContent),
regionID, trainID, sourceType, analysisType,
originalFileSize, compressedContentSize, uploadTime, durationMs)
sendSSEData(c, map[string]interface{}{
"done": true,
"inputTokens": collector.inputTokens,
"outputTokens": collector.outputTokens,
"cacheHitTokens": collector.cacheHitTokens,
})
flusher.Flush()
}
func sendSSEData(c *gin.Context, data map[string]interface{}) {
b, _ := json.Marshal(data)
fmt.Fprintf(c.Writer, "data: %s\n\n", string(b))
}
func sendSSEError(c *gin.Context, msg string) {
b, _ := json.Marshal(map[string]string{"error": msg})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(b))
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
}
func saveAnalysisRecord(content string, inputTokens, outputTokens, cacheHitTokens, cacheMissTokens,
inputSizeBytes, outputSizeBytes int,
regionID *uint32, trainID, sourceType, analysisType string,
originalFileSize, compressedContentSize int64, uploadTime int64, durationMs int64) {
var pricing models.AIPricingConfig
var costJSON string
var totalCost float64
if err := config.DB.First(&pricing).Error; err == nil {
cacheMissPrice := pricing.CacheMissPricePerMillion
if cacheMissPrice == 0 {
cacheMissPrice = pricing.InputPricePerMillion
}
cacheHitPrice := pricing.CacheHitPricePerMillion
if cacheHitPrice == 0 {
cacheHitPrice = pricing.InputPricePerMillion
}
cacheHitCost := float64(cacheHitTokens) * cacheHitPrice / 1_000_000
cacheMissCost := float64(cacheMissTokens) * cacheMissPrice / 1_000_000
outputCost := float64(outputTokens) * pricing.OutputPricePerMillion / 1_000_000
totalCost = cacheHitCost + cacheMissCost + outputCost
costInfo := map[string]interface{}{
"pricingName": pricing.Name,
"provider": pricing.Provider,
"inputPricePerMillion": pricing.InputPricePerMillion,
"cacheHitPricePerMillion": cacheHitPrice,
"cacheMissPricePerMillion": cacheMissPrice,
"outputPricePerMillion": pricing.OutputPricePerMillion,
"cacheHitCost": cacheHitCost,
"cacheMissCost": cacheMissCost,
"outputCost": outputCost,
}
if b, err := json.Marshal(costInfo); err == nil {
costJSON = string(b)
}
}
record := models.AIAnalysisRecord{
RegionID: regionID,
TrainId: trainID,
SourceType: sourceType,
AnalysisType: analysisType,
AnalysisResult: content,
CostJSON: costJSON,
TotalCost: totalCost,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheHitTokens: cacheHitTokens,
CacheMissTokens: cacheMissTokens,
InputSizeBytes: inputSizeBytes,
OutputSizeBytes: outputSizeBytes,
DurationMs: durationMs,
OriginalFileSize: originalFileSize,
CompressedContentSize: compressedContentSize,
UploadTime: uploadTime,
}
if err := config.DB.Create(&record).Error; err != nil {
log.Printf("Failed to save analysis record: %v", err)
}
}
func resolveTeachingPlanContent(c *gin.Context, form *multipart.Form, source string) (string, int64, error) {
switch strings.ToLower(strings.TrimSpace(source)) {
case sourceUpload:
docxFiles := form.File["teaching_plan"]
if len(docxFiles) == 0 {
return "", 0, fmt.Errorf("Missing required file: teaching_plan (.docx)")
}
content, err := readDocxContent(docxFiles[0])
return content, docxFiles[0].Size, err
case sourceWechat:
docxFiles := form.File["teaching_plan"]
if len(docxFiles) == 0 {
return "", 0, fmt.Errorf("Missing required file: teaching_plan (.docx)")
}
content, err := readDocxContent(docxFiles[0])
return content, docxFiles[0].Size, err
case sourceCloud:
lessonPlanID := c.PostForm("lesson_plan_id")
if strings.TrimSpace(lessonPlanID) == "" {
return "", 0, fmt.Errorf("missing required field: lesson_plan_id")
}
var fileRecord models.AppFile
if err := config.DB.Where("id = ? AND file_type = ?", lessonPlanID, models.AppFileTypeLessonPlan).First(&fileRecord).Error; err != nil {
return "", 0, err
}
content, err := readDocxContentFromPath(fileRecord.FilePath)
return content, fileRecord.FileSize, err
default:
return "", 0, fmt.Errorf("invalid teaching_plan_source, expected %s, %s or %s", sourceUpload, sourceWechat, sourceCloud)
}
}