feat: 添加 AI 记账服务
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user