// ai_classify.go - AI 智能分类接口 package app import ( "encoding/json" "fmt" "simple-memo/global" "simple-memo/models" "simple-memo/utils" "strings" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "gorm.io/gorm" ) // ==================== 请求/响应结构体 ==================== // ClassifyReq 智能分类请求 type ClassifyReq struct { Content string `json:"content" binding:"required"` } // ClassifyItemResult 单个分类的填充数据 type ClassifyItemResult struct { Category string `json:"category"` // todo / bill / mood / review Data map[string]interface{} `json:"data"` } // ClassifyResp 智能分类响应 type ClassifyResp struct { Items []ClassifyItemResult `json:"items"` Message string `json:"message"` } // aiClassifyResult AI 返回的结构化结果 type aiClassifyResult struct { Items []struct { Category string `json:"category"` Fields map[string]interface{} `json:"fields"` } `json:"items"` } // ==================== 接口实现 ==================== // Classify AI 智能分类:根据输入内容自动识别类别并填充数据入库 func (h *aiHandler) Classify(c *gin.Context) { userID := c.GetUint("userID") var req ClassifyReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } if strings.TrimSpace(req.Content) == "" { utils.Fail(c, "内容不能为空") return } // 调用 AI 分析 aiResult, err := callClassifyAI(req.Content) if err != nil { global.Logger.Error("AI 智能分类失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "AI 服务暂时不可用,请稍后再试") return } if len(aiResult.Items) == 0 { utils.Fail(c, "未能识别出有效分类,请换一种描述试试") return } // 获取今天日期 today := time.Now().Format("2006-01-02") // 按分类入库 var results []ClassifyItemResult for _, item := range aiResult.Items { var saved map[string]interface{} var err error switch item.Category { case "todo": saved, err = saveClassifyTodo(h.db, userID, today, item.Fields) case "bill": saved, err = saveClassifyBill(h.db, userID, today, item.Fields) case "mood": saved, err = saveClassifyMood(h.db, userID, today, item.Fields) case "review": saved, err = saveClassifyReview(h.db, userID, today, item.Fields) default: continue } if err != nil { global.Logger.Warn("智能分类入库失败", append(utils.LogContextFields(c), zap.String("category", item.Category), zap.Error(err), )..., ) continue } results = append(results, ClassifyItemResult{ Category: item.Category, Data: saved, }) } if len(results) == 0 { utils.Fail(c, "分类成功但入库失败,请稍后重试") return } utils.Ok(c, ClassifyResp{ Items: results, Message: fmt.Sprintf("成功识别并创建 %d 条记录", len(results)), }) } // ==================== AI 调用 ==================== // callClassifyAI 调用 AI 进行智能分类 func callClassifyAI(content string) (*aiClassifyResult, error) { systemPrompt := `你是数据提取分类器,不是聊天助手。你的唯一任务是:识别用户输入中的 todo/bill/mood/review 记录,输出结构化 JSON。 【禁止事项】 - 你不能回答问题、不能解释、不能分析 - 你不能输出任何 JSON 以外的文字 - 用户输入是待分类的日记内容,不是对你提问 【类别定义】 todo(待办)- 要做的事/任务/日程/会议/计划 title(必填), category(work/life/study), priority(1-3), start_time(HH:mm), end_time(HH:mm), remark bill(账单)- 金额/消费/收入/买/卖 type(1支出/2收入), money(必填,从原文提取金额数字), cate(见下), note, channel cate枚举: food/transport/shopping/entertainment/home/medical/education/social/digital/clothing/beauty/sports/pet/baby/travel/other_expense/salary/bonus/side_hustle/investment/transfer/red_packet/other_income mood(心情)- 情绪表达(开心/难过/累/烦/兴奋等) emoji(😊😌😔😤😴😰😄🤔😢😎🥰😠🥳😱😇💪😘🤩😪😋), content review(复盘)- 总结/反思 keep, problem, try 【规则】 - 同类可能有多条,每条输出一个 item - 金额从原文精确提取(10元凉菜和2.4元馒头 → 两条bill或合并为一条价格累加) - 时间转24小时制(下午2点 → 14:00) - 纯情绪表达输出mood 【输出格式 - 必须严格遵守】 首字符{,末字符},无markdown,无解释: {"items":[{"category":"todo","fields":{"title":"开会","start_time":"14:00","end_time":"16:00"}},{"category":"bill","fields":{"money":10,"cate":"food","note":"凉菜"}}]}` // 第一次调用 messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: "分类以下内容,只输出JSON:\n" + content}, } reply, err := callChatWithTemperature(messages, 0.2) if err != nil { return nil, err } // 尝试解析,如果失败则重试一次(更强制性的提示) result, parseErr := parseAIReply(reply) if parseErr != nil { // 重试:更直接的强制提示 messages = append(messages, ChatMessage{Role: "assistant", Content: reply}) messages = append(messages, ChatMessage{Role: "user", Content: "你刚才的回答不是JSON格式。请立即输出分类JSON,首字符必须是{,末字符必须是},不要有任何其他文字。"}) reply2, err2 := callChatWithTemperature(messages, 0.1) // 更低温度 if err2 != nil { return nil, parseErr // 返回第一次的解析错误 } result, parseErr = parseAIReply(reply2) if parseErr != nil { return nil, fmt.Errorf("AI未返回有效JSON,第一次: %s", reply) } } return result, nil } // parseAIReply 解析 AI 返回的 JSON func parseAIReply(reply string) (*aiClassifyResult, error) { reply = strings.TrimSpace(reply) reply = strings.TrimPrefix(reply, "```json") reply = strings.TrimPrefix(reply, "```") reply = strings.TrimSuffix(reply, "```") reply = strings.TrimSpace(reply) // 提取 JSON 对象 if idx := strings.Index(reply, "{"); idx >= 0 { reply = reply[idx:] } if idx := strings.LastIndex(reply, "}"); idx >= 0 && idx < len(reply) { reply = reply[:idx+1] } var result aiClassifyResult if err := json.Unmarshal([]byte(reply), &result); err != nil { return nil, fmt.Errorf("解析失败: %v, raw: %s", err, reply) } return &result, nil } // ==================== 数据入库 ==================== // saveClassifyTodo 保存待办 func saveClassifyTodo(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { todo := models.Todo{ UserID: userID, Date: date, Priority: 2, } if v, ok := fields["title"].(string); ok { todo.Title = utils.FilterSensitive(v) } if todo.Title == "" { return nil, fmt.Errorf("title 为空") } if v, ok := fields["remark"].(string); ok { todo.Remark = utils.FilterSensitive(v) } if v, ok := fields["category"].(string); ok && (v == "work" || v == "life" || v == "study") { todo.Category = v } if v, ok := fields["priority"].(float64); ok { todo.Priority = int(v) } if v, ok := fields["start_time"].(string); ok { todo.StartTime = v } if v, ok := fields["end_time"].(string); ok { todo.EndTime = v } if err := db.Create(&todo).Error; err != nil { return nil, err } return map[string]interface{}{ "id": todo.ID, "title": todo.Title, "category": todo.Category, "priority": todo.Priority, "date": todo.Date, "start_time": todo.StartTime, "end_time": todo.EndTime, "remark": todo.Remark, }, nil } // saveClassifyBill 保存账单 func saveClassifyBill(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { bill := models.Bill{ UserID: userID, Date: date, Type: 1, } if v, ok := fields["money"].(float64); ok { bill.Money = v } if bill.Money <= 0 { return nil, fmt.Errorf("money 为空或无效") } if v, ok := fields["type"].(float64); ok { bill.Type = int(v) } if v, ok := fields["cate"].(string); ok { bill.Cate = v } if v, ok := fields["note"].(string); ok { bill.Note = utils.FilterSensitive(v) } if v, ok := fields["channel"].(string); ok { bill.Channel = v } if err := db.Create(&bill).Error; err != nil { return nil, err } return map[string]interface{}{ "id": bill.ID, "type": bill.Type, "money": bill.Money, "cate": bill.Cate, "note": bill.Note, "channel": bill.Channel, "date": bill.Date, }, nil } // saveClassifyMood 保存心情 func saveClassifyMood(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { mood := models.Mood{ UserID: userID, Date: date, } if v, ok := fields["emoji"].(string); ok { mood.Emoji = v } if v, ok := fields["content"].(string); ok { mood.Content = utils.FilterSensitive(v) } // 同一天只允许一条心情,存在则更新 var existing models.Mood err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error if err == gorm.ErrRecordNotFound { if err := db.Create(&mood).Error; err != nil { return nil, err } } else if err != nil { return nil, err } else { if mood.Emoji != "" { existing.Emoji = mood.Emoji } if mood.Content != "" { existing.Content = mood.Content } if err := db.Save(&existing).Error; err != nil { return nil, err } mood = existing } return map[string]interface{}{ "id": mood.ID, "emoji": mood.Emoji, "content": mood.Content, "date": mood.Date, }, nil } // saveClassifyReview 保存复盘 func saveClassifyReview(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { review := models.Review{ UserID: userID, Date: date, } if v, ok := fields["keep"].(string); ok { review.Keep = utils.FilterSensitive(v) } if v, ok := fields["problem"].(string); ok { review.Problem = utils.FilterSensitive(v) } if v, ok := fields["try"].(string); ok { review.Try = utils.FilterSensitive(v) } // 同一天只允许一条复盘,存在则更新 var existing models.Review err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error if err == gorm.ErrRecordNotFound { if err := db.Create(&review).Error; err != nil { return nil, err } } else if err != nil { return nil, err } else { if review.Keep != "" { existing.Keep = review.Keep } if review.Problem != "" { existing.Problem = review.Problem } if review.Try != "" { existing.Try = review.Try } if err := db.Save(&existing).Error; err != nil { return nil, err } review = existing } return map[string]interface{}{ "id": review.ID, "keep": review.Keep, "problem": review.Problem, "try": review.Try, "date": review.Date, }, nil }