Files
sunct 3c3bf53ae4 docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档
- 新增简记memo完整版UI视觉设计规范和界面细节
- 添加IDEA项目配置文件.gitignore
- 创建404页面HTML文件,包含响应式布局和错误提示
- 添加关于页面HTML文件,展示品牌介绍和团队信息
- 实现AES加解密工具函数,支持请求体加密
- 添加用户协议页面基础框架
2026-07-31 14:12:32 +08:00

399 lines
11 KiB
Go
Raw Permalink 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.
// 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
}