Version: 0.9.2.dev.260406
后端:
1.Chat 四路由升级(二分类 chat/task → 四路由 direct_reply/execute/deep_answer/plan)
- 新建model/chat_contract.go:路由决策模型,含 NeedsRoughBuild 粗排标记
- 更新node/chat.go:四路由分流;新增 deep_answer 深度回答路径(二次 LLM 开 thinking)
- 更新prompt/chat.go:意图分类 prompt 升级为四路由 prompt;新增 deep_answer prompt
2.粗排节点(RoughBuild)全链路
- 新建node/rough_build.go:粗排节点,调用注入的算法函数,结果写入 ScheduleState 后进 Execute 微调
- 更新graph/common_graph.go:注册 RoughBuild 节点;Chat/Confirm 后可路由至粗排
- 更新model/graph_run_state.go:新增 RoughBuildPlacement/RoughBuildFunc 类型;Deps 注入入口
- 更新model/plan_contract.go:PlanDecision 新增 NeedsRoughBuild/TaskClassIDs 字段
- 更新node/plan.go:plan_done 时写入粗排标记和 TaskClassIDs
3.任务类约束元数据(TaskClassMeta)贯穿 prompt → tools → 持久化
- 更新tools/state.go:新增 TaskClassMeta;ScheduleState.TaskClasses;ScheduleTask.TaskClassID;Clone 深拷贝
- 更新conv/schedule_state.go:加载时构建 TaskClassMeta;Diff 支持 HostEventID 嵌入关系
- 更新conv/schedule_provider.go:新增 LoadTaskClassMetas 按需加载
- 更新model/state_store.go:ScheduleStateProvider 接口新增 LoadTaskClassMetas
- 更新prompt/base.go:renderStateSummary 渲染任务类约束
- 更新prompt/plan.go:注入任务类 ID 上下文和粗排识别规则
- 更新tools/read_tools.go:GetOverview 展示任务类约束
- 更新model/common_state.go:CommonState 新增 TaskClassIDs/TaskClasses/NeedsRoughBuild
4.Execute 健壮性增强(correction 重试 + 纯 ReAct 模式)
- 更新node/execute.go:未知工具名/空文本走 correction 重试而非 fatal;maxConsecutiveCorrections 提升为包级常量;新增无 plan 纯ReAct 模式;工具结果截断;speak 排除 ask_user/confirm
- 更新prompt/execute.go:新增 ReAct 模式 system prompt 和 contract
5.写入持久化完善(task_item source + 嵌入水课)
- 更新conv/schedule_persist.go:place/move/unplace 支持 task_item source,含嵌入水课和普通 task event 两条路径
- 新建conv/schedule_preview.go:ScheduleState → 排程预览缓存,复用旧格式,前端无需改动
6.状态持久化体系(Redis → MySQL outbox 异步)
- 更新dao/cache.go:Redis 快照 TTL 从 24h 改为 2h,配合 MySQL outbox
- 新建model/agent_state_snapshot_record.go:快照 MySQL 记录模型
- 新建service/events/agent_state_persist.go:outbox 异步持久化处理器
- 更新cmd/start.go + inits/mysql.go:注册快照事件处理器 + AutoMigrate
- 更新service/agentsvc/agent_newagent.go:注入 RoughBuildFunc;outbox 异步写快照;排程结果写 Redis 预览缓存
7.基础设施与稳定性
- 更新stream/sse_adapter.go:outChan 满时静默丢弃,保证持久化不被 SSE 阻断
- 更新service/agentsvc/agent.go:新增 readAgentExtraIntSlice;outChan 容量 8→256
- 更新node/agent_nodes.go:Chat 注入工具 schema;Deliver 改 saveAgentState 替代 deleteAgentState
前端:无
仓库:无
This commit is contained in:
@@ -69,25 +69,28 @@ func applyScheduleChange(ctx context.Context, manager *dao.RepoManager, change S
|
||||
|
||||
// applyPlaceChange 应用放置变更。
|
||||
func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
// Place:pending → placed,为现有 Event 创建 Schedule
|
||||
// 前提:Event 已经存在(SourceID 是 ScheduleEvent.ID)
|
||||
// NewCoords 包含所有需要放置的位置(可能多天/多节)
|
||||
|
||||
if len(change.NewCoords) == 0 {
|
||||
return fmt.Errorf("place 变更缺少目标位置")
|
||||
}
|
||||
|
||||
if change.Source != "event" || change.SourceID == 0 {
|
||||
return fmt.Errorf("place 变更需要有效的 event source")
|
||||
switch change.Source {
|
||||
case "event":
|
||||
return applyPlaceEventSource(ctx, manager, change, userID)
|
||||
case "task_item":
|
||||
return applyPlaceTaskItem(ctx, manager, change, userID)
|
||||
default:
|
||||
return fmt.Errorf("place 变更不支持的 source: %s", change.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// 按周天分组,压缩成 slot ranges
|
||||
// applyPlaceEventSource 处理 source=event 的放置(为已有 Event 创建 Schedule 记录)。
|
||||
func applyPlaceEventSource(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("place event 变更需要有效的 source_id")
|
||||
}
|
||||
groups := groupCoordsByWeekDay(change.NewCoords)
|
||||
for week, dayGroups := range groups {
|
||||
for dayOfWeek, coords := range dayGroups {
|
||||
startSection, endSection := minMaxSection(coords)
|
||||
|
||||
// 创建 schedule 记录(event 已存在,只创建 schedule)
|
||||
schedules := make([]model.Schedule, endSection-startSection+1)
|
||||
for sec := startSection; sec <= endSection; sec++ {
|
||||
schedules[sec-startSection] = model.Schedule{
|
||||
@@ -98,10 +101,7 @@ func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change Sche
|
||||
EventID: change.SourceID,
|
||||
}
|
||||
}
|
||||
|
||||
// 批量创建
|
||||
_, err := manager.Schedule.AddSchedules(schedules)
|
||||
if err != nil {
|
||||
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
|
||||
return fmt.Errorf("创建 schedule 失败: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -109,29 +109,134 @@ func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change Sche
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMoveChange 应用移动变更。
|
||||
func applyMoveChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
// Move:已有 schedule,只更新位置
|
||||
// 需要删除旧位置的 schedule,在新位置创建新 schedule
|
||||
// applyPlaceTaskItem 处理 source=task_item 的放置。
|
||||
//
|
||||
// 两条路径:
|
||||
// 1. 嵌入水课(HostEventID != 0):在宿主 Schedule 记录上设置 embedded_task_id。
|
||||
// 2. 普通放置(HostEventID == 0):新建 ScheduleEvent(type=task) + Schedule 记录。
|
||||
// 两条路径最终都更新 task_items.embedded_time。
|
||||
func applyPlaceTaskItem(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("place task_item 变更需要有效的 source_id")
|
||||
}
|
||||
|
||||
// 1. 删除旧位置
|
||||
if change.Source == "event" && change.SourceID != 0 {
|
||||
if err := manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID); err != nil {
|
||||
return fmt.Errorf("删除旧位置失败: %w", err)
|
||||
// task_item 只占一段连续时段,取第一个 coord 的 week/dayOfWeek
|
||||
first := change.NewCoords[0]
|
||||
week, dayOfWeek := first.Week, first.DayOfWeek
|
||||
startSection, endSection := minMaxSection(change.NewCoords)
|
||||
|
||||
targetTime := &model.TargetTime{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: startSection,
|
||||
SectionTo: endSection,
|
||||
}
|
||||
|
||||
if change.HostEventID != 0 {
|
||||
// 嵌入路径:更新宿主 Schedule 记录的 embedded_task_id
|
||||
if err := manager.Schedule.EmbedTaskIntoSchedule(
|
||||
startSection, endSection, dayOfWeek, week, userID, change.SourceID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("嵌入水课失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 普通路径:新建 ScheduleEvent + Schedule 记录
|
||||
startTime, endTime, err := RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("时间转换失败: %w", err)
|
||||
}
|
||||
relID := change.SourceID
|
||||
event := model.ScheduleEvent{
|
||||
UserID: userID,
|
||||
Name: change.Name,
|
||||
Type: "task",
|
||||
RelID: &relID,
|
||||
CanBeEmbedded: false,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
eventID, err := manager.Schedule.AddScheduleEvent(&event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 schedule_event 失败: %w", err)
|
||||
}
|
||||
schedules := make([]model.Schedule, endSection-startSection+1)
|
||||
for i, sec := 0, startSection; sec <= endSection; i, sec = i+1, sec+1 {
|
||||
schedules[i] = model.Schedule{
|
||||
UserID: userID,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
Section: sec,
|
||||
EventID: eventID,
|
||||
Status: "normal",
|
||||
}
|
||||
}
|
||||
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
|
||||
return fmt.Errorf("创建 schedule 记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建新位置(复用 place 逻辑)
|
||||
if err := manager.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, change.SourceID, targetTime); err != nil {
|
||||
return fmt.Errorf("更新 task_item embedded_time 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMoveChange 应用移动变更。
|
||||
func applyMoveChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
switch change.Source {
|
||||
case "event":
|
||||
if change.SourceID != 0 {
|
||||
if err := manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID); err != nil {
|
||||
return fmt.Errorf("删除旧位置失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "task_item":
|
||||
// 清理旧位置
|
||||
if change.OldHostEventID != 0 {
|
||||
// 旧位置是嵌入:清空宿主的 embedded_task_id
|
||||
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.OldHostEventID); err != nil {
|
||||
return fmt.Errorf("清除旧嵌入关系失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 旧位置是普通 task event:按 task_item_id 删除
|
||||
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("删除旧 task_item 日程失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyPlaceChange(ctx, manager, change, userID)
|
||||
}
|
||||
|
||||
// applyUnplaceChange 应用移除变更。
|
||||
func applyUnplaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
// Unplace:删除 schedule,任务恢复为 pending
|
||||
if change.Source == "event" && change.SourceID != 0 {
|
||||
switch change.Source {
|
||||
case "event":
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("unplace event 变更需要有效的 source_id")
|
||||
}
|
||||
return manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID)
|
||||
case "task_item":
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("unplace task_item 变更需要有效的 source_id")
|
||||
}
|
||||
if change.HostEventID != 0 {
|
||||
// 是嵌入:清空宿主 Schedule 的 embedded_task_id
|
||||
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.HostEventID); err != nil {
|
||||
return fmt.Errorf("清除嵌入关系失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 普通 task event:按 task_item_id 删除
|
||||
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("删除 task_item 日程失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := manager.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("清除 task_item embedded_time 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unplace 变更不支持的 source: %s", change.Source)
|
||||
}
|
||||
return fmt.Errorf("unplace 变更的 source 不是 event: %s", change.Source)
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
109
backend/conv/schedule_preview.go
Normal file
109
backend/conv/schedule_preview.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// ScheduleStateToPreview 将 newAgent 的 ScheduleState 转换为前端预览缓存格式。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做数据格式转换,不做业务逻辑;
|
||||
// 2. 将每个 ScheduleTask 的每个 TaskSlot 转为一条 HybridScheduleEntry;
|
||||
// 3. Day → (Week, DayOfWeek) 通过 ScheduleState.DayToWeekDay 转换;
|
||||
// 4. 转换失败的 slot(day_index 无效)静默跳过。
|
||||
func ScheduleStateToPreview(
|
||||
state *newagenttools.ScheduleState,
|
||||
userID int,
|
||||
conversationID string,
|
||||
taskClassIDs []int,
|
||||
summary string,
|
||||
) *model.SchedulePlanPreviewCache {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]model.HybridScheduleEntry, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 待安排且无位置的任务不生成 entry。
|
||||
if t.Status == "pending" && len(t.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, slot := range t.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := model.HybridScheduleEntry{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: t.Name,
|
||||
}
|
||||
|
||||
// Type 映射。
|
||||
if t.Source == "event" {
|
||||
if t.EventType != "" {
|
||||
entry.Type = t.EventType
|
||||
} else {
|
||||
entry.Type = "course"
|
||||
}
|
||||
} else {
|
||||
entry.Type = "task"
|
||||
}
|
||||
|
||||
// Status 映射:existing 不变,pending(有位置)= suggested。
|
||||
if t.Status == "pending" {
|
||||
entry.Status = "suggested"
|
||||
} else {
|
||||
entry.Status = "existing"
|
||||
}
|
||||
|
||||
// ID 映射。
|
||||
if t.Source == "event" {
|
||||
entry.EventID = t.SourceID
|
||||
} else {
|
||||
entry.TaskItemID = t.SourceID
|
||||
}
|
||||
|
||||
// 嵌入与阻塞语义。
|
||||
entry.CanBeEmbedded = t.CanEmbed
|
||||
if t.Source == "event" && t.CanEmbed && t.EmbeddedBy == nil {
|
||||
// 可嵌入且当前无嵌入任务 → 不阻塞 suggested 占位。
|
||||
entry.BlockForSuggested = false
|
||||
} else {
|
||||
entry.BlockForSuggested = true
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成摘要(若调用方未提供)。
|
||||
if summary == "" {
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "existing" {
|
||||
existingCount++
|
||||
} else {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
summary = fmt.Sprintf("共 %d 个日程条目,其中已确定 %d 个,新安排 %d 个。", len(entries), existingCount, suggestedCount)
|
||||
}
|
||||
|
||||
return &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Summary: summary,
|
||||
HybridEntries: entries,
|
||||
TaskClassIDs: taskClassIDs,
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,51 @@ func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID i
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// LoadTaskClassMetas 加载指定任务类的约束元数据(不含 Items、不含日程),供 Plan 阶段提前消费。
|
||||
func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]newagenttools.TaskClassMeta, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载任务类元数据失败: %w", err)
|
||||
}
|
||||
metas := make([]newagenttools.TaskClassMeta, 0, len(complete))
|
||||
for _, tc := range complete {
|
||||
meta := newagenttools.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: derefString(tc.Name),
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// buildExtraItemCategories 从已有日程中提取不属于给定 taskClasses 的 task event 的 category 映射。
|
||||
// 当加载全部 taskClass 时,通常返回空 map。
|
||||
func buildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
|
||||
@@ -178,6 +178,7 @@ func LoadScheduleState(
|
||||
}
|
||||
catID := tc.ID
|
||||
|
||||
pendingCount := 0
|
||||
for _, item := range tc.Items {
|
||||
if item.Status == nil || *item.Status != model.TaskItemStatusUnscheduled {
|
||||
continue
|
||||
@@ -197,17 +198,46 @@ func LoadScheduleState(
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: name,
|
||||
Category: catName,
|
||||
Status: "pending",
|
||||
Duration: duration,
|
||||
CategoryID: catID,
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: name,
|
||||
Category: catName,
|
||||
Status: "pending",
|
||||
Duration: duration,
|
||||
CategoryID: catID,
|
||||
TaskClassID: tc.ID,
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
pendingCount++
|
||||
}
|
||||
|
||||
// 有待安排 item 的任务类才暴露约束给 LLM。
|
||||
if pendingCount > 0 {
|
||||
meta := newagenttools.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: catName,
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
state.TaskClasses = append(state.TaskClasses, meta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +316,13 @@ type ScheduleChange struct {
|
||||
NewCoords []SlotCoord
|
||||
// For move/unplace: old slot positions
|
||||
OldCoords []SlotCoord
|
||||
|
||||
// HostEventID: source=task_item 嵌入路径时,宿主课程的 schedule_event.id。
|
||||
// Place/Unplace:当前操作位置的宿主 EventID(0 表示非嵌入)。
|
||||
// Move:新位置的宿主 EventID。
|
||||
HostEventID int
|
||||
// OldHostEventID: Move 时旧位置的宿主 EventID(0 表示旧位置非嵌入)。
|
||||
OldHostEventID int
|
||||
}
|
||||
|
||||
// DiffScheduleState compares original and modified ScheduleState,
|
||||
@@ -313,40 +350,44 @@ func DiffScheduleState(
|
||||
// Place: pending → has slots
|
||||
case wasPending && hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangePlace,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
Type: ChangePlace,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
})
|
||||
|
||||
// Move: had slots → different slots
|
||||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeMove,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
Type: ChangeMove,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
OldHostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
|
||||
// Unplace: had slots → no slots
|
||||
case hadSlots && !hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeUnplace,
|
||||
StateID: mod.StateID,
|
||||
Source: orig.Source,
|
||||
SourceID: orig.SourceID,
|
||||
EventType: orig.EventType,
|
||||
Name: orig.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
Type: ChangeUnplace,
|
||||
StateID: mod.StateID,
|
||||
Source: orig.Source,
|
||||
SourceID: orig.SourceID,
|
||||
EventType: orig.EventType,
|
||||
Name: orig.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
HostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,6 +417,20 @@ func slotsEqual(a, b []newagenttools.TaskSlot) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveHostEventID 从任务的 EmbedHost 字段反查宿主的 ScheduleEvent.ID。
|
||||
// 用于 DiffScheduleState 在生成 ScheduleChange 时记录嵌入路径的宿主 EventID。
|
||||
// 若任务非嵌入(EmbedHost == nil)或宿主不存在,返回 0。
|
||||
func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.ScheduleState) int {
|
||||
if task == nil || task.EmbedHost == nil {
|
||||
return 0
|
||||
}
|
||||
host := state.TaskByStateID(*task.EmbedHost)
|
||||
if host == nil {
|
||||
return 0
|
||||
}
|
||||
return host.SourceID
|
||||
}
|
||||
|
||||
// expandToCoords converts compressed TaskSlots to individual SlotCoords.
|
||||
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
|
||||
var coords []SlotCoord
|
||||
|
||||
279
backend/conv/schedule_state_test.go
Normal file
279
backend/conv/schedule_state_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// buildTestState 构造最小可用的 ScheduleState,DayMapping 让 expandToCoords 能正常工作。
|
||||
func buildTestState(days []newagenttools.DayMapping, tasks []newagenttools.ScheduleTask) *newagenttools.ScheduleState {
|
||||
return &newagenttools.ScheduleState{
|
||||
Window: newagenttools.ScheduleWindow{
|
||||
TotalDays: len(days),
|
||||
DayMapping: days,
|
||||
},
|
||||
Tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultDays 返回 3 天的 DayMapping:day1=week3/dow1, day2=week3/dow2, day3=week3/dow3
|
||||
func defaultDays() []newagenttools.DayMapping {
|
||||
return []newagenttools.DayMapping{
|
||||
{DayIndex: 1, Week: 3, DayOfWeek: 1},
|
||||
{DayIndex: 2, Week: 3, DayOfWeek: 2},
|
||||
{DayIndex: 3, Week: 3, DayOfWeek: 3},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DiffScheduleState: task_item place ====================
|
||||
|
||||
// TestDiff_PlaceTaskItem_NonEmbed 验证:普通放置 task_item 时 HostEventID=0。
|
||||
func TestDiff_PlaceTaskItem_NonEmbed(t *testing.T) {
|
||||
days := defaultDays()
|
||||
|
||||
original := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending", Duration: 2},
|
||||
})
|
||||
|
||||
modified := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 1, SlotEnd: 2}},
|
||||
},
|
||||
})
|
||||
|
||||
changes := DiffScheduleState(original, modified)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("期望 1 个变更,实际 %d 个", len(changes))
|
||||
}
|
||||
c := changes[0]
|
||||
if c.Type != ChangePlace {
|
||||
t.Errorf("期望 ChangePlace,实际 %s", c.Type)
|
||||
}
|
||||
if c.Source != "task_item" || c.SourceID != 10 {
|
||||
t.Errorf("source 或 sourceID 错误: %s/%d", c.Source, c.SourceID)
|
||||
}
|
||||
if c.HostEventID != 0 {
|
||||
t.Errorf("非嵌入路径 HostEventID 应为 0,实际 %d", c.HostEventID)
|
||||
}
|
||||
if len(c.NewCoords) != 2 {
|
||||
t.Errorf("期望 2 个节次坐标,实际 %d", len(c.NewCoords))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiff_PlaceTaskItem_Embed 验证:嵌入放置时 HostEventID = 宿主的 SourceID。
|
||||
func TestDiff_PlaceTaskItem_Embed(t *testing.T) {
|
||||
days := defaultDays()
|
||||
|
||||
// 原始:宿主(水课)已安排,guest 待安排
|
||||
original := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 100,
|
||||
Source: "event",
|
||||
SourceID: 999, // ScheduleEvent.ID of the host course
|
||||
Name: "高数",
|
||||
Status: "existing",
|
||||
CanEmbed: true,
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
},
|
||||
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending", Duration: 2},
|
||||
})
|
||||
|
||||
hostID := 100
|
||||
// 修改后:guest 嵌入到宿主
|
||||
modified := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 100,
|
||||
Source: "event",
|
||||
SourceID: 999,
|
||||
Name: "高数",
|
||||
Status: "existing",
|
||||
CanEmbed: true,
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
EmbeddedBy: &[]int{1}[0],
|
||||
},
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
EmbedHost: &hostID,
|
||||
},
|
||||
})
|
||||
|
||||
changes := DiffScheduleState(original, modified)
|
||||
// 宿主 slots 未变,只有 guest 产生 place 变更
|
||||
var placeChange *ScheduleChange
|
||||
for i := range changes {
|
||||
if changes[i].SourceID == 10 {
|
||||
placeChange = &changes[i]
|
||||
}
|
||||
}
|
||||
if placeChange == nil {
|
||||
t.Fatal("未找到 task_item 的 place 变更")
|
||||
}
|
||||
if placeChange.HostEventID != 999 {
|
||||
t.Errorf("嵌入路径 HostEventID 应为 999(宿主 SourceID),实际 %d", placeChange.HostEventID)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DiffScheduleState: task_item unplace ====================
|
||||
|
||||
// TestDiff_UnplaceTaskItem_NonEmbed 验证:从普通位置移除时 HostEventID=0。
|
||||
func TestDiff_UnplaceTaskItem_NonEmbed(t *testing.T) {
|
||||
days := defaultDays()
|
||||
|
||||
original := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 5, SlotEnd: 6}},
|
||||
},
|
||||
})
|
||||
modified := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending"},
|
||||
})
|
||||
|
||||
changes := DiffScheduleState(original, modified)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("期望 1 个变更,实际 %d", len(changes))
|
||||
}
|
||||
c := changes[0]
|
||||
if c.Type != ChangeUnplace {
|
||||
t.Errorf("期望 ChangeUnplace,实际 %s", c.Type)
|
||||
}
|
||||
if c.HostEventID != 0 {
|
||||
t.Errorf("普通移除 HostEventID 应为 0,实际 %d", c.HostEventID)
|
||||
}
|
||||
if len(c.OldCoords) != 2 {
|
||||
t.Errorf("期望 2 个旧坐标,实际 %d", len(c.OldCoords))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiff_UnplaceTaskItem_Embed 验证:从嵌入位置移除时 HostEventID = 宿主 SourceID。
|
||||
func TestDiff_UnplaceTaskItem_Embed(t *testing.T) {
|
||||
days := defaultDays()
|
||||
hostStateID := 100
|
||||
|
||||
original := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 100,
|
||||
Source: "event",
|
||||
SourceID: 999,
|
||||
Name: "高数",
|
||||
Status: "existing",
|
||||
CanEmbed: true,
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
EmbeddedBy: &[]int{1}[0],
|
||||
},
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
EmbedHost: &hostStateID,
|
||||
},
|
||||
})
|
||||
modified := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 100,
|
||||
Source: "event",
|
||||
SourceID: 999,
|
||||
Name: "高数",
|
||||
Status: "existing",
|
||||
CanEmbed: true,
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 3, SlotEnd: 4}},
|
||||
},
|
||||
{StateID: 1, Source: "task_item", SourceID: 10, Name: "复习线代", Status: "pending"},
|
||||
})
|
||||
|
||||
changes := DiffScheduleState(original, modified)
|
||||
var unplaceChange *ScheduleChange
|
||||
for i := range changes {
|
||||
if changes[i].SourceID == 10 {
|
||||
unplaceChange = &changes[i]
|
||||
}
|
||||
}
|
||||
if unplaceChange == nil {
|
||||
t.Fatal("未找到 task_item 的 unplace 变更")
|
||||
}
|
||||
if unplaceChange.HostEventID != 999 {
|
||||
t.Errorf("嵌入移除 HostEventID 应为 999,实际 %d", unplaceChange.HostEventID)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DiffScheduleState: task_item move ====================
|
||||
|
||||
// TestDiff_MoveTaskItem 验证:task_item 移动时 OldHostEventID 和 HostEventID 分别对应旧/新位置宿主。
|
||||
func TestDiff_MoveTaskItem_NonEmbedToNonEmbed(t *testing.T) {
|
||||
days := defaultDays()
|
||||
|
||||
original := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 1, SlotStart: 1, SlotEnd: 2}},
|
||||
},
|
||||
})
|
||||
modified := buildTestState(days, []newagenttools.ScheduleTask{
|
||||
{
|
||||
StateID: 1,
|
||||
Source: "task_item",
|
||||
SourceID: 10,
|
||||
Name: "复习线代",
|
||||
Status: "existing",
|
||||
Slots: []newagenttools.TaskSlot{{Day: 2, SlotStart: 5, SlotEnd: 6}},
|
||||
},
|
||||
})
|
||||
|
||||
changes := DiffScheduleState(original, modified)
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("期望 1 个变更,实际 %d", len(changes))
|
||||
}
|
||||
c := changes[0]
|
||||
if c.Type != ChangeMove {
|
||||
t.Errorf("期望 ChangeMove,实际 %s", c.Type)
|
||||
}
|
||||
if c.HostEventID != 0 || c.OldHostEventID != 0 {
|
||||
t.Errorf("非嵌入移动两个 HostEventID 均应为 0,实际 %d/%d", c.OldHostEventID, c.HostEventID)
|
||||
}
|
||||
if len(c.OldCoords) != 2 || len(c.NewCoords) != 2 {
|
||||
t.Errorf("旧坐标 %d 个,新坐标 %d 个,均期望 2 个", len(c.OldCoords), len(c.NewCoords))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== resolveHostEventID ====================
|
||||
|
||||
func TestResolveHostEventID_NoEmbed(t *testing.T) {
|
||||
task := &newagenttools.ScheduleTask{StateID: 1, EmbedHost: nil}
|
||||
state := buildTestState(defaultDays(), nil)
|
||||
if got := resolveHostEventID(task, state); got != 0 {
|
||||
t.Errorf("无嵌入时应返回 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHostEventID_WithEmbed(t *testing.T) {
|
||||
hostID := 100
|
||||
task := &newagenttools.ScheduleTask{StateID: 1, EmbedHost: &hostID}
|
||||
state := buildTestState(defaultDays(), []newagenttools.ScheduleTask{
|
||||
{StateID: 100, Source: "event", SourceID: 999},
|
||||
})
|
||||
if got := resolveHostEventID(task, state); got != 999 {
|
||||
t.Errorf("期望宿主 SourceID=999,实际 %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user