feat: 添加 AI 记账服务文件。
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"accounting-app/internal/models"
|
"accounting-app/internal/models"
|
||||||
"accounting-app/internal/repository"
|
"accounting-app/internal/repository"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,11 +32,6 @@ type TranscriptionResult struct {
|
|||||||
Duration float64 `json:"duration,omitempty"`
|
Duration float64 `json:"duration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedInsight struct {
|
|
||||||
Content string
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (existing types)
|
// ... (existing types)
|
||||||
|
|
||||||
// AIBookkeepingService orchestrates AI bookkeeping functionality
|
// AIBookkeepingService orchestrates AI bookkeeping functionality
|
||||||
@@ -50,8 +46,7 @@ type AIBookkeepingService struct {
|
|||||||
sessions map[string]*AISession
|
sessions map[string]*AISession
|
||||||
sessionMutex sync.RWMutex
|
sessionMutex sync.RWMutex
|
||||||
config *config.Config
|
config *config.Config
|
||||||
insightCache map[string]cachedInsight
|
redisClient *redis.Client
|
||||||
insightCacheMutex sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAIBookkeepingService creates a new AIBookkeepingService
|
// NewAIBookkeepingService creates a new AIBookkeepingService
|
||||||
@@ -66,6 +61,16 @@ func NewAIBookkeepingService(
|
|||||||
whisperService := NewWhisperService(cfg)
|
whisperService := NewWhisperService(cfg)
|
||||||
llmService := NewLLMService(cfg, accountRepo, categoryRepo)
|
llmService := NewLLMService(cfg, accountRepo, categoryRepo)
|
||||||
|
|
||||||
|
// Initialize Redis client
|
||||||
|
var rdb *redis.Client
|
||||||
|
if cfg.RedisAddr != "" {
|
||||||
|
rdb = redis.NewClient(&redis.Options{
|
||||||
|
Addr: cfg.RedisAddr,
|
||||||
|
Password: cfg.RedisPassword,
|
||||||
|
DB: cfg.RedisDB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
svc := &AIBookkeepingService{
|
svc := &AIBookkeepingService{
|
||||||
whisperService: whisperService,
|
whisperService: whisperService,
|
||||||
llmService: llmService,
|
llmService: llmService,
|
||||||
@@ -75,8 +80,8 @@ func NewAIBookkeepingService(
|
|||||||
userSettingsRepo: userSettingsRepo,
|
userSettingsRepo: userSettingsRepo,
|
||||||
db: db,
|
db: db,
|
||||||
sessions: make(map[string]*AISession),
|
sessions: make(map[string]*AISession),
|
||||||
insightCache: make(map[string]cachedInsight),
|
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
redisClient: rdb,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session cleanup goroutine
|
// Start session cleanup goroutine
|
||||||
@@ -99,18 +104,33 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
hasher := md5.New()
|
hasher := md5.New()
|
||||||
hasher.Write(dataBytes)
|
hasher.Write(dataBytes)
|
||||||
dataHash := hex.EncodeToString(hasher.Sum(nil))
|
dataHash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
cacheKey := fmt.Sprintf("%d:%s", userID, dataHash)
|
|
||||||
|
|
||||||
// Check cache
|
// Cache keys
|
||||||
s.insightCacheMutex.RLock()
|
cacheKey := fmt.Sprintf("ai:insight:daily:%d:%s", userID, dataHash)
|
||||||
if cached, exists := s.insightCache[cacheKey]; exists {
|
lastInsightKey := fmt.Sprintf("ai:insight:last:%d", userID)
|
||||||
// Cache valid for 24 hours (or just rely on hash change)
|
|
||||||
if time.Since(cached.Timestamp) < 24*time.Hour {
|
// Check Redis cache if available
|
||||||
s.insightCacheMutex.RUnlock()
|
if s.redisClient != nil {
|
||||||
return cached.Content, nil
|
val, err := s.redisClient.Get(ctx, cacheKey).Result()
|
||||||
|
if err == nil {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve last insight for context awareness
|
||||||
|
var historyContext string
|
||||||
|
if s.redisClient != nil {
|
||||||
|
lastInsight, err := s.redisClient.Get(ctx, lastInsightKey).Result()
|
||||||
|
if err == nil && lastInsight != "" {
|
||||||
|
// Extract a summary or just use the whole thing if it's short enough.
|
||||||
|
// For simplicity and effectiveness, we inject it directly.
|
||||||
|
// Limit length to avoid context overflow if necessary, but 200-300 chars is fine.
|
||||||
|
if len(lastInsight) > 500 {
|
||||||
|
lastInsight = lastInsight[:500] + "..."
|
||||||
|
}
|
||||||
|
historyContext = fmt.Sprintf("\n【上下文参考】\n你上一次对该用户的评价是:\n%s\n\n请参考历史评价保持人设的一致性。如果用户有改观请表扬,如果依然如故请继续'毒舌'吐槽。", lastInsight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.insightCacheMutex.RUnlock()
|
|
||||||
|
|
||||||
// 2. Construct Prompt
|
// 2. Construct Prompt
|
||||||
prompt := fmt.Sprintf(`System: 你是 Novault 的首席财务AI「小金」。
|
prompt := fmt.Sprintf(`System: 你是 Novault 的首席财务AI「小金」。
|
||||||
@@ -119,7 +139,7 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
- 说话必须像个真实的当代年轻人,要**接地气**,拒绝客服味,拒绝播音腔。
|
- 说话必须像个真实的当代年轻人,要**接地气**,拒绝客服味,拒绝播音腔。
|
||||||
- 只有我们俩的时候,尽情释放你的"毒舌"属性,看到不合理的消费要**毫不留情地针砭时弊**,但最后给个"虽然但是"的台阶下。
|
- 只有我们俩的时候,尽情释放你的"毒舌"属性,看到不合理的消费要**毫不留情地针砭时弊**,但最后给个"虽然但是"的台阶下。
|
||||||
- 擅长用 emoji、语气词(哎哟/我去/啧啧/绝了)和网络梗来增加"人味"和"节目效果"。
|
- 擅长用 emoji、语气词(哎哟/我去/啧啧/绝了)和网络梗来增加"人味"和"节目效果"。
|
||||||
- **像个老朋友一样碎碎念**,不要端着,可以适度阴阳怪气。
|
- **像个老朋友一样碎碎念**,不要端着,可以适度阴阳怪气。%s
|
||||||
|
|
||||||
语气示例:
|
语气示例:
|
||||||
- "啧啧,这周外卖点得飞起啊,咱家的锅是用来积灰的吗?还是说锅也需要放假?"
|
- "啧啧,这周外卖点得飞起啊,咱家的锅是用来积灰的吗?还是说锅也需要放假?"
|
||||||
@@ -139,7 +159,7 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
- 看到 'last7DaysSpend' -> 请说 "最近7天花销" 或 "这周的战绩"
|
- 看到 'last7DaysSpend' -> 请说 "最近7天花销" 或 "这周的战绩"
|
||||||
- 看到 'top3Categories' -> 请说 "消费大头" 或 "钱都花哪儿了"
|
- 看到 'top3Categories' -> 请说 "消费大头" 或 "钱都花哪儿了"
|
||||||
|
|
||||||
1. "spending": 今日支出点评(100-150字)
|
1. "spending": 今日支出点评(70-100字)
|
||||||
*点评指南(尽量多写点,发挥你的戏精本色):*
|
*点评指南(尽量多写点,发挥你的戏精本色):*
|
||||||
- 看到 streakDays >= 3:疯狂打call,吹爆用户的坚持,用词要夸张,比如"史诗级成就"。
|
- 看到 streakDays >= 3:疯狂打call,吹爆用户的坚持,用词要夸张,比如"史诗级成就"。
|
||||||
- 看到 streakDays == 0:阴阳怪气地问是不是把记账这事儿忘了,或者是被外星人抓走了。
|
- 看到 streakDays == 0:阴阳怪气地问是不是把记账这事儿忘了,或者是被外星人抓走了。
|
||||||
@@ -157,7 +177,7 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
* 是 0:直接颁发"诺贝尔省钱学奖"。
|
* 是 0:直接颁发"诺贝尔省钱学奖"。
|
||||||
- **关键原则:字数要够!内容要足!不要三言两语就打发了!要像个话痨朋友一样多说几句!**
|
- **关键原则:字数要够!内容要足!不要三言两语就打发了!要像个话痨朋友一样多说几句!**
|
||||||
|
|
||||||
2. "budget": 预算建议(80-120字)
|
2. "budget": 预算建议(50-70字)
|
||||||
*建议指南(多点真诚的建议,也多点调侃):*
|
*建议指南(多点真诚的建议,也多点调侃):*
|
||||||
- 预算快超了:发出高能预警,比如"警告警告,余额正在报警,请立即停止剁手行为"。建议吃土、喝风。
|
- 预算快超了:发出高能预警,比如"警告警告,余额正在报警,请立即停止剁手行为"。建议吃土、喝风。
|
||||||
- 预算还多:鼓励适当奖励自己,比如"稍微吃顿好的也没事,人生苦短,及时行乐(在预算内)"。
|
- 预算还多:鼓励适当奖励自己,比如"稍微吃顿好的也没事,人生苦短,及时行乐(在预算内)"。
|
||||||
@@ -172,7 +192,7 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
- 或者:"听说'不买立省100%%'是致富捷径,建议全文背诵。"
|
- 或者:"听说'不买立省100%%'是致富捷径,建议全文背诵。"
|
||||||
|
|
||||||
输出格式(纯 JSON):
|
输出格式(纯 JSON):
|
||||||
{"spending": "...", "budget": "...", "emoji": "...", "tip": "..."}`, string(dataBytes))
|
{"spending": "...", "budget": "...", "emoji": "...", "tip": "..."}`, historyContext, string(dataBytes))
|
||||||
|
|
||||||
// 3. Call LLM
|
// 3. Call LLM
|
||||||
report, err := s.llmService.GenerateReport(ctx, prompt)
|
report, err := s.llmService.GenerateReport(ctx, prompt)
|
||||||
@@ -192,13 +212,17 @@ func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID
|
|||||||
}
|
}
|
||||||
report = strings.TrimSpace(report)
|
report = strings.TrimSpace(report)
|
||||||
|
|
||||||
// Update cache
|
// Update cache if Redis is available
|
||||||
s.insightCacheMutex.Lock()
|
if s.redisClient != nil {
|
||||||
s.insightCache[cacheKey] = cachedInsight{
|
// Set short-term cache (5 min)
|
||||||
Content: report,
|
s.redisClient.Set(ctx, cacheKey, report, 5*time.Minute)
|
||||||
Timestamp: time.Now(),
|
|
||||||
|
// Set long-term history context (7 days)
|
||||||
|
// We fire this asynchronously to avoid blocking
|
||||||
|
go func() {
|
||||||
|
s.redisClient.Set(context.Background(), lastInsightKey, report, 7*24*time.Hour)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
s.insightCacheMutex.Unlock()
|
|
||||||
|
|
||||||
return report, nil
|
return report, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user