Files
smartmate/backend/service/task-class.go
Losita d3cec2a5b9 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 精排引擎决策记录

  ⚠️ 已知问题:深度思考模式耗时较长,超时策略待优化
2026-03-19 23:18:56 +08:00

537 lines
20 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 service
import (
"context"
"errors"
"fmt"
"log"
"sort"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
)
type TaskClassService struct {
// 这里可以添加数据库连接或其他依赖
taskClassRepo *dao.TaskClassDAO
cacheRepo *dao.CacheDAO
scheduleRepo *dao.ScheduleDAO
repoManager *dao.RepoManager // 统一管理多个 DAO 的事务
}
func NewTaskClassService(taskClassRepo *dao.TaskClassDAO, cacheRepo *dao.CacheDAO, scheduleRepo *dao.ScheduleDAO, manager *dao.RepoManager) *TaskClassService {
return &TaskClassService{
taskClassRepo: taskClassRepo,
cacheRepo: cacheRepo,
scheduleRepo: scheduleRepo,
repoManager: manager,
}
}
// AddOrUpdateTaskClass 为指定用户添加任务类
func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model.UserAddTaskClassRequest, userID int, method int, targetTaskClassID int) error {
//1.先校验参数
if req.Mode == "auto" {
if req.StartDate == "" || req.EndDate == "" {
return respond.MissingParamForAutoScheduling
}
st, err := time.Parse("2006-01-02", req.StartDate)
if err != nil {
return respond.WrongParamType
}
ed, err := time.Parse("2006-01-02", req.EndDate)
if err != nil {
return respond.WrongParamType
}
if st.After(ed) {
return respond.InvalidDateRange
}
}
if req.Mode == "" || req.Name == "" || len(req.Items) == 0 {
return respond.MissingParam
}
//2.写数据库(事务内)
if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error {
taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID)
if err != nil {
return err
}
if method == 1 { // 更新操作
taskClass.ID = targetTaskClassID
}
taskClassID, err := txDAO.AddOrUpdateTaskClass(userID, taskClass)
if err != nil {
return err
}
for i := range items {
items[i].CategoryID = &taskClassID
}
if err := txDAO.AddOrUpdateTaskClassItems(userID, items); err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}
func (sv *TaskClassService) GetUserTaskClassInfos(ctx context.Context, userID int) (*model.UserGetTaskClassesResponse, error) {
//1.先查询redis
list, err := sv.cacheRepo.GetTaskClassList(ctx, userID)
if err == nil {
//命中缓存
return list, nil
} else if !errors.Is(err, redis.Nil) { //不是缓存未命中错误说明redis可能炸了照常放行
log.Println("redis获取任务分类列表失败:", err)
}
//2.缓存未命中,查询数据库
taskClasses, err := sv.taskClassRepo.GetUserTaskClasses(userID)
if err != nil {
return nil, err
}
resp := conv.TaskClassModelToResponse(taskClasses)
//3.写入缓存
err = sv.cacheRepo.AddTaskClassList(ctx, userID, resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (sv *TaskClassService) GetUserCompleteTaskClass(ctx context.Context, userID int, taskClassID int) (*model.UserAddTaskClassRequest, error) {
//1.查询数据库
taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, err
}
//2.转换为响应结构体
resp, err := conv.ProcessUserGetCompleteTaskClassRequest(taskClass)
if err != nil {
return nil, err
}
return resp, nil
}
func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, req *model.UserInsertTaskClassItemToScheduleRequest, userID int, taskID int) error {
//1.先验证任务块归属
taskClassID, err := sv.taskClassRepo.GetTaskClassIDByTaskItemID(ctx, taskID) //通过任务块ID获取所属任务类ID
if err != nil {
return err
}
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
if err != nil {
return err
}
if ownerID != userID {
return respond.TaskClassItemNotBelongToUser
}
//2.再检查任务块本身是否已经被安排
result, err := sv.taskClassRepo.IfTaskClassItemArranged(ctx, taskID)
if err != nil {
return err
}
if result {
return respond.TaskClassItemAlreadyArranged
}
//3.取出任务块信息
taskItem, err := sv.taskClassRepo.GetTaskClassItemByID(ctx, taskID) //通过任务块ID获取任务块信息
if err != nil {
return err
}
//更新TaskClassItem的embedded_time字段
taskItem.EmbeddedTime = &model.TargetTime{
DayOfWeek: req.DayOfWeek,
Week: req.Week,
SectionFrom: req.StartSection,
SectionTo: req.EndSection,
}
//3.判断是否嵌入课程
if req.EmbedCourseEventID != 0 {
//先检查看课程是否存在、是否归属该用户以及是否已经被嵌入了其他任务块
courseOwnerID, err := sv.scheduleRepo.GetCourseUserIDByID(ctx, req.EmbedCourseEventID)
if err != nil {
return err
}
if courseOwnerID != userID {
return respond.CourseNotBelongToUser
}
//再检查用户给的时间是否和课程的时间匹配(目前逻辑是给的区间必须完全匹配)
match, err := sv.scheduleRepo.IsCourseTimeMatch(ctx, req.EmbedCourseEventID, req.Week, req.DayOfWeek, req.StartSection, req.EndSection)
if err != nil {
return err
}
if !match {
return respond.CourseTimeNotMatch
}
//查询对应时段的课程是否已被其他任务块嵌入了(目前业务限制:一个课程只能被一个任务块嵌入,但是目前设计是支持多个任务块嵌入一节课的,只要放得下)
isEmbedded, err := sv.scheduleRepo.IsCourseEmbeddedByOtherTaskBlock(ctx, req.EmbedCourseEventID, req.StartSection, req.EndSection)
if err != nil {
return err
}
if isEmbedded {
return respond.CourseAlreadyEmbeddedByOtherTaskBlock
}
//嵌入课程,直接更新日程表对应时段的 embedded_task_id 字段
err = sv.scheduleRepo.EmbedTaskIntoSchedule(req.StartSection, req.EndSection, req.DayOfWeek, req.Week, userID, taskID)
if err != nil {
return err
}
//更新任务块的 embedded_time 字段
err = sv.taskClassRepo.UpdateTaskClassItemEmbeddedTime(ctx, taskID, taskItem.EmbeddedTime)
if err != nil {
return err
}
return nil
}
//4.否则构造Schedule模型
sections := make([]int, 0, req.EndSection-req.StartSection+1)
schedules, scheduleEvent, err := conv.UserInsertTaskItemRequestToModel(req, taskItem, nil, userID, req.StartSection, req.EndSection)
if err != nil {
return err
}
//将节次区间转换为节次切片,方便后续检查冲突
for section := req.StartSection; section <= req.EndSection; section++ {
sections = append(sections, section)
}
//4.1 统一检查冲突(避免逐条查库)
conflict, err := sv.scheduleRepo.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections)
if err != nil {
return err
}
if conflict {
return respond.ScheduleConflict
}
// 5. 写入数据库(通过 RepoManager 统一管理事务)
// 这里的 sv.daoManager 是你在初始化 Service 时注入的全局 RepoManager 实例
if err := sv.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error {
// 5.1 使用事务中的 ScheduleRepo 插入 Event
// 💡 这里的 txM.Schedule 已经注入了事务句柄
//此处要将req中的起始section以及第几周、星期几转换成绝对时间存入scheduleEvent的StartTime和EndTime字段中方便后续查询和冲突检查
st, ed, err := conv.RelativeTimeToRealTime(req.Week, req.DayOfWeek, req.StartSection, req.EndSection)
if err != nil {
return err
}
scheduleEvent.StartTime = st
scheduleEvent.EndTime = ed
eventID, err := txM.Schedule.AddScheduleEvent(scheduleEvent)
if err != nil {
return err // 触发回滚
}
// 5.2 关联 ID纯内存操作无需 tx
for i := range schedules {
schedules[i].EventID = eventID
}
// 5.3 使用事务中的 ScheduleRepo 批量插入原子槽位
// 💡 如果这里因为外键或唯一索引报错5.1 的 Event 也会被撤回
if _, err = txM.Schedule.AddSchedules(schedules); err != nil {
return err // 触发回滚
}
// 5.4 使用事务中的 TaskRepo 更新任务状态
// 💡 这里的 txM.Task 取代了你原来的 txDAO
if err := txM.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, taskID, taskItem.EmbeddedTime); err != nil {
return err // 触发回滚
}
return nil
}); err != nil {
// 这里处理最终的错误返回,比如 respond.Error
return err
}
return nil
}
func (sv *TaskClassService) DeleteTaskClassItem(ctx context.Context, userID int, taskItemID int) error {
//1.先验证任务块归属
taskClassID, err := sv.taskClassRepo.GetTaskClassIDByTaskItemID(ctx, taskItemID) //通过任务块ID获取所属任务类ID
if err != nil {
return err
}
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
if err != nil {
return err
}
if ownerID != userID {
return respond.TaskClassItemNotBelongToUser
}
//2.如果该任务块已经被安排了,先解除安排,再删除任务块(事务)
if err := sv.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error {
//2.1.先检查该任务块是否已经被安排了
arranged, err := txM.TaskClass.IfTaskClassItemArranged(ctx, taskItemID)
if err != nil {
return err
}
if arranged {
//2.2.如果已经被安排了,先解除安排
//先扫schedules找到该task_item_id并删除
_, txErr := txM.Schedule.FindEmbeddedTaskIDAndDeleteIt(ctx, taskItemID)
//2.3.再将task_items表的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskItemID)
if txErr != nil {
return txErr
}
//再删除schedule_event表中对应的事件
txErr = txM.Schedule.DeleteScheduleEventByTaskItemID(ctx, taskItemID)
if txErr != nil {
return txErr
}
}
//2.4.最后删除任务块
err = txM.TaskClass.DeleteTaskClassItemByID(ctx, taskItemID)
if err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}
func (sv *TaskClassService) DeleteTaskClass(ctx context.Context, userID int, taskClassID int) error {
//1.先验证任务类归属
ownerID, err := sv.taskClassRepo.GetTaskClassUserIDByID(ctx, taskClassID) //通过任务类ID获取所属用户ID
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return respond.WrongTaskClassID
}
return err
}
if ownerID != userID {
return respond.TaskClassNotBelongToUser
}
//2.删除任务类(事务)
err = sv.taskClassRepo.DeleteTaskClassByID(ctx, taskClassID)
if err != nil {
return err
}
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)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return respond.WrongTaskClassID
}
return err
}
//2.校验任务类的参数是否合法
if taskClass == nil {
return respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return respond.TaskClassModeNotAuto
}
//3.获取任务类安排的时间范围内的全部周数信息(左右边界不足一周的情况也要算作一周),用于下方冲突检查
startWeekTime := conv.CalculateFirstDayOfWeek(*taskClass.StartDate)
endWeekTime := conv.CalculateLastDayOfWeek(*taskClass.EndDate)
schedules, err := sv.scheduleRepo.GetUserSchedulesByTimeRange(ctx, userID, startWeekTime, endWeekTime)
if err != nil {
return err
}
startWeek, _, err := conv.RealDateToRelativeDate(startWeekTime.Format("2006-01-02"))
if err != nil {
return err
}
endWeek, _, err := conv.RealDateToRelativeDate(endWeekTime.Format("2006-01-02"))
if err != nil {
return err
}
//4.统一检查冲突(避免逐条查库)
//先将日程放入一个map中key是"周-星期-节次"value是课程信息方便后续检查冲突
courseMap := make(map[string]model.Schedule)
for _, schedule := range schedules {
key := fmt.Sprintf("%d-%d-%d", schedule.Week, schedule.DayOfWeek, schedule.Section)
courseMap[key] = schedule
}
//再遍历每个任务块的安排时间,检查是否和课程冲突(目前逻辑是只要有一个时段冲突就算冲突,后续可以优化为统计冲突的时段数量,或者提供具体的冲突时段信息)
for _, plan := range plans.Items {
if plan.Week < startWeek || plan.Week > endWeek {
return respond.TaskClassItemTryingToInsertOutOfTimeRange
}
for section := plan.StartSection; section <= plan.EndSection; section++ {
key := fmt.Sprintf("%d-%d-%d", plan.Week, plan.DayOfWeek, section)
// 如果课程存在,并且满足以下任一条件则认为冲突:
// 1. 课程时段已经被其他任务块嵌入了(不允许多个任务块嵌入同一课程)
// 2. 当前时段的课的EventID与用户计划中指定的EmbedCourseEventID不匹配说明用户计划要嵌入的课程和当前时段的课不是同一节
// 3. 用户计划中没有指定EmbedCourseEventID即EmbedCourseEventID为0但当前时段有课不允许在有课的时段安排任务块
// 4. 当前时段的课不允许被嵌入即使用户计划中指定了EmbedCourseEventID但如果课程本身不允许被嵌入了也算冲突
if course, exists := courseMap[key]; exists && ((plan.EmbedCourseEventID != 0 && course.EmbeddedTask != nil) ||
(plan.EmbedCourseEventID != course.EventID) || plan.EmbedCourseEventID == 0 || !course.Event.CanBeEmbedded) {
return respond.ScheduleConflict
}
}
}
//5.分流批量写入数据库(通过 RepoManager 统一管理事务)
//先分流
toEmbed := make([]model.SingleTaskClassItem, 0) //需要嵌入课程的任务块
toNormal := make([]model.SingleTaskClassItem, 0) //需要新建日程的任务块
for _, item := range plans.Items {
if item.EmbedCourseEventID != 0 {
toEmbed = append(toEmbed, item)
} else {
toNormal = append(toNormal, item)
}
}
//再开事务批量写库
if err := sv.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error {
//5.1 先处理需要嵌入课程的任务块
//先提取出需要嵌入的课程ID和TaskItemID列表
courseIDs := make([]int, 0, len(toEmbed))
for _, item := range toEmbed {
courseIDs = append(courseIDs, item.EmbedCourseEventID)
}
itemIDs := make([]int, 0, len(toEmbed))
for _, item := range toEmbed {
itemIDs = append(itemIDs, item.TaskItemID)
}
//检查任务块本身是否已经被安排
result, err := sv.taskClassRepo.BatchCheckIfTaskClassItemsArranged(ctx, itemIDs)
if err != nil {
return err
}
if result {
return respond.TaskClassItemAlreadyArranged
}
//验证一下plans中的taskItemID确实都属于这个用户和这个任务类避免用户恶意构造请求把别的用户的任务块或者不属于任何任务类的任务块也安排了
//同时也能检查是否重复
result, err = sv.taskClassRepo.ValidateTaskItemIDsBelongToTaskClass(ctx, taskClassID, itemIDs)
if err != nil {
return err
}
if !result {
return respond.TaskClassItemNotBelongToTaskClass
}
//批量更新日程表中对应课程的embedded_task_id字段目前业务限制一个课程只能被一个任务块嵌入了所以直接批量更新不用担心覆盖问题
err = txM.Schedule.BatchEmbedTaskIntoSchedule(ctx, courseIDs, itemIDs)
if err != nil {
return err
}
//批量更新任务块的embedded_time字段
targetTimes := make([]*model.TargetTime, 0, len(toEmbed))
for _, item := range toEmbed {
targetTimes = append(targetTimes, &model.TargetTime{
DayOfWeek: item.DayOfWeek,
Week: item.Week,
SectionFrom: item.StartSection,
SectionTo: item.EndSection,
})
}
err = txM.TaskClass.BatchUpdateTaskClassItemEmbeddedTime(ctx, itemIDs, targetTimes)
if err != nil {
return err
}
//5.2 再处理需要新建日程的任务块
//先提取出需要新建日程的任务块ID列表
normalItemIDs := make([]int, 0, len(toNormal))
for _, item := range toNormal {
normalItemIDs = append(normalItemIDs, item.TaskItemID)
}
//验证一下plans中的taskItemID确实都属于这个任务类避免用户恶意构造请求把别的用户的任务块或者不属于任何任务类的任务块也安排了
result, err = sv.taskClassRepo.ValidateTaskItemIDsBelongToTaskClass(ctx, taskClassID, normalItemIDs)
if err != nil {
return err
}
if !result {
return respond.TaskClassItemNotBelongToTaskClass
}
//批量提取TaskItems
taskItems, err := txM.TaskClass.GetTaskClassItemsByIDs(ctx, normalItemIDs)
if err != nil {
return err
}
if len(taskItems) != len(normalItemIDs) {
log.Printf("警告批量提取任务块时返回的任务块数量与请求中的任务块ID数量不匹配可能存在数据问题。请求ID数量%d返回任务块数量%d", len(normalItemIDs), len(taskItems))
return respond.InternalError(errors.New("返回的任务块数量与请求中的任务块ID数量不匹配可能存在数据问题"))
}
//将toNormal按照TaskItemID升序排序将taskItems也按照ID升序排序保证一一对应关系上面已经检查过重复
//如果请求中的任务块ID有重复这里就无法保证一一对应关系了后续可以考虑在请求层面加一个校验拒绝包含重复任务块ID的请求
sort.SliceStable(toNormal, func(i, j int) bool {
return toNormal[i].TaskItemID < toNormal[j].TaskItemID
})
sort.SliceStable(taskItems, func(i, j int) bool {
return taskItems[i].ID < taskItems[j].ID
})
//开始构建event和schedules
finalSchedules := make([]model.Schedule, 0) //最终要插入数据库的Schedule切片
finalScheduleEvents := make([]model.ScheduleEvent, 0) //最终要插入数据库的ScheduleEvent切片
pos := make([]int, 0) //记录每个任务块对应的Schedule在finalSchedules中的位置方便后续批量插入数据库后回填EventID
for i := 0; i < len(toNormal); i++ {
item := toNormal[i]
taskItem := taskItems[i]
if item.StartSection < 1 || item.EndSection > 12 || item.StartSection > item.EndSection {
return respond.InvalidSectionRange
}
schedules, scheduleEvent, err := conv.UserInsertTaskItemRequestToModel(&model.UserInsertTaskClassItemToScheduleRequest{
Week: item.Week,
DayOfWeek: item.DayOfWeek,
StartSection: item.StartSection,
EndSection: item.EndSection,
EmbedCourseEventID: 0, //不嵌入课程
}, &taskItem, nil, userID, item.StartSection, item.EndSection)
if err != nil {
return err
}
finalScheduleEvents = append(finalScheduleEvents, *scheduleEvent)
for range schedules {
pos = append(pos, len(finalScheduleEvents)-1)
}
finalSchedules = append(finalSchedules, schedules...)
}
//最后批量插入数据库
//先插入ScheduleEvent表获取生成的EventID再批量插入Schedule表最后批量更新TaskClassItem的embedded_time字段
ids, err := txM.Schedule.InsertScheduleEvents(ctx, finalScheduleEvents)
if err != nil {
return err
}
// 将生成的 ScheduleEvent ID 赋值给对应的 Schedule 的 EventID 字段
for i := range finalSchedules {
finalSchedules[i].EventID = ids[pos[i]]
}
if _, err = txM.Schedule.AddSchedules(finalSchedules); err != nil {
return err
}
//批量更新任务块的embedded_time字段
targetTimes = make([]*model.TargetTime, 0, len(toEmbed))
for _, item := range toNormal {
targetTimes = append(targetTimes, &model.TargetTime{
DayOfWeek: item.DayOfWeek,
Week: item.Week,
SectionFrom: item.StartSection,
SectionTo: item.EndSection,
})
}
//提取出所有需要更新的任务块ID
itemIDs = make([]int, 0, len(toNormal))
for _, item := range toNormal {
itemIDs = append(itemIDs, item.TaskItemID)
}
err = txM.TaskClass.BatchUpdateTaskClassItemEmbeddedTime(ctx, itemIDs, targetTimes)
if err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}