- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
399 lines
11 KiB
Go
399 lines
11 KiB
Go
// 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
|
||
}
|