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(总分流图 + 智能排程流转图)并补充决策文档 - ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
163 lines
5.5 KiB
Go
163 lines
5.5 KiB
Go
package agentsvc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
"github.com/LoveLosita/smartflow/backend/respond"
|
||
)
|
||
|
||
// saveSchedulePlanPreview 把排程结果以结构化 JSON 快照写入 Redis。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责把 finalState 中的 summary + candidate_plans 收敛为缓存 DTO;
|
||
// 2. 负责以“失败不阻断聊天主链路”的策略执行写入;
|
||
// 3. 不负责 SSE 返回协议,不负责数据库落库。
|
||
func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *scheduleplan.SchedulePlanState) {
|
||
// 1. 基础前置校验:任何关键依赖缺失都直接返回,避免产生无意义错误日志。
|
||
if s == nil || s.agentCache == nil || finalState == nil {
|
||
return
|
||
}
|
||
normalizedChatID := strings.TrimSpace(chatID)
|
||
if normalizedChatID == "" {
|
||
return
|
||
}
|
||
|
||
// 2. 组装缓存快照:
|
||
// 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
|
||
// 2.2 candidate_plans 做切片拷贝,避免后续引用共享导致意外覆盖;
|
||
// 2.3 generated_at 用于前端判断“当前预览的新鲜度”。
|
||
summary := strings.TrimSpace(finalState.FinalSummary)
|
||
if summary == "" {
|
||
summary = "排程流程已完成,但未生成结果摘要。"
|
||
}
|
||
preview := &model.SchedulePlanPreviewCache{
|
||
UserID: userID,
|
||
ConversationID: normalizedChatID,
|
||
TraceID: strings.TrimSpace(finalState.TraceID),
|
||
Summary: summary,
|
||
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
|
||
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
|
||
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
|
||
AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
|
||
GeneratedAt: time.Now(),
|
||
}
|
||
|
||
// 3. 尝试写入缓存:
|
||
// 3.1 写入失败仅打日志,不上抛错误,保证聊天接口协议与可用性不受影响;
|
||
// 3.2 兜底策略是“用户仍可收到文本摘要”,只是暂时无法通过新接口拉取结构化预览。
|
||
if err := s.agentCache.SetSchedulePlanPreview(ctx, userID, normalizedChatID, preview); err != nil {
|
||
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
||
}
|
||
}
|
||
|
||
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责参数归一化、缓存读取与会话归属校验;
|
||
// 2. 负责把缓存 DTO 转成 API 响应 DTO;
|
||
// 3. 不负责触发排程,不负责补算缓存。
|
||
func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) {
|
||
// 1. 参数校验:conversation_id 为空直接返回参数错误,避免无效 Redis 请求。
|
||
normalizedChatID := strings.TrimSpace(chatID)
|
||
if normalizedChatID == "" {
|
||
return nil, respond.MissingParam
|
||
}
|
||
if s == nil || s.agentCache == nil {
|
||
return nil, errors.New("agent cache is not initialized")
|
||
}
|
||
|
||
// 2. 查询缓存并校验归属:
|
||
// 2.1 缓存未命中:统一返回“预览不存在/已过期”;
|
||
// 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息;
|
||
// 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。
|
||
preview, err := s.agentCache.GetSchedulePlanPreview(ctx, userID, normalizedChatID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if preview == nil {
|
||
return nil, respond.SchedulePlanPreviewNotFound
|
||
}
|
||
if preview.UserID > 0 && preview.UserID != userID {
|
||
return nil, respond.SchedulePlanPreviewNotFound
|
||
}
|
||
|
||
// 3. 映射响应结构,保证输出字段稳定。
|
||
plans := cloneWeekSchedules(preview.CandidatePlans)
|
||
if plans == nil {
|
||
plans = make([]model.UserWeekSchedule, 0)
|
||
}
|
||
return &model.GetSchedulePlanPreviewResponse{
|
||
ConversationID: normalizedChatID,
|
||
TraceID: strings.TrimSpace(preview.TraceID),
|
||
Summary: strings.TrimSpace(preview.Summary),
|
||
CandidatePlans: plans,
|
||
GeneratedAt: preview.GeneratedAt,
|
||
}, nil
|
||
}
|
||
|
||
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
|
||
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||
for _, week := range src {
|
||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||
copy(eventsCopy, week.Events)
|
||
dst = append(dst, model.UserWeekSchedule{
|
||
Week: week.Week,
|
||
Events: eventsCopy,
|
||
})
|
||
}
|
||
return dst
|
||
}
|
||
|
||
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
|
||
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]model.HybridScheduleEntry, len(src))
|
||
copy(dst, src)
|
||
return dst
|
||
}
|
||
|
||
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
|
||
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]model.TaskClassItem, 0, len(src))
|
||
for _, item := range src {
|
||
copied := item
|
||
if item.CategoryID != nil {
|
||
v := *item.CategoryID
|
||
copied.CategoryID = &v
|
||
}
|
||
if item.Order != nil {
|
||
v := *item.Order
|
||
copied.Order = &v
|
||
}
|
||
if item.Content != nil {
|
||
v := *item.Content
|
||
copied.Content = &v
|
||
}
|
||
if item.Status != nil {
|
||
v := *item.Status
|
||
copied.Status = &v
|
||
}
|
||
if item.EmbeddedTime != nil {
|
||
t := *item.EmbeddedTime
|
||
copied.EmbeddedTime = &t
|
||
}
|
||
dst = append(dst, copied)
|
||
}
|
||
return dst
|
||
}
|