Files
hr_data_analyzer/controllers/ai.go
T
2026-08-05 11:11:59 +08:00

700 lines
26 KiB
Go

// 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"
audienceTypeKindergarten = "kindergarten"
audienceTypePrimarySchool = "primary_school"
audienceTypeMiddleSchool = "middle_school"
audienceTypeUniversity = "university"
audienceTypeYoungAdult = "young_adult"
audienceTypeMiddleAged = "middle_aged"
audienceTypeElderly = "elderly"
audienceTypeArmy = "army"
sourceUpload = "upload"
sourceCloud = "cloud"
sourceWechat = "wechat"
)
func resolveAudienceLabel(audienceType string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(audienceType)) {
case "", audienceTypeKindergarten:
return "幼儿园", true
case audienceTypePrimarySchool:
return "小学", true
case audienceTypeMiddleSchool:
return "中学", true
case audienceTypeUniversity:
return "大学", true
case audienceTypeYoungAdult:
return "青年人", true
case audienceTypeMiddleAged:
return "中年人", true
case audienceTypeElderly:
return "老年人", true
case audienceTypeArmy:
return "18-25岁军人", true
default:
return "", false
}
}
func resolveAudienceDescription(audienceType, audienceDetail string) (string, bool) {
audienceLabel, ok := resolveAudienceLabel(audienceType)
if !ok {
return "", false
}
detail := strings.TrimSpace(audienceDetail)
if detail == "" || strings.Contains(detail, audienceLabel) {
if detail != "" {
return detail, true
}
return audienceLabel, true
}
return audienceLabel + detail, true
}
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, audienceLabel string) string {
if analysisType == analysisTypeHeartRateWithSteps {
return fmt.Sprintf(`请根据以下体育课堂的教案、心率监测数据和训练结束步数汇总,生成一份详细的课堂分析报告:
## 教案内容:
%s
## 心率监测数据:
%s
## 训练结束步数汇总:
%s
这是一份面向%s人群的体育课教案、课程心率监测数据和训练结束步数汇总。请结合该人群的年龄、生理特点和运动安全要求,分析课程教学效果、运动量和运动负荷情况是否科学,并提出课程设计的优化方案。
分析要求:
1. 步数只作为移动量、活动密度和参与度的辅助参考,不能替代心率负荷判断。
2. 请判断步数与心率是否一致。例如高步数高心率通常说明移动量较大;低步数高心率则可能是力量、支撑、跳跃、对抗或其他无氧/原地高强度活动。
3. 不要简单以步数高低判断运动量是否合理,必须结合教案内容、动作形式和心率变化综合判断。
4. 在教学建议中明确说明本节课是否适合继续使用步数作为辅助分析指标。
5. 根据每个人的心率变化、平均心率、峰值心率、心率区间停留时间、有效运动时长、步数以及教案中的运动项目和动作形式,分别估算本节课的运动热量消耗。
6. 必须按心率数据和步数数据中的人员编号逐人输出,不得只给班级平均值;无法匹配的数据需明确标注。
7. 如果缺少体重、性别或精确年龄,不要给出虚假精确值。请根据适用人群采用合理的参考体重区间,并明确列出假设,最终以千卡区间表示,同时给出估算可信度(高、中、低)。
8. 热量估算应以心率反映的运动强度和持续时间为主,以步数和教案动作类型进行修正;对于力量、跳跃、支撑、对抗等低步数但高负荷项目,不得仅因步数较少而低估消耗。
## 逐人热量消耗估算
请在课堂整体分析后单独输出下表,覆盖全部可识别人员:
| 人员编号 | 有效运动时长 | 平均/峰值心率 | 主要心率区间 | 步数 | 参考体重假设 | 估算消耗(千卡) | 可信度 | 估算依据与异常说明 |
| -------- | ------------ | ------------- | ------------ | ---- | ------------ | ---------------- | ------ | ------------------ |
表格后请补充全班估算总消耗、个人平均消耗、最高和最低估算消耗,并说明这些结果仅用于运动教学分析,不替代专业代谢检测或医疗评估。
优化方案参考如下格式,教学过程需要详细一些:
# 体育教案优化方案
| 项目 | 内容 |
| ------------ | -------------------------------- |
| **课程名** | |
| **适用人群/年级** | |
| **教师姓名** | |
| **时间** | 年 月 日 |
| **地点** | |
| **人数** | 男: 女: |
| **时长** | 分钟 |
| **天气预报** | 晴 雨 阴;温度 ℃ |
| **器材准备** | |
## 教学目标
| 类型 | 目标 |
| -------- | ------------ |
| **体能目标** | |
| **技能目标** | |
| **情感目标** | |
## 教学过程
| 阶段 | 阶段 | 项目名称 | 引导语及教学方法 | 队形/站位/留意点 | 目标心率区间 | 时间(分) |
| ---------- | -------- | ----------------------------- | ------------------------ | --------------------- | ------------ | ---------- |
| **准备部分** | 热身与状态评估 | | | | | |
| **正课部分** | 技能学习 | | | | | |
| | 体能练习 | | | | | |
| | 综合应用 | | | | | |
| **结束部分** | 整理放松 | | | | | |
| | 课堂总结 | | | | | |
请以专业体育教师的视角,提供详细的数据分析和教学建议。请直接输出报告内容,不要包含"好的"、"收到"、"作为一名..."等任何开场白或客套话。`, teachingPlanContent, heartRateContent, stepContent, audienceLabel)
}
return fmt.Sprintf(`请根据以下体育课堂的教案和心率监测数据,生成一份详细的课堂分析报告:
## 教案内容:
%s
## 心率监测数据:
%s
这是一份面向%s人群的体育课教案和课程心率监测数据。请结合该人群的年龄、生理特点和运动安全要求,对照分析课程教学效果、运动量和运动负荷情况是否科学,并提出课程设计的优化方案。
优化方案参考如下格式,教学过程需要详细一些:
# 体育教案优化方案
| 项目 | 内容 |
| ------------ | -------------------------------- |
| **课程名** | |
| **适用人群/年级** | |
| **教师姓名** | |
| **时间** | 年 月 日 |
| **地点** | |
| **人数** | 男: 女: |
| **时长** | 分钟 |
| **天气预报** | 晴 雨 阴;温度 ℃ |
| **器材准备** | |
## 教学目标
| 类型 | 目标 |
| -------- | ------------ |
| **体能目标** | |
| **技能目标** | |
| **情感目标** | |
## 教学过程
| 阶段 | 阶段 | 项目名称 | 引导语及教学方法 | 队形/站位/留意点 | 目标心率区间 | 时间(分) |
| ---------- | -------- | ----------------------------- | ------------------------ | --------------------- | ------------ | ---------- |
| **准备部分** | 热身与状态评估 | | | | | |
| **正课部分** | 技能学习 | | | | | |
| | 体能练习 | | | | | |
| | 综合应用 | | | | | |
| **结束部分** | 整理放松 | | | | | |
| | 课堂总结 | | | | | |
请以专业体育教师的视角,提供详细的数据分析和教学建议。请直接输出报告内容,不要包含"好的"、"收到"、"作为一名..."等任何开场白或客套话。`, teachingPlanContent, heartRateContent, audienceLabel)
}
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: 8000,
},
)
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 audience_type formData string false "适用人群: kindergarten(默认) | primary_school | middle_school | university | young_adult | middle_aged | elderly | army"
// @Param audience_detail formData string false "精确班级或年级,例如: 小班、中班、大班、小学一年级"
// @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")
audienceType := c.PostForm("audience_type")
audienceDetail := c.PostForm("audience_detail")
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
}
audienceLabel, ok := resolveAudienceDescription(audienceType, audienceDetail)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audience_type, expected kindergarten, primary_school, middle_school, university, young_adult, middle_aged, elderly or army"})
return
}
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, audienceLabel)
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: 8000,
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)
}
}