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:
Losita
2026-04-06 23:15:54 +08:00
parent b1eb6bedf9
commit 2038185730
30 changed files with 1866 additions and 298 deletions

View File

@@ -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 {
// Placepending → 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)
}
// ==================== 辅助函数 ====================

View 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. 转换失败的 slotday_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,
}
}

View File

@@ -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 {

View File

@@ -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当前操作位置的宿主 EventID0 表示非嵌入)。
// Move新位置的宿主 EventID。
HostEventID int
// OldHostEventID: Move 时旧位置的宿主 EventID0 表示旧位置非嵌入)。
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

View File

@@ -0,0 +1,279 @@
package conv
import (
"testing"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// buildTestState 构造最小可用的 ScheduleStateDayMapping 让 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 天的 DayMappingday1=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)
}
}