Files
smartmate/backend/conv/schedule_state.go
Losita 2038185730 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
前端:无
仓库:无
2026-04-06 23:15:54 +08:00

448 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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当前操作位置的宿主 EventID0 表示非嵌入)。
// Move新位置的宿主 EventID。
HostEventID int
// OldHostEventID: Move 时旧位置的宿主 EventID0 表示旧位置非嵌入)。
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
}