Files
smartmate/backend/service/agentsvc/agent_schedule_preview.go
Losita e5b27df80d Version: 0.7.2.dev.260322
feat(schedule-plan):  重构智能排程链路并修复粗排双节对齐问题

-  新增“对话级排程状态持久化”能力:引入 `agent_schedule_states` 模型/DAO,并接入启动迁移
-  智能排程图升级:补齐小幅微调(quick refine)分支,完善预算/并发/状态字段流转
-  预览链路增强:完善排程预览服务读写与桥接逻辑,新增本地预览页 `infra/schedule_preview_viewer.html`
- ♻️ 缓存治理统一:将相关缓存处理收口到 DAO + `cache_deleter` 联动清理,移除旧散落逻辑
- 🐛 修复粗排核心 bug:禁止单节降级,强制双节并按 `1-2/3-4/...` 对齐;修复结束日扫描边界问题
-  新增粗排回归测试:覆盖孤立单节、偶数起点双节、Filler 对齐等关键场景
2026-03-22 13:50:10 +08:00

278 lines
9.9 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 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. 基础前置校验state 为空时直接返回,避免写入半成品快照。
if s == 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. 调用目的:先写 Redis 预览,保证前端查询接口能快速读取结构化结果。
// 3.1 Redis 是“快路径”;失败只记录日志,不中断主链路;
// 3.2 失败兜底由后续 MySQL 快照承接。
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 调用目的:同步写 MySQL 状态快照,保证 Redis 失效后仍可连续微调。
// 4.1 这里采用“同步写库”而不是 outbox因为下一轮微调要强实时读取
// 4.2 快照写入失败只打日志,不阻断本轮用户回复,避免体验抖动;
// 4.3 revision 自增由 DAO 的 upsert 冲突更新负责。
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); 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 {
return nil, errors.New("agent service is not initialized")
}
// 2. 查询缓存并校验归属:
// 2.1 缓存未命中:统一返回“预览不存在/已过期”;
// 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息;
// 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
return nil, err
}
if preview != nil {
if preview.UserID > 0 && preview.UserID != userID {
return nil, respond.SchedulePlanPreviewNotFound
}
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
}
}
// 3. Redis 未命中时回落 MySQL 快照:
// 3.1 读取成功后直接返回,避免用户看到“预览不存在”的假阴性;
// 3.2 若本次命中 DB 且缓存可用,则顺手回填 Redis提升后续命中率
// 3.3 DB 也未命中时再返回 not found。
if s.repo != nil {
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
return nil, err
}
if snapshot != nil {
response := snapshotToSchedulePlanPreviewResponse(snapshot)
if s.cacheDAO != nil {
cachePreview := snapshotToSchedulePlanPreviewCache(snapshot)
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil {
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return response, nil
}
}
return nil, respond.SchedulePlanPreviewNotFound
}
// 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
}
// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。
//
// 职责边界:
// 1. 负责字段映射与深拷贝,避免跨层共享可变切片;
// 2. 负责补齐 state_version 默认值;
// 3. 不负责数据库写入(写入由 DAO 承担)。
func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *scheduleplan.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
}
return &model.SchedulePlanStateSnapshot{
UserID: userID,
ConversationID: conversationID,
StateVersion: model.SchedulePlanStateVersionV1,
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
Constraints: append([]string(nil), st.Constraints...),
HybridEntries: cloneHybridEntries(st.HybridEntries),
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
UserIntent: strings.TrimSpace(st.UserIntent),
Strategy: strings.TrimSpace(st.Strategy),
AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
RestartRequested: st.RestartRequested,
FinalSummary: strings.TrimSpace(st.FinalSummary),
Completed: st.Completed,
TraceID: strings.TrimSpace(st.TraceID),
}
}
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
if snapshot == nil {
return nil
}
summary := strings.TrimSpace(snapshot.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
generatedAt := snapshot.UpdatedAt
if generatedAt.IsZero() {
generatedAt = time.Now()
}
return &model.SchedulePlanPreviewCache{
UserID: snapshot.UserID,
ConversationID: snapshot.ConversationID,
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: summary,
CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans),
TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...),
HybridEntries: cloneHybridEntries(snapshot.HybridEntries),
AllocatedItems: cloneTaskClassItems(snapshot.AllocatedItems),
GeneratedAt: generatedAt,
}
}
// snapshotToSchedulePlanPreviewResponse 把 MySQL 快照转换为查询接口响应。
func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse {
if snapshot == nil {
return nil
}
plans := cloneWeekSchedules(snapshot.CandidatePlans)
if plans == nil {
plans = make([]model.UserWeekSchedule, 0)
}
summary := strings.TrimSpace(snapshot.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
generatedAt := snapshot.UpdatedAt
if generatedAt.IsZero() {
generatedAt = time.Now()
}
return &model.GetSchedulePlanPreviewResponse{
ConversationID: snapshot.ConversationID,
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: summary,
CandidatePlans: plans,
GeneratedAt: generatedAt,
}
}