后端:
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
前端:无
仓库:无
448 lines
12 KiB
Go
448 lines
12 KiB
Go
package conv
|
||
|
||
import (
|
||
"sort"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||
)
|
||
|
||
// WindowDay represents a single day in the planning window.
|
||
type WindowDay struct {
|
||
Week int
|
||
DayOfWeek int
|
||
}
|
||
|
||
// ==================== Load: DB → State ====================
|
||
|
||
// LoadScheduleState builds a ScheduleState from database query results.
|
||
//
|
||
// Parameters:
|
||
// - schedules: existing Schedule records in the window (must preload Event and EmbeddedTask)
|
||
// - taskClasses: TaskClasses being scheduled (must preload Items)
|
||
// - extraItemCategories: optional TaskClassItem.ID → category name,
|
||
// for task events not belonging to the provided taskClasses
|
||
// - windowDays: ordered (week, day_of_week) pairs defining the planning window
|
||
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),
|
||
}
|
||
|
||
// --- Step 1: Build day mapping and lookup index ---
|
||
dayLookup := make(map[[2]int]int, len(windowDays))
|
||
for i, wd := range windowDays {
|
||
idx := i + 1
|
||
state.Window.DayMapping[i] = newagenttools.DayMapping{
|
||
DayIndex: idx,
|
||
Week: wd.Week,
|
||
DayOfWeek: wd.DayOfWeek,
|
||
}
|
||
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = idx
|
||
}
|
||
|
||
// --- Step 2: Build itemID → categoryName lookup ---
|
||
// extraItemCategories first (lower priority), then taskClasses overwrites (higher priority).
|
||
itemCategoryLookup := make(map[int]string)
|
||
for id, name := range extraItemCategories {
|
||
itemCategoryLookup[id] = name
|
||
}
|
||
for _, tc := range taskClasses {
|
||
catName := "任务"
|
||
if tc.Name != nil {
|
||
catName = *tc.Name
|
||
}
|
||
for _, item := range tc.Items {
|
||
itemCategoryLookup[item.ID] = catName
|
||
}
|
||
}
|
||
|
||
// --- Step 3: Process existing schedules → existing tasks ---
|
||
type slotGroup struct {
|
||
week int
|
||
dayOfWeek int
|
||
sections []int
|
||
}
|
||
|
||
eventSlotMap := make(map[int][]slotGroup) // eventID → groups
|
||
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]
|
||
|
||
// Category
|
||
category := "课程"
|
||
if event.Type == "task" {
|
||
category = "任务"
|
||
if event.RelID != nil {
|
||
if cat, ok := itemCategoryLookup[*event.RelID]; ok {
|
||
category = cat
|
||
}
|
||
}
|
||
}
|
||
|
||
// Locked: course + not embeddable
|
||
locked := event.Type == "course" && !event.CanBeEmbedded
|
||
|
||
// Compress sections into slot ranges
|
||
var slots []newagenttools.TaskSlot
|
||
for _, g := range groups {
|
||
sort.Ints(g.sections)
|
||
start, end := g.sections[0], g.sections[0]
|
||
for _, sec := range g.sections[1:] {
|
||
if sec == end+1 {
|
||
end = sec
|
||
} else {
|
||
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 slots by day, then slot_start
|
||
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++
|
||
}
|
||
|
||
// --- Step 4: Process pending task items → pending tasks ---
|
||
itemStateIDs := make(map[int]int) // TaskClassItem.ID → stateID
|
||
|
||
for _, tc := range taskClasses {
|
||
catName := "任务"
|
||
if tc.Name != nil {
|
||
catName = *tc.Name
|
||
}
|
||
catID := tc.ID
|
||
|
||
pendingCount := 0
|
||
for _, item := range tc.Items {
|
||
if item.Status == nil || *item.Status != model.TaskItemStatusUnscheduled {
|
||
continue
|
||
}
|
||
|
||
duration := 2
|
||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
||
duration = d
|
||
}
|
||
}
|
||
|
||
name := ""
|
||
if item.Content != nil {
|
||
name = *item.Content
|
||
}
|
||
|
||
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,
|
||
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)
|
||
}
|
||
}
|
||
|
||
// --- Step 5: Resolve embed relationships ---
|
||
// Extend itemStateIDs with existing task events (rel_id → stateID)
|
||
for eventID, stateID := range eventStateIDs {
|
||
event := eventInfo[eventID]
|
||
if event.Type == "task" && event.RelID != nil {
|
||
if _, exists := itemStateIDs[*event.RelID]; !exists {
|
||
itemStateIDs[*event.RelID] = stateID
|
||
}
|
||
}
|
||
}
|
||
|
||
for i := range schedules {
|
||
s := &schedules[i]
|
||
if s.EmbeddedTaskID == nil || s.Event == nil {
|
||
continue
|
||
}
|
||
hostStateID, ok := eventStateIDs[s.EventID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
guestStateID, ok := itemStateIDs[*s.EmbeddedTaskID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
// Host: record which guest is embedded
|
||
hostTask := state.TaskByStateID(hostStateID)
|
||
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||
v := guestStateID
|
||
hostTask.EmbeddedBy = &v
|
||
}
|
||
|
||
// Guest: record which host it's embedded into, copy host's slots
|
||
guestTask := state.TaskByStateID(guestStateID)
|
||
if guestTask != nil && guestTask.EmbedHost == nil {
|
||
v := hostStateID
|
||
guestTask.EmbedHost = &v
|
||
}
|
||
}
|
||
|
||
return state
|
||
}
|
||
|
||
// ==================== Diff: State comparison ====================
|
||
|
||
// ScheduleChangeType classifies the type of state change.
|
||
type ScheduleChangeType string
|
||
|
||
const (
|
||
ChangePlace ScheduleChangeType = "place" // pending → placed
|
||
ChangeMove ScheduleChangeType = "move" // slots relocated
|
||
ChangeUnplace ScheduleChangeType = "unplace" // placed → pending
|
||
)
|
||
|
||
// SlotCoord is an individual section position in DB coordinates (week, day_of_week, section).
|
||
type SlotCoord struct {
|
||
Week int
|
||
DayOfWeek int
|
||
Section int
|
||
}
|
||
|
||
// ScheduleChange represents a single task change between original and modified state.
|
||
type ScheduleChange struct {
|
||
Type ScheduleChangeType
|
||
StateID int
|
||
Source string // "event" | "task_item"
|
||
SourceID int // ScheduleEvent.ID or TaskClassItem.ID
|
||
EventType string // "course" | "task" (source=event only)
|
||
CategoryID int // source=task_item only
|
||
Name string
|
||
|
||
// For place/move: new slot positions (expanded to individual sections)
|
||
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,
|
||
// returning the changes that need to be persisted to the database.
|
||
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 {
|
||
// 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),
|
||
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),
|
||
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),
|
||
HostEventID: resolveHostEventID(orig, original),
|
||
})
|
||
}
|
||
}
|
||
|
||
return changes
|
||
}
|
||
|
||
// indexByStateID creates a map of stateID → *ScheduleTask.
|
||
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 compares two TaskSlot slices for equality.
|
||
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
|
||
}
|
||
|
||
// 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
|
||
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
|
||
}
|