Version: 0.7.0.dev.260319

 feat(agent): 新增智能排程 Agent 全链路 + ReAct 精排引擎

  🏗️ 智能排程 Graph 编排(阶段 1 基础链路)
  - 新增 scheduleplan 包:state / tool / prompt / nodes / runner / graph 六件套
  - 实现 plan → preview → materialize → apply → reflect → finalize 完整图编排
  - 通过函数注入解耦 agent 层与 service 层,避免循环依赖
  - 路由层新增 schedule_plan 动作,复用现有 SSE + 持久化链路

  🧠 ReAct 精排引擎(阶段 1.5 语义化微调)
  - 粗排后构建"混合日程"(既有课程 + 建议任务),统一为 HybridScheduleEntry
  - LLM 开启深度思考,通过 Swap / Move / TimeAvailable / GetAvailableSlots 四个 Tool 在内存中优化任务时间
  - reasoning_content 实时流式推送前端,用户可见 AI 思考过程
  - 精排结果仅预览不落库,向后兼容(未注入依赖时走原有 materialize 路径)

  📝 文档
  - 新增 ReAct 精排引擎决策记录

  ⚠️ 已知问题:深度思考模式耗时较长,超时策略待优化
This commit is contained in:
Losita
2026-03-19 23:16:35 +08:00
parent cd95aeeaaa
commit d3cec2a5b9
24 changed files with 2737 additions and 24 deletions

View File

@@ -1,9 +1,12 @@
package service
import (
"context"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/inits"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/service/agentsvc"
)
@@ -14,9 +17,43 @@ import (
type AgentService = agentsvc.AgentService
// NewAgentService 是迁移期兼容构造函数。
//
// 说明:
// 1) 外部调用签名保持不变;
// 1) 外部调用签名不变,新增排程依赖通过可选方式注入(见 NewAgentServiceWithSchedule
// 2) 真实构造逻辑已下沉到 service/agentsvc 包。
func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService {
return agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher)
}
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
//
// 设计目的:
// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService / TaskClassService
// 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天;
// 3) 保持 NewAgentService 签名不变,向下兼容。
func NewAgentServiceWithSchedule(
aiHub *inits.AIHub,
repo *dao.AgentDAO,
taskRepo *dao.TaskDAO,
agentRedis *dao.AgentCache,
eventPublisher outboxinfra.EventPublisher,
scheduleSvc *ScheduleService,
taskClassSvc *TaskClassService,
) *AgentService {
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher)
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
if scheduleSvc != nil {
svc.SmartPlanningRawFunc = scheduleSvc.SmartPlanningRaw
svc.HybridScheduleWithPlanFunc = scheduleSvc.HybridScheduleWithPlan
}
if taskClassSvc != nil {
svc.BatchApplyPlansFunc = taskClassSvc.BatchApplyPlans
// GetTaskClassByID 复用 TaskClassService 内部的 DAO 调用。
svc.GetTaskClassByIDFunc = func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) {
return taskClassSvc.GetCompleteTaskClassByID(ctx, taskClassID, userID)
}
}
return svc
}

View File

@@ -26,6 +26,21 @@ type AgentService struct {
taskRepo *dao.TaskDAO
agentCache *dao.AgentCache
eventPublisher outboxinfra.EventPublisher
// ── 排程计划依赖(函数注入,避免 service 包循环依赖)──
// SmartPlanningRawFunc 调用粗排算法,同时返回展示结构和已分配的任务项。
// 由 service/agent_bridge.go 在构造时注入 ScheduleService.SmartPlanningRaw。
SmartPlanningRawFunc func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
// BatchApplyPlansFunc 将排程方案批量落库。
// 由 service/agent_bridge.go 在构造时注入 TaskClassService.BatchApplyPlans。
BatchApplyPlansFunc func(ctx context.Context, taskClassID, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error
// GetTaskClassByIDFunc 获取任务类详情(含 Items
// 由 service/agent_bridge.go 在构造时注入。
GetTaskClassByIDFunc func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error)
// HybridScheduleWithPlanFunc 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。
// 由 service/agent_bridge.go 在构造时注入。可选:未注入时走原有 materialize 路径。
HybridScheduleWithPlanFunc func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
}
// NewAgentService 构造 AgentService。
@@ -233,7 +248,7 @@ func (s *AgentService) runNormalChatFlow(
s.ensureConversationTitleAsync(userID, chatID)
}
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) {
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string, extra map[string]any) (<-chan string, <-chan error) {
requestStart := time.Now()
traceID := uuid.NewString()
@@ -366,7 +381,27 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
return
}
// 3.6 未知 action 兜底:走普通聊天,保证可用性
// 3.6 schedule_plan执行智能排程 graph
if routing.Action == route.ActionSchedulePlan {
reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName)
if planErr != nil {
log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr)
progress.Emit("schedule_plan.fallback", "智能排程暂不可用,先切回普通对话。")
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan)
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.7 未知 action 兜底:走普通聊天,保证可用性。
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
}()

View File

@@ -0,0 +1,101 @@
package agentsvc
import (
"context"
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
)
// runSchedulePlanFlow 执行"智能排程"分支。
//
// 职责边界:
// 1. 负责把本次请求接入 scheduleplan 执行器;
// 2. 负责注入排程依赖SmartPlanning / BatchApplyPlans / GetTaskClassByID
// 3. 负责对话历史获取,支持连续对话微调;
// 4. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
func (s *AgentService) runSchedulePlanFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
chatID string,
traceID string,
extra map[string]any,
emitStage func(stage, detail string),
outChan chan<- string,
modelName string,
) (string, error) {
// 1. 依赖预检:排程依赖函数必须注入,否则无法完成排程链路。
if s.SmartPlanningRawFunc == nil || s.BatchApplyPlansFunc == nil || s.GetTaskClassByIDFunc == nil {
return "", errors.New("schedule plan service dependencies are not ready")
}
if selectedModel == nil {
return "", errors.New("schedule plan model is nil")
}
// 2. 获取对话历史,用于连续对话微调场景。
// 优先从 Redis 读取,未命中时回源 DB。
var chatHistory []*schema.Message
if s.agentCache != nil {
history, err := s.agentCache.GetHistory(ctx, chatID)
if err != nil {
log.Printf("获取排程对话历史失败 chat_id=%s: %v", chatID, err)
} else if history != nil {
chatHistory = history
}
}
// 2.1 缓存未命中时回源 DB。
if chatHistory == nil && s.repo != nil {
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID)
if hisErr != nil {
log.Printf("回源 DB 获取排程对话历史失败 chat_id=%s: %v", chatID, hisErr)
} else {
chatHistory = conv.ToEinoMessages(histories)
}
}
// 3. 初始化排程状态对象。
state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
// 4. 构建依赖注入并执行 graph。
finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
Model: selectedModel,
State: state,
Deps: scheduleplan.SchedulePlanToolDeps{
SmartPlanningRaw: s.SmartPlanningRawFunc,
BatchApplyPlans: s.BatchApplyPlansFunc,
GetTaskClassByID: s.GetTaskClassByIDFunc,
HybridScheduleWithPlan: s.HybridScheduleWithPlanFunc,
},
UserMessage: userMessage,
Extra: extra,
ChatHistory: chatHistory,
EmitStage: emitStage,
OutChan: outChan,
ModelName: modelName,
})
if runErr != nil {
return "", runErr
}
// 5. 提取最终回复。
if finalState == nil {
return "排程流程异常,请稍后重试。", nil
}
reply := strings.TrimSpace(finalState.FinalSummary)
if reply == "" {
reply = "排程流程已完成,但未生成结果摘要。"
}
return reply, nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
@@ -407,3 +408,162 @@ func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassI
//5.将推荐的时间安排转换为前端需要的格式返回
return result, nil
}
// SmartPlanningRaw 执行粗排算法并同时返回展示结构和已分配的任务项。
//
// 职责边界:
// 1) 与 SmartPlanning 共享完全相同的前置校验和粗排逻辑;
// 2) 额外返回 allocatedItems每项的 EmbeddedTime 已由算法回填),
// 供 Agent 排程链路直接转换为 BatchApplyPlans 请求,无需再让模型"二次分配"。
func (ss *ScheduleService) SmartPlanningRaw(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) {
// 1. 获取任务类详情。
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
// 2. 获取时间范围内的全部日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate))
if err != nil {
return nil, nil, err
}
// 3. 执行粗排算法,拿到已分配的 itemsEmbeddedTime 已回填)。
allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 同时生成展示结构,供 SSE 阶段推送给前端预览。
displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems)
return displayResult, allocatedItems, nil
}
// HybridScheduleWithPlan 构建"混合日程":将既有日程与粗排建议合并为统一结构。
//
// 职责边界:
// 1) 获取 TaskClass 时间范围内的既有日程(课程 + 已落库任务);
// 2) 调用粗排算法获取建议分配;
// 3) 将两者合并为 []HybridScheduleEntry供 ReAct 精排引擎在内存中操作。
//
// 返回值:
// - entries混合日程条目existing + suggested
// - allocatedItems粗排已分配的任务项用于后续落库
// - error
func (ss *ScheduleService) HybridScheduleWithPlan(
ctx context.Context, userID, taskClassID int,
) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) {
// 1. 获取任务类详情。
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
// 2. 获取时间范围内的既有日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(
ctx, userID,
conv.CalculateFirstDayOfWeek(*taskClass.StartDate),
conv.CalculateLastDayOfWeek(*taskClass.EndDate),
)
if err != nil {
return nil, nil, err
}
// 3. 执行粗排算法。
allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 合并为 HybridScheduleEntry 切片。
entries := make([]model.HybridScheduleEntry, 0, len(schedules)/2+len(allocatedItems))
// 4.1 既有日程:按 EventID+Week+DayOfWeek 分组,合并连续节次。
type eventGroupKey struct {
EventID int
Week int
DayOfWeek int
}
type eventGroup struct {
Key eventGroupKey
Name string
Type string
Sections []int
}
groupMap := make(map[eventGroupKey]*eventGroup)
for _, s := range schedules {
key := eventGroupKey{EventID: s.EventID, Week: s.Week, DayOfWeek: s.DayOfWeek}
g, ok := groupMap[key]
if !ok {
name := "未知"
typ := "course"
if s.Event != nil {
name = s.Event.Name
typ = s.Event.Type
}
g = &eventGroup{Key: key, Name: name, Type: typ}
groupMap[key] = g
}
g.Sections = append(g.Sections, s.Section)
}
for _, g := range groupMap {
if len(g.Sections) == 0 {
continue
}
// 排序后取首尾作为 SectionFrom/SectionTo
minS, maxS := g.Sections[0], g.Sections[0]
for _, s := range g.Sections[1:] {
if s < minS {
minS = s
}
if s > maxS {
maxS = s
}
}
entries = append(entries, model.HybridScheduleEntry{
Week: g.Key.Week,
DayOfWeek: g.Key.DayOfWeek,
SectionFrom: minS,
SectionTo: maxS,
Name: g.Name,
Type: g.Type,
Status: "existing",
EventID: g.Key.EventID,
})
}
// 4.2 粗排建议:每个已分配的 TaskClassItem 转为一条 suggested 条目。
for _, item := range allocatedItems {
if item.EmbeddedTime == nil {
continue
}
name := "未命名任务"
if item.Content != nil && strings.TrimSpace(*item.Content) != "" {
name = strings.TrimSpace(*item.Content)
}
entries = append(entries, model.HybridScheduleEntry{
Week: item.EmbeddedTime.Week,
DayOfWeek: item.EmbeddedTime.DayOfWeek,
SectionFrom: item.EmbeddedTime.SectionFrom,
SectionTo: item.EmbeddedTime.SectionTo,
Name: name,
Type: "task",
Status: "suggested",
TaskItemID: item.ID,
})
}
return entries, allocatedItems, nil
}

View File

@@ -315,6 +315,15 @@ func (sv *TaskClassService) DeleteTaskClass(ctx context.Context, userID int, tas
return nil
}
// GetCompleteTaskClassByID 获取任务类完整详情(含关联的 TaskClassItem 列表)。
//
// 职责边界:
// 1) 直接委托 DAO 层查询,不做额外业务逻辑;
// 2) 主要供 Agent 排程链路使用,获取 Items 用于 materialize 节点映射。
func (sv *TaskClassService) GetCompleteTaskClassByID(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) {
return sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
}
func (sv *TaskClassService) BatchApplyPlans(ctx context.Context, taskClassID int, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error {
//1.通过任务类id获取任务类详情
taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)