Files
smartmate/backend/dao/agent-cache.go
Losita f3f9902e93 Version: 0.7.1.dev.260321
feat(agent):  重构智能排程分流与双通道交付,补齐周级预算并接入连续微调复用

- 🔀 通用路由升级为 action 分流(chat/quick_note_create/task_query/schedule_plan),路由失败直接返回内部错误,不再回落聊天
- 🧭 智能排程链路重构:统一图编排与节点职责,完善日级/周级调优协作与提示词约束
- 📊 周级预算改为“有效周保底 + 负载加权分配”,避免有效周零预算并提升资源利用率
- ⚙️ 日级并发优化细化:按天拆分 DayGroup 并发执行,低收益天(suggested<=2)跳过,单天失败仅回退该天结果并继续全局
- 🧵 周级并发优化细化:按周并发 worker 执行,单周“单步动作”循环(每轮仅 1 个 Move/Swap 或 done),失败周保留原方案不影响其它周
- 🛰️ 新增排程预览双通道:聊天主链路输出终审文本,结构化 candidate_plans 通过 /api/v1/agent/schedule-preview 拉取
- 🗃️ 增补 Redis 预览缓存读写与清理逻辑,新增对应 API、路由、模型与错误码支持
- ♻️ 接入连续对话微调复用:命中同会话历史预览时复用上轮 HybridEntries,避免每轮重跑粗排
- 🛡️ 增加复用保护:仅当本轮与上轮 task_class_ids 集合一致才复用;不一致回退全量粗排
- 🧰 扩展预览缓存字段(task_class_ids/hybrid_entries/allocated_items),支撑微调承接链路
- 🗺️ 更新 README 5.4 Mermaid(总分流图 + 智能排程流转图)并补充决策文档

- ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
2026-03-21 22:08:35 +08:00

248 lines
7.3 KiB
Go
Raw 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.
package dao
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino/schema"
"github.com/go-redis/redis/v8"
)
type AgentCache struct {
client *redis.Client
// 默认窗口大小(会被会话级动态窗口覆盖)
windowSize int
// 缓存过期时间
expiration time.Duration
}
const (
minHistoryWindowSize = 16
maxHistoryWindowSize = 4096
)
func NewAgentCache(client *redis.Client) *AgentCache {
return &AgentCache{
client: client,
windowSize: 128,
expiration: 1 * time.Hour,
}
}
func (m *AgentCache) historyKey(sessionID string) string {
return fmt.Sprintf("smartflow:history:%s", sessionID)
}
func (m *AgentCache) historyWindowKey(sessionID string) string {
return fmt.Sprintf("smartflow:history_window:%s", sessionID)
}
func (m *AgentCache) schedulePreviewKey(userID int, sessionID string) string {
return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, sessionID)
}
func (m *AgentCache) normalizeWindowSize(size int) int {
if size < minHistoryWindowSize {
return minHistoryWindowSize
}
if size > maxHistoryWindowSize {
return maxHistoryWindowSize
}
return size
}
func (m *AgentCache) getSessionWindowSize(ctx context.Context, sessionID string) (int, error) {
windowKey := m.historyWindowKey(sessionID)
val, err := m.client.Get(ctx, windowKey).Result()
if err == redis.Nil {
return m.windowSize, nil
}
if err != nil {
return 0, err
}
size, convErr := strconv.Atoi(val)
if convErr != nil {
return m.windowSize, nil
}
return m.normalizeWindowSize(size), nil
}
// SetSessionWindowSize 设置会话级窗口上限。
func (m *AgentCache) SetSessionWindowSize(ctx context.Context, sessionID string, size int) error {
normalized := m.normalizeWindowSize(size)
windowKey := m.historyWindowKey(sessionID)
return m.client.Set(ctx, windowKey, normalized, m.expiration).Err()
}
// EnforceHistoryWindow 按当前会话窗口强制修剪历史队列。
func (m *AgentCache) EnforceHistoryWindow(ctx context.Context, sessionID string) error {
size, err := m.getSessionWindowSize(ctx, sessionID)
if err != nil {
return err
}
key := m.historyKey(sessionID)
pipe := m.client.Pipeline()
pipe.LTrim(ctx, key, 0, int64(size-1))
pipe.Expire(ctx, key, m.expiration)
_, err = pipe.Exec(ctx)
return err
}
func (m *AgentCache) PushMessage(ctx context.Context, sessionID string, msg *schema.Message) error {
key := m.historyKey(sessionID)
size, err := m.getSessionWindowSize(ctx, sessionID)
if err != nil {
return err
}
// 1. 序列化 Eino 消息。
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal message failed: %w", err)
}
// 2. 使用 Pipeline 保证“写入+裁剪+续期”原子执行。
pipe := m.client.Pipeline()
pipe.LPush(ctx, key, data)
pipe.LTrim(ctx, key, 0, int64(size-1))
pipe.Expire(ctx, key, m.expiration)
_, err = pipe.Exec(ctx)
return err
}
func (m *AgentCache) GetHistory(ctx context.Context, sessionID string) ([]*schema.Message, error) {
key := m.historyKey(sessionID)
vals, err := m.client.LRange(ctx, key, 0, -1).Result()
if err != nil {
return nil, err
}
if len(vals) == 0 {
return nil, nil
}
messages := make([]*schema.Message, len(vals))
for i, val := range vals {
var msg schema.Message
if err := json.Unmarshal([]byte(val), &msg); err != nil {
return nil, err
}
// LRANGE 返回 [最新...最旧],这里反转成 [最旧...最新]
messages[len(vals)-1-i] = &msg
}
return messages, nil
}
// BackfillHistory 在缓存失效时,把历史消息一次性回填到 Redis。
func (m *AgentCache) BackfillHistory(ctx context.Context, sessionID string, messages []*schema.Message) error {
key := m.historyKey(sessionID)
size, err := m.getSessionWindowSize(ctx, sessionID)
if err != nil {
return err
}
if len(messages) == 0 {
return m.client.Del(ctx, key).Err()
}
values := make([]interface{}, len(messages))
for i, msg := range messages {
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal failed at index %d: %w", i, err)
}
values[i] = data
}
pipe := m.client.Pipeline()
pipe.Del(ctx, key)
pipe.LPush(ctx, key, values...)
pipe.LTrim(ctx, key, 0, int64(size-1))
pipe.Expire(ctx, key, m.expiration)
_, err = pipe.Exec(ctx)
return err
}
func (m *AgentCache) ClearHistory(ctx context.Context, sessionID string) error {
historyKey := m.historyKey(sessionID)
windowKey := m.historyWindowKey(sessionID)
return m.client.Del(ctx, historyKey, windowKey).Err()
}
func (m *AgentCache) GetConversationStatus(ctx context.Context, sessionID string) (bool, error) {
key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID)
n, err := m.client.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n == 1, nil
}
func (m *AgentCache) SetConversationStatus(ctx context.Context, sessionID string) error {
key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID)
// 仅用于“存在性”标记:只有不存在时才写入,避免重复写。
return m.client.SetNX(ctx, key, 1, m.expiration).Err()
}
func (m *AgentCache) DeleteConversationStatus(ctx context.Context, sessionID string) error {
key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID)
return m.client.Del(ctx, key).Err()
}
// SetSchedulePlanPreview 写入“排程预览”缓存。
//
// 步骤化说明:
// 1. 先把结构化预览序列化成 JSON避免缓存层结构漂移。
// 2. 再按 user_id + conversation_id 写入,确保用户间数据隔离。
// 3. 最后带 TTL 写入,保证预览是短期临时态而非长期状态。
//
// 失败处理:
// 1. preview 为空时直接返回错误,避免写入无意义空值。
// 2. 序列化失败或 Redis 写入失败都返回 error由上层决定是否降级。
func (m *AgentCache) SetSchedulePlanPreview(ctx context.Context, userID int, sessionID string, preview *model.SchedulePlanPreviewCache) error {
if preview == nil {
return fmt.Errorf("schedule preview is nil")
}
data, err := json.Marshal(preview)
if err != nil {
return fmt.Errorf("marshal schedule preview failed: %w", err)
}
return m.client.Set(ctx, m.schedulePreviewKey(userID, sessionID), data, m.expiration).Err()
}
// GetSchedulePlanPreview 读取“排程预览”缓存。
//
// 语义约定:
// 1. 未命中返回 (nil, nil),上层可区分“未生成”与“已过期”。
// 2. 反序列化失败返回 error避免把脏缓存当成正常结果。
// 3. 不做 DB 回源,预览缓存失效后由业务侧重新生成。
func (m *AgentCache) GetSchedulePlanPreview(ctx context.Context, userID int, sessionID string) (*model.SchedulePlanPreviewCache, error) {
raw, err := m.client.Get(ctx, m.schedulePreviewKey(userID, sessionID)).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
var preview model.SchedulePlanPreviewCache
if err = json.Unmarshal([]byte(raw), &preview); err != nil {
return nil, fmt.Errorf("unmarshal schedule preview failed: %w", err)
}
return &preview, nil
}
// DeleteSchedulePlanPreview 删除“排程预览”缓存。
//
// 说明:
// 1. 删除是幂等操作key 不存在也视为成功。
// 2. 用于新一轮排程前清理旧快照,避免前端读到过期结果。
func (m *AgentCache) DeleteSchedulePlanPreview(ctx context.Context, userID int, sessionID string) error {
return m.client.Del(ctx, m.schedulePreviewKey(userID, sessionID)).Err()
}