Version: 0.9.11.dev.260409

后端:
1. conv 并行迁移与切流接线(旧目录下沉到 newAgent/conv)
   - 新建 newAgent/conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go,保持原有排程转换/预览/持久化能力;
   - 删除旧目录 conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go;
   - 更新 cmd/start.go 与 service/agentsvc/agent_newagent.go,ScheduleProvider/SchedulePersistor 与 preview 转换统一切到 newAgent/conv;
   - 删除旧 conv/schedule_state_test.go(迁移期测试文件清理)。
2. execute 循环上下文收口增强(历史归档 + 当前轮清晰化)
   - 更新 node/chat.go:仅在 completed 收口时写 execute_loop_closed marker,供后续 prompt 分层归档;
   - 更新 prompt/execute_context.go:msg1/msg2 升级为 V3,按收口标记拆分“历史归档 loop / 当前活跃 loop”,并增加 msg1 长度预算裁剪;
   - 更新 node/execute.go:新增 execute 置顶上下文同步(execution_context/current_step),在轮次开始与 next_plan 后即时刷新;
   - 更新 prompt/execute.go + execute_context.go:补齐“当前计划步骤 + done_when”强约束,禁止未达成判定时提前 next_plan。
3. 图路由与执行策略微调
   - 更新 graph/common_graph.go:Plan/Confirm 分支允许直接进入 Deliver 收口;
   - 更新 node/plan.go:always_execute 链路下补发计划摘要并写入历史,保证自动执行与手动确认文案一致;
   - 更新 model/common_state.go:DefaultMaxRounds 从 30 提升到 60。
4. 复合工具规划器重构(去重实现,复用 logic 公共能力)
   - 更新 tools/compound_tools.go:min_context_switch / spread_even 改为调用 backend/logic 规划器(PlanMinContextSwitchMoves / PlanEvenSpreadMoves);
   - 新增 state_id↔logic_id 映射层,统一入参与回填,避免工具层与规划层 ID 语义耦合;
   - 删除 compound_tools 内部重复的规划/归一化/分组/打分实现,减少第三份复制逻辑。
5. 同步调试与文档
   - 更新 newAgent/Log.txt 调试日志;
   - 新增 memory/记忆模块实施计划.md(面试优先版到产品可用版的落地路线)。
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-09 22:20:30 +08:00
parent 821c2cde5d
commit 574d44c332
17 changed files with 9470 additions and 4539 deletions

View File

@@ -0,0 +1,280 @@
package newagentconv
import (
"context"
"fmt"
baseconv "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// SchedulePersistorAdapter 实现 model.SchedulePersistor 接口。
// 组合 RepoManager调用 PersistScheduleChanges 持久化变更。
type SchedulePersistorAdapter struct {
manager *dao.RepoManager
}
// NewSchedulePersistorAdapter 创建持久化适配器。
func NewSchedulePersistorAdapter(manager *dao.RepoManager) *SchedulePersistorAdapter {
return &SchedulePersistorAdapter{manager: manager}
}
// PersistScheduleChanges 实现 model.SchedulePersistor 接口。
func (a *SchedulePersistorAdapter) PersistScheduleChanges(ctx context.Context, original, modified *newagenttools.ScheduleState, userID int) error {
return PersistScheduleChanges(ctx, a.manager, original, modified, userID)
}
// PersistScheduleChanges 将内存中的 ScheduleState 变更持久化到数据库。
//
// 职责边界:
// 1. 调用 DiffScheduleState 计算变更;
// 2. 在事务中逐个应用变更到数据库;
// 3. 全部成功或全部回滚,保证原子性。
func PersistScheduleChanges(
ctx context.Context,
manager *dao.RepoManager,
original *newagenttools.ScheduleState,
modified *newagenttools.ScheduleState,
userID int,
) error {
changes := DiffScheduleState(original, modified)
if len(changes) == 0 {
return nil
}
return manager.Transaction(ctx, func(txM *dao.RepoManager) error {
for _, change := range changes {
if err := applyScheduleChange(ctx, txM, change, userID); err != nil {
return fmt.Errorf("应用变更失败 [%s %s]: %w", change.Type, change.Name, err)
}
}
return nil
})
}
// applyScheduleChange 应用单个变更到数据库。
func applyScheduleChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
switch change.Type {
case ChangePlace:
return applyPlaceChange(ctx, manager, change, userID)
case ChangeMove:
return applyMoveChange(ctx, manager, change, userID)
case ChangeUnplace:
return applyUnplaceChange(ctx, manager, change, userID)
default:
return fmt.Errorf("未知变更类型: %s", change.Type)
}
}
// applyPlaceChange 应用放置变更。
func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
if len(change.NewCoords) == 0 {
return fmt.Errorf("place 变更缺少目标位置")
}
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)
}
}
// 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)
schedules := make([]model.Schedule, endSection-startSection+1)
for sec := startSection; sec <= endSection; sec++ {
schedules[sec-startSection] = model.Schedule{
UserID: userID,
Week: week,
DayOfWeek: dayOfWeek,
Section: sec,
EventID: change.SourceID,
}
}
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
return fmt.Errorf("创建 schedule 失败: %w", err)
}
}
}
return nil
}
// 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")
}
// 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 := baseconv.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)
}
}
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 {
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)
}
}
// ==================== 辅助函数 ====================
// intPtr 返回 int 指针,零值返回 nil。
func intPtr(v int) *int {
if v == 0 {
return nil
}
return &v
}
// groupCoordsByWeekDay 按周天分组坐标。
func groupCoordsByWeekDay(coords []SlotCoord) map[int]map[int][]SlotCoord {
result := make(map[int]map[int][]SlotCoord)
for _, coord := range coords {
if result[coord.Week] == nil {
result[coord.Week] = make(map[int][]SlotCoord)
}
result[coord.Week][coord.DayOfWeek] = append(result[coord.Week][coord.DayOfWeek], coord)
}
return result
}
// minMaxSection 返回坐标列表中的最小和最大节次。
func minMaxSection(coords []SlotCoord) (min, max int) {
if len(coords) == 0 {
return 0, 0
}
min, max = coords[0].Section, coords[0].Section
for _, c := range coords[1:] {
if c.Section < min {
min = c.Section
}
if c.Section > max {
max = c.Section
}
}
return
}

View File

@@ -0,0 +1,121 @@
package newagentconv
import (
"fmt"
"time"
"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 newagenttools.IsPendingTask(*t) {
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 不变suggested / 兼容建议态统一输出为 suggested。
if shouldMarkSuggestedInPreview(*t) {
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,
GeneratedAt: time.Now(),
}
}
// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。
//
// 规则说明:
// 1. 新语义下,显式 suggested 直接输出为建议态;
// 2. 兼容旧快照pending+Slots、existing+Duration>0 的 task_item 也继续按 suggested 输出;
// 3. 这样前端预览口径可以在迁移期保持稳定,不会因为状态枚举切换而抖动。
func shouldMarkSuggestedInPreview(t newagenttools.ScheduleTask) bool {
return newagenttools.IsSuggestedTask(t)
}

View File

@@ -0,0 +1,304 @@
package newagentconv
import (
"context"
"fmt"
"sort"
"time"
baseconv "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// ScheduleProvider 实现 model.ScheduleStateProvider 接口。
// 通过 DAO 层加载用户的日程和任务数据,调用 LoadScheduleState 构建内存状态。
//
// 职责边界:
// 1. 只负责"从 DB 查数据 + 调 LoadScheduleState 转换",不含业务逻辑;
// 2. 不负责缓存(由上层 Service 决定是否缓存);
// 3. 不负责 Diff 和持久化(由 Confirm 流程负责)。
type ScheduleProvider struct {
scheduleDAO *dao.ScheduleDAO
taskClassDAO *dao.TaskClassDAO
}
// NewScheduleProvider 创建 ScheduleProvider。
func NewScheduleProvider(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskClassDAO) *ScheduleProvider {
return &ScheduleProvider{
scheduleDAO: scheduleDAO,
taskClassDAO: taskClassDAO,
}
}
// LoadScheduleState 实现 model.ScheduleStateProvider 接口。
//
// 窗口策略:
// 1. 优先从 task class 的 StartDate/EndDate 推算规划窗口,覆盖粗排所需的完整日期范围;
// 2. task class 无日期信息时,降级到当前周 7 天(兼容普通查询场景)。
//
// 日程加载策略:对窗口内每周分别调用 GetUserWeeklySchedule 并合并结果。
func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*newagenttools.ScheduleState, error) {
// 1. 加载用户所有任务类(含 Items 预加载)。
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID)
if err != nil {
return nil, err
}
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
}
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
//
// 设计说明:
// 1. 负责:让粗排 / Execute 首次读取的 DayMapping 与本轮 task_class_ids 保持同一时间窗口;
// 2. 不负责:裁掉窗口内已有的 existing/suggested 阻塞物,这部分仍由日程加载主流程统一保留;
// 3. 失败策略:若 task_class_ids 为空,则退回全量加载,避免调用方额外分支。
func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
ctx context.Context,
userID int,
taskClassIDs []int,
) (*newagenttools.ScheduleState, error) {
if len(taskClassIDs) == 0 {
return p.LoadScheduleState(ctx, userID)
}
taskClasses, err := p.loadCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
if err != nil {
return nil, err
}
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
}
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
//
// 步骤说明:
// 1. 先根据传入 taskClasses 计算 DayMapping 窗口,保证粗排坐标能映射回 day_index
// 2. 若窗口无法从任务类日期推导,则退回当前周 7 天,兼容普通查询场景;
// 3. 再按窗口覆盖的周批量拉取 existing schedules与 taskClasses 一起交给 LoadScheduleState 统一建模。
func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
ctx context.Context,
userID int,
taskClasses []model.TaskClass,
) (*newagenttools.ScheduleState, error) {
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
if len(windowDays) == 0 {
var err error
windowDays, weeks, err = buildCurrentWeekWindow()
if err != nil {
return nil, err
}
}
// 2. 按周加载日程(含 Event + EmbeddedTask 预加载)。
var allSchedules []model.Schedule
for _, w := range weeks {
weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w)
if err != nil {
return nil, fmt.Errorf("加载用户周日程失败 week=%d: %w", w, err)
}
allSchedules = append(allSchedules, weekSchedules...)
}
// 3. 构建额外 item category 映射。
extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses)
// 4. 调用已有的 LoadScheduleState 构建内存状态。
return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
}
// buildWindowFromTaskClasses 从 task class 的 StartDate/EndDate 推算规划窗口。
//
// 返回值:
// - windowDays窗口内每天的 (week, dayOfWeek) 有序列表;
// - weeks窗口覆盖的周号去重、升序供按周加载日程使用
// - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。
func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
minWeek, minDay := 0, 0
maxWeek, maxDay := 0, 0
hasWindow := false
for _, tc := range taskClasses {
// 1. 先要求任务类具备完整且合法的起止日期,避免坏数据把整轮窗口拖坏。
// 2. 再逐条做绝对日期 -> 相对周/天转换;转换失败的任务类直接忽略,不影响其余合法任务类。
// 3. 只有至少一条任务类成功进入窗口后,才返回有效 DayMapping。
if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) {
continue
}
startWeek, startDay, err := baseconv.RealDateToRelativeDate(tc.StartDate.Format(baseconv.DateFormat))
if err != nil {
continue
}
endWeek, endDay, err := baseconv.RealDateToRelativeDate(tc.EndDate.Format(baseconv.DateFormat))
if err != nil {
continue
}
if !hasWindow || isRelativeDateBefore(startWeek, startDay, minWeek, minDay) {
minWeek, minDay = startWeek, startDay
}
if !hasWindow || isRelativeDateBefore(maxWeek, maxDay, endWeek, endDay) {
maxWeek, maxDay = endWeek, endDay
}
hasWindow = true
}
if !hasWindow {
return nil, nil
}
weeksSet := make(map[int]bool)
w, d := minWeek, minDay
for {
windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d})
weeksSet[w] = true
if w == maxWeek && d == maxDay {
break
}
d++
if d > 7 {
d = 1
w++
}
if w > maxWeek+1 { // 防止因日期转换异常导致无限循环
break
}
}
weeks = make([]int, 0, len(weeksSet))
for wk := range weeksSet {
weeks = append(weeks, wk)
}
sort.Ints(weeks)
return windowDays, weeks
}
// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。
func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
now := time.Now()
currentWeek, _, err := baseconv.RealDateToRelativeDate(now.Format(baseconv.DateFormat))
if err != nil {
return nil, nil, fmt.Errorf("解析当前日期失败: %w", err)
}
windowDays = make([]WindowDay, 7)
for i := 0; i < 7; i++ {
windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1}
}
return windowDays, []int{currentWeek}, nil
}
// isRelativeDateBefore 比较两个“相对周/天”坐标的先后关系。
func isRelativeDateBefore(leftWeek, leftDay, rightWeek, rightDay int) bool {
if leftWeek != rightWeek {
return leftWeek < rightWeek
}
return leftDay < rightDay
}
// loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。
func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID int) ([]model.TaskClass, error) {
basicClasses, err := p.taskClassDAO.GetUserTaskClasses(userID)
if err != nil {
return nil, fmt.Errorf("加载用户任务类失败: %w", err)
}
if len(basicClasses) == 0 {
return nil, nil
}
ids := make([]int, len(basicClasses))
for i, tc := range basicClasses {
ids[i] = tc.ID
}
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, ids)
if err != nil {
return nil, fmt.Errorf("加载完整任务类失败: %w", err)
}
return complete, nil
}
// loadCompleteTaskClassesByIDs 批量加载指定任务类(含 Items 预加载)。
func (p *ScheduleProvider) loadCompleteTaskClassesByIDs(
ctx context.Context,
userID int,
taskClassIDs []int,
) ([]model.TaskClass, 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)
}
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 {
knownItemIDs := make(map[int]bool)
for _, tc := range taskClasses {
for _, item := range tc.Items {
knownItemIDs[item.ID] = true
}
}
categories := make(map[int]string)
for _, s := range schedules {
if s.Event == nil || s.Event.Type != "task" || s.Event.RelID == nil {
continue
}
itemID := *s.Event.RelID
if !knownItemIDs[itemID] {
categories[itemID] = "任务"
}
}
return categories
}

View File

@@ -0,0 +1,594 @@
package newagentconv
import (
"sort"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
type WindowDay struct {
Week int
DayOfWeek int
}
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 newAgent 工具层可直接操作的 ScheduleState。
//
// 职责边界:
// 1. 只负责数据映射与状态归一,不做数据库读写;
// 2. 同时兼容三种“任务已落位”信号event.rel_id、schedules.embedded_task_id、task_item.embedded_time
// 3. 对嵌入课程任务优先判定为 existing避免误挂回 pending。
func LoadScheduleState(
schedules []model.Schedule,
taskClasses []model.TaskClass,
extraItemCategories map[int]string,
windowDays []WindowDay,
) *newagenttools.ScheduleState {
state := &newagenttools.ScheduleState{
Window: newagenttools.ScheduleWindow{
TotalDays: len(windowDays),
DayMapping: make([]newagenttools.DayMapping, len(windowDays)),
},
Tasks: make([]newagenttools.ScheduleTask, 0),
}
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
dayLookup := make(map[[2]int]int, len(windowDays))
for i, wd := range windowDays {
dayIndex := i + 1
state.Window.DayMapping[i] = newagenttools.DayMapping{
DayIndex: dayIndex,
Week: wd.Week,
DayOfWeek: wd.DayOfWeek,
}
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
}
// 2. 构建 task_item -> 分类名映射。
// 2.1 先放 extraItemCategories低优先级兜底
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
itemCategoryLookup := make(map[int]string)
for id, name := range extraItemCategories {
itemCategoryLookup[id] = name
}
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil && *tc.Name != "" {
catName = *tc.Name
}
for _, item := range tc.Items {
itemCategoryLookup[item.ID] = catName
}
}
// 3. 先把 schedules 聚合成 event 任务existing
type slotGroup struct {
week int
dayOfWeek int
sections []int
}
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
eventInfo := make(map[int]*model.ScheduleEvent)
for i := range schedules {
s := &schedules[i]
if s.Event == nil {
continue
}
if _, exists := eventInfo[s.EventID]; !exists {
eventInfo[s.EventID] = s.Event
}
groups := eventSlotMap[s.EventID]
found := false
for gi := range groups {
if groups[gi].week == s.Week && groups[gi].dayOfWeek == s.DayOfWeek {
groups[gi].sections = append(groups[gi].sections, s.Section)
found = true
break
}
}
if !found {
groups = append(groups, slotGroup{
week: s.Week,
dayOfWeek: s.DayOfWeek,
sections: []int{s.Section},
})
}
eventSlotMap[s.EventID] = groups
}
nextStateID := 1
eventStateIDs := make(map[int]int) // eventID -> stateID
for eventID, groups := range eventSlotMap {
event := eventInfo[eventID]
if event == nil {
continue
}
category := "课程"
if event.Type == "task" {
category = "任务"
if event.RelID != nil {
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
category = cat
}
}
}
locked := event.Type == "course" && !event.CanBeEmbedded
var slots []newagenttools.TaskSlot
for _, g := range groups {
if len(g.sections) == 0 {
continue
}
sort.Ints(g.sections)
start, end := g.sections[0], g.sections[0]
for _, sec := range g.sections[1:] {
if sec == end+1 {
end = sec
continue
}
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
start, end = sec, sec
}
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
}
sort.Slice(slots, func(i, j int) bool {
if slots[i].Day != slots[j].Day {
return slots[i].Day < slots[j].Day
}
return slots[i].SlotStart < slots[j].SlotStart
})
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "event",
SourceID: eventID,
Name: event.Name,
Category: category,
Status: "existing",
Locked: locked,
Slots: slots,
CanEmbed: event.CanBeEmbedded,
EventType: event.Type,
})
eventStateIDs[eventID] = stateID
nextStateID++
}
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
// 4.1 event.rel_id 占位:该 item 已有 task event
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
itemIDToTaskEventStateID := make(map[int]int)
for eventID, stateID := range eventStateIDs {
event := eventInfo[eventID]
if event == nil || event.Type != "task" || event.RelID == nil {
continue
}
itemIDToTaskEventStateID[*event.RelID] = stateID
}
itemIDToEmbedHostStateID := make(map[int]int)
for i := range schedules {
s := &schedules[i]
if s.EmbeddedTaskID == nil {
continue
}
hostStateID, ok := eventStateIDs[s.EventID]
if !ok {
continue
}
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
}
// 5. 处理 task_items
// 5.1 先消化 existingtask event / 课程嵌入 / embedded_time
// 5.2 剩余条目再按 status 判 pending。
itemStateIDs := make(map[int]int) // task_item_id -> stateID
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil && *tc.Name != "" {
catName = *tc.Name
}
defaultDuration := estimateTaskItemDuration(tc)
pendingCount := 0
for _, item := range tc.Items {
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
itemStateIDs[item.ID] = stateID
continue
}
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
hostSlots := []newagenttools.TaskSlot(nil)
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
hostSlots = cloneTaskSlots(hostTask.Slots)
}
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: taskItemName(item),
Category: catName,
Status: "existing",
Slots: hostSlots,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
continue
}
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: taskItemName(item),
Category: catName,
Status: "existing",
Slots: slots,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
continue
}
if !isTaskItemPending(item) {
continue
}
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: taskItemName(item),
Category: catName,
Status: "pending",
Duration: defaultDuration,
CategoryID: tc.ID,
TaskClassID: tc.ID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
pendingCount++
}
// 仅当该任务类仍有 pending 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)
}
}
// 6. 统一回填嵌入关系:
// 6.1 host 记录 EmbeddedBy
// 6.2 guest 记录 EmbedHost
// 6.3 guest 强制 existing + host slots防止“嵌入任务残留 pending”。
for i := range schedules {
s := &schedules[i]
if s.EmbeddedTaskID == nil {
continue
}
hostStateID, ok := eventStateIDs[s.EventID]
if !ok {
continue
}
hostTask := state.TaskByStateID(hostStateID)
itemID := *s.EmbeddedTaskID
guestStateID, ok := itemStateIDs[itemID]
if !ok {
// 兜底:只在 schedules 层看到嵌入关系taskClasses 不含该 item 时补建 guest。
name := ""
categoryID := 0
taskClassID := 0
if s.EmbeddedTask != nil {
name = taskItemName(*s.EmbeddedTask)
if s.EmbeddedTask.CategoryID != nil {
categoryID = *s.EmbeddedTask.CategoryID
taskClassID = *s.EmbeddedTask.CategoryID
}
}
category := "任务"
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
category = cat
}
hostSlots := []newagenttools.TaskSlot(nil)
if hostTask != nil {
hostSlots = cloneTaskSlots(hostTask.Slots)
}
guestStateID = nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: guestStateID,
Source: "task_item",
SourceID: itemID,
Name: name,
Category: category,
Status: "existing",
Slots: hostSlots,
CategoryID: categoryID,
TaskClassID: taskClassID,
})
itemStateIDs[itemID] = guestStateID
nextStateID++
}
if hostTask != nil && hostTask.EmbeddedBy == nil {
v := guestStateID
hostTask.EmbeddedBy = &v
}
guestTask := state.TaskByStateID(guestStateID)
if guestTask == nil {
continue
}
if guestTask.EmbedHost == nil {
v := hostStateID
guestTask.EmbedHost = &v
}
guestTask.Status = "existing"
if hostTask != nil && len(guestTask.Slots) == 0 {
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
}
// existing 的 task_item 不应再携带 Duration避免预览层误判成 suggested。
guestTask.Duration = 0
}
return state
}
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
//
// 说明:
// 1. status=nil 兼容历史数据,按“未安排”处理;
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
func isTaskItemPending(item model.TaskClassItem) bool {
if item.Status == nil {
return true
}
return *item.Status == model.TaskItemStatusUnscheduled
}
// estimateTaskItemDuration 估算 pending 任务默认时长。
//
// 规则:若任务类声明了 total_slots则按 total_slots / item_count 取整(最少 1
// 否则回退到 2 节。
func estimateTaskItemDuration(tc model.TaskClass) int {
duration := 2
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
duration = d
}
}
return duration
}
// taskItemName 读取任务项展示名。
func taskItemName(item model.TaskClassItem) string {
if item.Content == nil {
return ""
}
return *item.Content
}
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
// 若 target 为空、节次非法、或不在窗口内,返回 false。
func slotsFromTargetTime(
target *model.TargetTime,
dayLookup map[[2]int]int,
) ([]newagenttools.TaskSlot, bool) {
if target == nil {
return nil, false
}
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
return nil, false
}
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
if !ok {
return nil, false
}
return []newagenttools.TaskSlot{
{
Day: day,
SlotStart: target.SectionFrom,
SlotEnd: target.SectionTo,
},
}, true
}
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
type ScheduleChangeType string
const (
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
)
// SlotCoord 表示数据库坐标系中的单节槽位week/day_of_week/section
type SlotCoord struct {
Week int
DayOfWeek int
Section int
}
// ScheduleChange 描述单个任务在前后状态间的变化。
type ScheduleChange struct {
Type ScheduleChangeType
StateID int
Source string // "event" | "task_item"
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
EventType string // 仅 source=event 时有意义course/task
CategoryID int // 仅 source=task_item 时有意义
Name string
// place/move 的新位置(展开到逐节坐标)。
NewCoords []SlotCoord
// move/unplace 的旧位置(展开到逐节坐标)。
OldCoords []SlotCoord
// HostEventID变更后位置对应的宿主 event非嵌入为 0
HostEventID int
// OldHostEventIDmove 时旧位置对应的宿主 event非嵌入为 0
OldHostEventID int
}
// DiffScheduleState 比较 original 与 modified返回需要持久化的变更集合。
func DiffScheduleState(
original *newagenttools.ScheduleState,
modified *newagenttools.ScheduleState,
) []ScheduleChange {
if original == nil || modified == nil {
return nil
}
origTasks := indexByStateID(original)
var changes []ScheduleChange
for i := range modified.Tasks {
mod := &modified.Tasks[i]
orig := origTasks[mod.StateID]
wasPending := orig == nil || orig.Status == "pending"
hasSlots := len(mod.Slots) > 0
hadSlots := orig != nil && len(orig.Slots) > 0
switch {
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),
HostEventID: resolveHostEventID(mod, modified),
})
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),
HostEventID: resolveHostEventID(mod, modified),
OldHostEventID: resolveHostEventID(orig, original),
})
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),
HostEventID: resolveHostEventID(orig, original),
})
}
}
return changes
}
// indexByStateID 将任务列表按 state_id 建立索引。
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
for i := range state.Tasks {
m[state.Tasks[i].StateID] = &state.Tasks[i]
}
return m
}
// slotsEqual 判断两个压缩槽位切片是否完全一致。
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// cloneTaskSlots 深拷贝槽位切片。
func cloneTaskSlots(src []newagenttools.TaskSlot) []newagenttools.TaskSlot {
if len(src) == 0 {
return nil
}
dst := make([]newagenttools.TaskSlot, len(src))
copy(dst, src)
return dst
}
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
// 非嵌入任务或宿主不存在时返回 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 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
var coords []SlotCoord
for _, slot := range slots {
week, dow, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
for sec := slot.SlotStart; sec <= slot.SlotEnd; sec++ {
coords = append(coords, SlotCoord{Week: week, DayOfWeek: dow, Section: sec})
}
}
return coords
}