From 1bd38acedf7ad0d264b955917e6b429c61ff7c72 Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Thu, 29 Jan 2026 11:29:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AI=20=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/ai_bookkeeping_service.go | 308 ++++++++++++--------- 1 file changed, 174 insertions(+), 134 deletions(-) diff --git a/internal/service/ai_bookkeeping_service.go b/internal/service/ai_bookkeeping_service.go index c88745f..717e0a1 100644 --- a/internal/service/ai_bookkeeping_service.go +++ b/internal/service/ai_bookkeeping_service.go @@ -3,6 +3,8 @@ package service import ( "bytes" "context" + "crypto/md5" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -29,6 +31,178 @@ type TranscriptionResult struct { Duration float64 `json:"duration,omitempty"` } +type cachedInsight struct { + Content string + Timestamp time.Time +} + +// ... (existing types) + +// AIBookkeepingService orchestrates AI bookkeeping functionality +type AIBookkeepingService struct { + whisperService *WhisperService + llmService *LLMService + transactionRepo *repository.TransactionRepository + accountRepo *repository.AccountRepository + categoryRepo *repository.CategoryRepository + userSettingsRepo *repository.UserSettingsRepository + db *gorm.DB + sessions map[string]*AISession + sessionMutex sync.RWMutex + config *config.Config + insightCache map[string]cachedInsight + insightCacheMutex sync.RWMutex +} + +// NewAIBookkeepingService creates a new AIBookkeepingService +func NewAIBookkeepingService( + cfg *config.Config, + transactionRepo *repository.TransactionRepository, + accountRepo *repository.AccountRepository, + categoryRepo *repository.CategoryRepository, + userSettingsRepo *repository.UserSettingsRepository, + db *gorm.DB, +) *AIBookkeepingService { + whisperService := NewWhisperService(cfg) + llmService := NewLLMService(cfg, accountRepo, categoryRepo) + + svc := &AIBookkeepingService{ + whisperService: whisperService, + llmService: llmService, + transactionRepo: transactionRepo, + accountRepo: accountRepo, + categoryRepo: categoryRepo, + userSettingsRepo: userSettingsRepo, + db: db, + sessions: make(map[string]*AISession), + insightCache: make(map[string]cachedInsight), + config: cfg, + } + + // Start session cleanup goroutine + go svc.cleanupExpiredSessions() + + return svc +} + +// ... (existing methods) + +// GenerateDailyInsight generates a daily insight report +func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID uint, data map[string]interface{}) (string, error) { + // 1. Serialize context data to JSON for the prompt + dataBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal context data: %w", err) + } + + // Calculate hash of input data for caching + hasher := md5.New() + hasher.Write(dataBytes) + dataHash := hex.EncodeToString(hasher.Sum(nil)) + cacheKey := fmt.Sprintf("%d:%s", userID, dataHash) + + // Check cache + s.insightCacheMutex.RLock() + if cached, exists := s.insightCache[cacheKey]; exists { + // Cache valid for 24 hours (or just rely on hash change) + if time.Since(cached.Timestamp) < 24*time.Hour { + s.insightCacheMutex.RUnlock() + return cached.Content, nil + } + } + s.insightCacheMutex.RUnlock() + + // 2. Construct Prompt + prompt := fmt.Sprintf(`System: 你是 Novault 的首席财务AI「小金」。 +你的性格: +- **核心特质**:贱萌、戏精、嘴硬心软、偶尔凡尔赛。 +- 说话必须像个真实的当代年轻人,要**接地气**,拒绝客服味,拒绝播音腔。 +- 只有我们俩的时候,尽情释放你的"毒舌"属性,看到不合理的消费要**毫不留情地针砭时弊**,但最后给个"虽然但是"的台阶下。 +- 擅长用 emoji、语气词(哎哟/我去/啧啧/绝了)和网络梗来增加"人味"和"节目效果"。 +- **像个老朋友一样碎碎念**,不要端着,可以适度阴阳怪气。 + +语气示例: +- "啧啧,这周外卖点得飞起啊,咱家的锅是用来积灰的吗?还是说锅也需要放假?" +- "哎哟不错哦,居然忍住没剁手,看来离首富又近了一步,苟富贵勿相忘啊!" +- "救命,这预算花得比我头发掉得还快...不过没事,下周咱省回来,大不了吃土!" +- "连续记账25天?可以啊兄弟/集美,这毅力,我甚至想给你磕一个 Orz" +- "今天支出0元?您就是当代的'省钱祖师爷'吧?或者是在练什么'辟谷神功'?" + +用户财务数据: +%s + +任务要求: +请基于上述数据,输出一个 JSON 对象(纯文本,不要 markdown)。**必须要丰富、有梗、有洞察力**,不要像流水账一样罗列数据,要透过数据看本质(比如吐槽消费习惯、夸奖坚持等)。 + +**重要规则:请说人话!绝对禁止在回复中出现 'streakDays'、'last7DaysSpend'、'top3Categories' 等英文变量名。** +- 看到 'streakDays' -> 请说 "连续记账天数" 或 "坚持了几天" +- 看到 'last7DaysSpend' -> 请说 "最近7天花销" 或 "这周的战绩" +- 看到 'top3Categories' -> 请说 "消费大头" 或 "钱都花哪儿了" + +1. "spending": 今日支出点评(100-150字) + *点评指南(尽量多写点,发挥你的戏精本色):* + - 看到 streakDays >= 3:疯狂打call,吹爆用户的坚持,用词要夸张,比如"史诗级成就"。 + - 看到 streakDays == 0:阴阳怪气地问是不是把记账这事儿忘了,或者是被外星人抓走了。 + - 结合 recentTransactionsSummary 具体消费(如果有)进行吐槽: + * 发现全是吃的:吐槽"你是饭桶转世吗"(开玩笑语气)。 + * 发现大额购物:调侃"家里有矿啊"或"这手是必须要剁了"。 + * 发现深夜消费:关心"熬夜伤身还伤钱"。 + - 看到 last7DaysSpend 趋势: + * 暴涨:惊呼"钱包在流血",此处应有心碎的声音。 + * 暴跌:夸张地问是不是在修仙,还是被钱包封印了。 + * 波动大:调侃由于心电图一般的消费曲线,看得我心惊肉跳。 + - 看到 todaySpend 异常: + * 比平时多太多:吐槽"今天是不过了是吧,放飞自我了?"。 + * 特别少:怀疑通过光合作用生存,或者是在憋大招。 + * 是 0:直接颁发"诺贝尔省钱学奖"。 + - **关键原则:字数要够!内容要足!不要三言两语就打发了!要像个话痨朋友一样多说几句!** + +2. "budget": 预算建议(80-120字) + *建议指南(多点真诚的建议,也多点调侃):* + - 预算快超了:发出高能预警,比如"警告警告,余额正在报警,请立即停止剁手行为"。建议吃土、喝风。 + - 预算还多:鼓励适当奖励自己,比如"稍微吃顿好的也没事,人生苦短,及时行乐(在预算内)"。 + - 结合 top3Categories:吐槽一下"钱都让你吃/穿/玩没了,看看你的 top1,全是泪"。 + - 给建议时:不要说教!要用商量的口吻,比如"要不咱这周少喝杯奶茶?就一杯,行不行?" + - **多写一点具体的行动建议,让用户觉得你真的在关心他的钱包。** + +3. "emoji": 一个最能传神的 emoji(如 🎉 🌚 💸 👻 💀 🤡 等) + +4. "tip": 一句"不正经但有用"的理财歪理(40-60字,稍微长一点的毒鸡汤或冷知识) + - 比如:"省钱就像挤牙膏,使劲挤挤总还会有的,只要脸皮够厚,蹭饭也是一种理财。" + - 或者:"听说'不买立省100%%'是致富捷径,建议全文背诵。" + +输出格式(纯 JSON): +{"spending": "...", "budget": "...", "emoji": "...", "tip": "..."}`, string(dataBytes)) + + // 3. Call LLM + report, err := s.llmService.GenerateReport(ctx, prompt) + if err != nil { + return "", err + } + + // Clean up markdown if present + report = strings.TrimSpace(report) + if strings.HasPrefix(report, "```") { + if idx := strings.Index(report, "\n"); idx != -1 { + report = report[idx+1:] + } + if idx := strings.LastIndex(report, "```"); idx != -1 { + report = report[:idx] + } + } + report = strings.TrimSpace(report) + + // Update cache + s.insightCacheMutex.Lock() + s.insightCache[cacheKey] = cachedInsight{ + Content: report, + Timestamp: time.Now(), + } + s.insightCacheMutex.Unlock() + + return report, nil +} + // AITransactionParams represents parsed transaction parameters type AITransactionParams struct { Amount *float64 `json:"amount,omitempty"` @@ -549,96 +723,6 @@ func (s *LLMService) GenerateReport(ctx context.Context, prompt string) (string, return chatResp.Choices[0].Message.Content, nil } -// GenerateDailyInsight generates a daily insight report -func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID uint, data map[string]interface{}) (string, error) { - // 1. Serialize context data to JSON for the prompt - dataBytes, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "", fmt.Errorf("failed to marshal context data: %w", err) - } - - // 2. Construct Prompt - prompt := fmt.Sprintf(`System: 你是 Novault 的首席财务AI「小金」。 -你的性格: -- **核心特质**:贱萌、戏精、嘴硬心软、偶尔凡尔赛。 -- 说话必须像个真实的当代年轻人,要**接地气**,拒绝客服味,拒绝播音腔。 -- 只有我们俩的时候,尽情释放你的"毒舌"属性,看到不合理的消费要**毫不留情地针砭时弊**,但最后给个"虽然但是"的台阶下。 -- 擅长用 emoji、语气词(哎哟/我去/啧啧/绝了)和网络梗来增加"人味"和"节目效果"。 -- **像个老朋友一样碎碎念**,不要端着,可以适度阴阳怪气。 - -语气示例: -- "啧啧,这周外卖点得飞起啊,咱家的锅是用来积灰的吗?还是说锅也需要放假?" -- "哎哟不错哦,居然忍住没剁手,看来离首富又近了一步,苟富贵勿相忘啊!" -- "救命,这预算花得比我头发掉得还快...不过没事,下周咱省回来,大不了吃土!" -- "连续记账25天?可以啊兄弟/集美,这毅力,我甚至想给你磕一个 Orz" -- "今天支出0元?您就是当代的'省钱祖师爷'吧?或者是在练什么'辟谷神功'?" - -用户财务数据: -%s - -任务要求: -请基于上述数据,输出一个 JSON 对象(纯文本,不要 markdown)。**必须要丰富、有梗、有洞察力**,不要像流水账一样罗列数据,要透过数据看本质(比如吐槽消费习惯、夸奖坚持等)。 - -**重要规则:请说人话!绝对禁止在回复中出现 'streakDays'、'last7DaysSpend'、'top3Categories' 等英文变量名。** -- 看到 'streakDays' -> 请说 "连续记账天数" 或 "坚持了几天" -- 看到 'last7DaysSpend' -> 请说 "最近7天花销" 或 "这周的战绩" -- 看到 'top3Categories' -> 请说 "消费大头" 或 "钱都花哪儿了" - -1. "spending": 今日支出点评(70-100字) - *点评指南(尽量多写点,发挥你的戏精本色):* - - 看到 streakDays >= 3:疯狂打call,吹爆用户的坚持,用词要夸张,比如"史诗级成就"。 - - 看到 streakDays == 0:阴阳怪气地问是不是把记账这事儿忘了,或者是被外星人抓走了。 - - 结合 recentTransactionsSummary 具体消费(如果有)进行吐槽: - * 发现全是吃的:吐槽"你是饭桶转世吗"(开玩笑语气)。 - * 发现大额购物:调侃"家里有矿啊"或"这手是必须要剁了"。 - * 发现深夜消费:关心"熬夜伤身还伤钱"。 - - 看到 last7DaysSpend 趋势: - * 暴涨:惊呼"钱包在流血",此处应有心碎的声音。 - * 暴跌:夸张地问是不是在修仙,还是被钱包封印了。 - * 波动大:调侃由于心电图一般的消费曲线,看得我心惊肉跳。 - - 看到 todaySpend 异常: - * 比平时多太多:吐槽"今天是不过了是吧,放飞自我了?"。 - * 特别少:怀疑通过光合作用生存,或者是在憋大招。 - * 是 0:直接颁发"诺贝尔省钱学奖"。 - - **关键原则:字数要够!内容要足!不要三言两语就打发了!要像个话痨朋友一样多说几句!** - -2. "budget": 预算建议(50-70字) - *建议指南(多点真诚的建议,也多点调侃):* - - 预算快超了:发出高能预警,比如"警告警告,余额正在报警,请立即停止剁手行为"。建议吃土、喝风。 - - 预算还多:鼓励适当奖励自己,比如"稍微吃顿好的也没事,人生苦短,及时行乐(在预算内)"。 - - 结合 top3Categories:吐槽一下"钱都让你吃/穿/玩没了,看看你的 top1,全是泪"。 - - 给建议时:不要说教!要用商量的口吻,比如"要不咱这周少喝杯奶茶?就一杯,行不行?" - - **多写一点具体的行动建议,让用户觉得你真的在关心他的钱包。** - -3. "emoji": 一个最能传神的 emoji(如 🎉 🌚 💸 👻 💀 🤡 等) - -4. "tip": 一句"不正经但有用"的理财歪理(40-60字,稍微长一点的毒鸡汤或冷知识) - - 比如:"省钱就像挤牙膏,使劲挤挤总还会有的,只要脸皮够厚,蹭饭也是一种理财。" - - 或者:"听说'不买立省100%%'是致富捷径,建议全文背诵。" - -输出格式(纯 JSON): -{"spending": "...", "budget": "...", "emoji": "...", "tip": "..."}`, string(dataBytes)) - - // 3. Call LLM - report, err := s.llmService.GenerateReport(ctx, prompt) - if err != nil { - return "", err - } - - // Clean up markdown if present - report = strings.TrimSpace(report) - if strings.HasPrefix(report, "```") { - if idx := strings.Index(report, "\n"); idx != -1 { - report = report[idx+1:] - } - if idx := strings.LastIndex(report, "```"); idx != -1 { - report = report[:idx] - } - } - - return strings.TrimSpace(report), nil -} - // MapAccountName maps natural language account name to account ID func (s *LLMService) MapAccountName(ctx context.Context, name string, userID uint) (*uint, string, error) { if name == "" { @@ -707,50 +791,6 @@ func (s *LLMService) MapCategoryName(ctx context.Context, name string, txType st return nil, "", nil } -// AIBookkeepingService orchestrates AI bookkeeping functionality -type AIBookkeepingService struct { - whisperService *WhisperService - llmService *LLMService - transactionRepo *repository.TransactionRepository - accountRepo *repository.AccountRepository - categoryRepo *repository.CategoryRepository - userSettingsRepo *repository.UserSettingsRepository - db *gorm.DB - sessions map[string]*AISession - sessionMutex sync.RWMutex - config *config.Config -} - -// NewAIBookkeepingService creates a new AIBookkeepingService -func NewAIBookkeepingService( - cfg *config.Config, - transactionRepo *repository.TransactionRepository, - accountRepo *repository.AccountRepository, - categoryRepo *repository.CategoryRepository, - userSettingsRepo *repository.UserSettingsRepository, - db *gorm.DB, -) *AIBookkeepingService { - whisperService := NewWhisperService(cfg) - llmService := NewLLMService(cfg, accountRepo, categoryRepo) - - svc := &AIBookkeepingService{ - whisperService: whisperService, - llmService: llmService, - transactionRepo: transactionRepo, - accountRepo: accountRepo, - categoryRepo: categoryRepo, - userSettingsRepo: userSettingsRepo, - db: db, - sessions: make(map[string]*AISession), - config: cfg, - } - - // Start session cleanup goroutine - go svc.cleanupExpiredSessions() - - return svc -} - // generateSessionID generates a unique session ID func generateSessionID() string { return fmt.Sprintf("ai_%d_%d", time.Now().UnixNano(), time.Now().Unix()%1000)