diff --git a/backend/conv/schedule_preview.go b/backend/conv/schedule_preview.go index 1282992..969cded 100644 --- a/backend/conv/schedule_preview.go +++ b/backend/conv/schedule_preview.go @@ -60,7 +60,7 @@ func ScheduleStateToPreview( } // Status 映射:existing 不变,pending(有位置)= suggested。 - if t.Status == "pending" { + if shouldMarkSuggestedInPreview(*t) { entry.Status = "suggested" } else { entry.Status = "existing" @@ -109,3 +109,19 @@ func ScheduleStateToPreview( GeneratedAt: time.Now(), } } + +// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。 +// +// 规则说明: +// 1. pending 任务在预览语义中属于“建议态”; +// 2. source=task_item 且 Duration>0 的任务来自待排任务池, +// 即使工具层在 place 后把它改成 existing,预览层也要继续按 suggested 输出。 +func shouldMarkSuggestedInPreview(t newagenttools.ScheduleTask) bool { + if t.Status == "pending" { + return true + } + if t.Source == "task_item" && t.Duration > 0 { + return true + } + return false +} diff --git a/backend/conv/schedule_state.go b/backend/conv/schedule_state.go index 2ed5b67..40e6364 100644 --- a/backend/conv/schedule_state.go +++ b/backend/conv/schedule_state.go @@ -7,22 +7,18 @@ import ( newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" ) -// WindowDay represents a single day in the planning window. +// WindowDay 表示排课窗口中的一天(相对周 + 周几)。 type WindowDay struct { Week int DayOfWeek int } -// ==================== Load: DB → State ==================== - -// LoadScheduleState builds a ScheduleState from database query results. +// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 newAgent 工具层可直接操作的 ScheduleState。 // -// 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 +// 职责边界: +// 1. 只负责数据映射与状态归一,不做数据库读写; +// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time; +// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。 func LoadScheduleState( schedules []model.Schedule, taskClasses []model.TaskClass, @@ -37,27 +33,28 @@ func LoadScheduleState( Tasks: make([]newagenttools.ScheduleTask, 0), } - // --- Step 1: Build day mapping and lookup index --- + // 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。 dayLookup := make(map[[2]int]int, len(windowDays)) for i, wd := range windowDays { - idx := i + 1 + dayIndex := i + 1 state.Window.DayMapping[i] = newagenttools.DayMapping{ - DayIndex: idx, + DayIndex: dayIndex, Week: wd.Week, DayOfWeek: wd.DayOfWeek, } - dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = idx + dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex } - // --- Step 2: Build itemID → categoryName lookup --- - // extraItemCategories first (lower priority), then taskClasses overwrites (higher priority). + // 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 { + if tc.Name != nil && *tc.Name != "" { catName = *tc.Name } for _, item := range tc.Items { @@ -65,14 +62,13 @@ func LoadScheduleState( } } - // --- Step 3: Process existing schedules → existing tasks --- + // 3. 先把 schedules 聚合成 event 任务(existing)。 type slotGroup struct { week int dayOfWeek int sections []int } - - eventSlotMap := make(map[int][]slotGroup) // eventID → groups + eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位 eventInfo := make(map[int]*model.ScheduleEvent) for i := range schedules { @@ -104,46 +100,45 @@ func LoadScheduleState( } nextStateID := 1 - eventStateIDs := make(map[int]int) // eventID → stateID - + eventStateIDs := make(map[int]int) // eventID -> stateID for eventID, groups := range eventSlotMap { event := eventInfo[eventID] + if event == nil { + continue + } - // Category category := "课程" if event.Type == "task" { category = "任务" if event.RelID != nil { - if cat, ok := itemCategoryLookup[*event.RelID]; ok { + if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" { 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 { + 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 - } 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 + 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 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 @@ -168,32 +163,91 @@ func LoadScheduleState( nextStateID++ } - // --- Step 4: Process pending task items → pending tasks --- - itemStateIDs := make(map[int]int) // TaskClassItem.ID → stateID + // 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 先消化 existing(task 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 { + if tc.Name != nil && *tc.Name != "" { catName = *tc.Name } - catID := tc.ID - + defaultDuration := estimateTaskItemDuration(tc) pendingCount := 0 + for _, item := range tc.Items { - if item.Status == nil || *item.Status != model.TaskItemStatusUnscheduled { + if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok { + itemStateIDs[item.ID] = stateID 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 + 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 } - name := "" - if item.Content != nil { - name = *item.Content + 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 @@ -201,11 +255,11 @@ func LoadScheduleState( StateID: stateID, Source: "task_item", SourceID: item.ID, - Name: name, + Name: taskItemName(item), Category: catName, Status: "pending", - Duration: duration, - CategoryID: catID, + Duration: defaultDuration, + CategoryID: tc.ID, TaskClassID: tc.ID, }) itemStateIDs[item.ID] = stateID @@ -213,7 +267,7 @@ func LoadScheduleState( pendingCount++ } - // 有待安排 item 的任务类才暴露约束给 LLM。 + // 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。 if pendingCount > 0 { meta := newagenttools.TaskClassMeta{ ID: tc.ID, @@ -241,92 +295,181 @@ func LoadScheduleState( } } - // --- 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 - } - } - } - + // 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 || s.Event == nil { + if s.EmbeddedTaskID == nil { continue } hostStateID, ok := eventStateIDs[s.EventID] if !ok { continue } - guestStateID, ok := itemStateIDs[*s.EmbeddedTaskID] + hostTask := state.TaskByStateID(hostStateID) + itemID := *s.EmbeddedTaskID + + guestStateID, ok := itemStateIDs[itemID] if !ok { - continue + // 兜底:只在 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++ } - // 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 { + 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 } -// ==================== Diff: State comparison ==================== +// 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 +} -// ScheduleChangeType classifies the type of state change. +// 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 → placed - ChangeMove ScheduleChangeType = "move" // slots relocated - ChangeUnplace ScheduleChangeType = "unplace" // placed → pending + ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置 + ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动 + ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending ) -// SlotCoord is an individual section position in DB coordinates (week, day_of_week, section). +// SlotCoord 表示数据库坐标系中的单节槽位(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. +// ScheduleChange 描述单个任务在前后状态间的变化。 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 + SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID + EventType string // 仅 source=event 时有意义(course/task) + CategoryID int // 仅 source=task_item 时有意义 Name string - // For place/move: new slot positions (expanded to individual sections) + // place/move 的新位置(展开到逐节坐标)。 NewCoords []SlotCoord - // For move/unplace: old slot positions + // move/unplace 的旧位置(展开到逐节坐标)。 OldCoords []SlotCoord - // HostEventID: source=task_item 嵌入路径时,宿主课程的 schedule_event.id。 - // Place/Unplace:当前操作位置的宿主 EventID(0 表示非嵌入)。 - // Move:新位置的宿主 EventID。 + // HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。 HostEventID int - // OldHostEventID: Move 时旧位置的宿主 EventID(0 表示旧位置非嵌入)。 + // OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。 OldHostEventID int } -// DiffScheduleState compares original and modified ScheduleState, -// returning the changes that need to be persisted to the database. +// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。 func DiffScheduleState( original *newagenttools.ScheduleState, modified *newagenttools.ScheduleState, @@ -347,7 +490,6 @@ func DiffScheduleState( hadSlots := orig != nil && len(orig.Slots) > 0 switch { - // Place: pending → has slots case wasPending && hasSlots: changes = append(changes, ScheduleChange{ Type: ChangePlace, @@ -360,8 +502,6 @@ func DiffScheduleState( 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, @@ -376,8 +516,6 @@ func DiffScheduleState( HostEventID: resolveHostEventID(mod, modified), OldHostEventID: resolveHostEventID(orig, original), }) - - // Unplace: had slots → no slots case hadSlots && !hasSlots: changes = append(changes, ScheduleChange{ Type: ChangeUnplace, @@ -395,7 +533,7 @@ func DiffScheduleState( return changes } -// indexByStateID creates a map of stateID → *ScheduleTask. +// 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 { @@ -404,7 +542,7 @@ func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.S return m } -// slotsEqual compares two TaskSlot slices for equality. +// slotsEqual 判断两个压缩槽位切片是否完全一致。 func slotsEqual(a, b []newagenttools.TaskSlot) bool { if len(a) != len(b) { return false @@ -417,9 +555,18 @@ func slotsEqual(a, b []newagenttools.TaskSlot) bool { return true } -// resolveHostEventID 从任务的 EmbedHost 字段反查宿主的 ScheduleEvent.ID。 -// 用于 DiffScheduleState 在生成 ScheduleChange 时记录嵌入路径的宿主 EventID。 -// 若任务非嵌入(EmbedHost == nil)或宿主不存在,返回 0。 +// 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 @@ -431,7 +578,7 @@ func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.S return host.SourceID } -// expandToCoords converts compressed TaskSlots to individual SlotCoords. +// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。 func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord { var coords []SlotCoord for _, slot := range slots { diff --git a/backend/newAgent/HANDOFF_粗排修复与Prompt重构.md b/backend/newAgent/HANDOFF_粗排修复与Prompt重构.md new file mode 100644 index 0000000..70d316c --- /dev/null +++ b/backend/newAgent/HANDOFF_粗排修复与Prompt重构.md @@ -0,0 +1,322 @@ +# Handoff + +以下内容可直接交给下一位助理继续做。 + +## 目标 + +当前有两条主线要继续推进: + +1. 粗排算法修复与链路纠偏 +目标:粗排完成后,不应该再把 LLM 引导到“手动一个个 `place` 补洞”。如果粗排后仍有 `pending`,按当前业务理解,这属于异常,应直接终止并报错,而不是继续优化或补排。 + +2. `execute` 上下文瘦身 + 可插拔 prompt 重构 +目标:把现在的“消息流水账堆砌”改成“结构化执行简报”,并且 prompt 不能写死成排程专用,要能复用于排程、加任务、学习计划等不同任务域。 + +## 用户已经明确确认的业务结论 + +- `always_execute`、后端是否自动放行、是否写库,这些是后端执行层语义,不应写进 prompt。 +- LLM 只需要按统一协议产出 `continue / confirm / ask_user / done / abort` 这类动作;后端怎么处理是后端自己的事。 +- 对排程场景,LLM 的主要职责是“粗排后的优化器”,不是“粗排补洞工”。 +- 如果“粗排完成后仍有 pending 任务”,这不是要让 LLM 手工 `place` 的正常状态,而是异常状态。 +- prompt 需要明显的文字引导,必须有编号和子编号,让 LLM 每轮都收到一份规范文本。 +- prompt 必须是可插拔的,不能写死成“排程优化”专用。 + +## 已经完成的改动 + +- 已修复“同一轮 user message 重复写入上下文”的问题。 +实现位置:`backend/newAgent/node/chat.go` +改动点:`handleChatResume` 不再重复 `AppendHistory(schema.UserMessage(...))`,现在 user message 只在 service 层统一写入一次。 + +- 已经给 `execute` 节点加了完整上下文调试打点。 +实现位置:`backend/newAgent/node/execute.go` +关键函数:`formatExecuteLLMMessagesForDebug` + +- 之前已经做过一轮粗排结果接入修复:`makeRoughBuildFunc` 改为使用 `HybridScheduleWithPlanMultiFunc` 的 `entries` 结果,而不是只看 `[]TaskClassItem`。 +实现位置:`backend/service/agentsvc/agent_newagent.go` + +## 当前上下文链路的真实现状 + +`execute` 真正喂给 LLM 的消息来自: + +- `backend/newAgent/node/execute.go` +- `backend/newAgent/prompt/execute.go` +- `backend/newAgent/prompt/base.go` + +当前拼装顺序是: + +- `system`:基础 persona + execute 阶段规则 +- `system`:工具摘要 +- `history`:完整历史消息 +- `system`:pinned blocks +- `user`:运行时执行提示词 + +这套链路的核心问题不是“少了什么”,而是“保留了太多不该保留的东西”。 + +## 已确认的上下文膨胀问题 + +基于用户提供的第 13 轮上下文样本,当前冗余主要有这些: + +- 大型 `tool result` 长期保留。 +典型是 `get_overview`、`list_tasks`、`find_free` 的超长结果被反复塞进 history。 + +- 同工具同参数的重复查询长期保留。 +例如 `find_free(duration=2)` 连续多次查询,主体内容几乎相同;`list_tasks(all)` 与 `get_overview` 也重复大量信息。 + +- 大量 assistant 过程性话术进入 history。 +例如“我先查一下”“我需要先获取”“我将安排……请确认”这类文本,对后续决策价值很低,却持续吃 token。 + +- 失败回合被原样保留。 +例如 `place` 缺 `task_id`、`find_free` 缺 `duration` 的失败记录,不需要完整原文链路,只需要摘要化保留“最近失败模式”。 + +- 指令层重复。 +`renderStateSummary`、pinned blocks、运行时 user prompt 存在明显重叠。 + +- `newAgent` 目前没有接旧链路那套历史 token budget 裁剪。 +对照位置: + - 新链路:`backend/service/agentsvc/agent_newagent.go` + - 旧链路:`backend/service/agentsvc/agent.go` + - token budget 工具:`backend/pkg/token_budget.go` + +## 当前排程链路里最需要纠偏的错误引导 + +当前这段逻辑已经不符合用户现在确认的业务前提: + +- `backend/newAgent/node/rough_build.go` + +这里现在会在粗排后写入一段 pinned 文本,大意是: + +- 如果还有 `pending`,就让 LLM 去 `get_overview/find_free/place` +- 重复 place,直到 pending 归零 + +这段引导现在应视为错误业务语义。下一位助理需要重点改掉它。 + +## 粗排算法主线的交接意见 + +下一位助理要继续查两件事: + +- 粗排算法本体是否真的仍会漏排。 +重点排查: + - `makeRoughBuildFunc` + - `RunRoughBuildNode` + - `placements` 写入 `ScheduleState` 后,是否所有目标任务都应有初始落位 + +- 如果业务上“粗排不应漏排”已经成立,那么链路要改成: + - 粗排完成且 `pending > 0`:直接异常结束 + - 不再把 LLM 引导成“手工补排” + - 最好在执行层支持 `abort` 语义,而不是让模型继续乱试 + +## prompt 重构主线的交接意见 + +用户已经认可的新方向是:把 prompt 改成“通用执行内核 + 可插拔领域模块 + 当前任务简报”。 + +推荐的 3-message 结构如下。 + +### 第一条消息:通用执行内核 + +职责: + +- 定义 agent 身份 +- 定义通用规则 +- 定义通用动作协议 +- 提供最小必要的 JSON 示例 + +### 第二条消息:领域模块 + +职责: + +- 注入当前领域名称、职责边界、目标、非目标 +- 注入领域工具简表 +- 注入领域硬约束、软目标 +- 注入异常定义与完成判定 + +### 第三条消息:运行时任务简报 + +职责: + +- 给出用户原始目标与最新补充 +- 给出当前实例级约束 +- 给出最新状态快照 +- 给出最近操作摘要 +- 给出上一次工具调用结果 +- 给出本轮目标 + +## 用户已经认可的 prompt 设计原则 + +- 必须保留 JSON 示例,否则 LLM 容易不会按协议输出。 +- prompt 必须有显式编号和子编号,例如 `1. / 1.1 / 2.1`。 +- prompt 不能写死成排程专用。 +- 排程只是一个领域模块示例,不是通用内核的一部分。 +- 对排程领域来说,应明确: + - 这是“粗排后的优化器” + - 不是“补排器” + - `pending > 0` 是异常条件,不是待办事项 +- 对不同领域,应通过占位参数注入,不要把具体业务写进通用层。 + +## 已产出的可插拔 prompt 方案要点 + +建议最终落地成这三层: + +### 通用执行内核 + +- 身份 +- 通用规则 +- 通用动作协议 +- 输出字段定义 +- 最小 JSON 示例 + +### 领域模块 + +- `domain_name` +- `task_type` +- `domain_primary_responsibility` +- `domain_out_of_scope` +- `domain_goals` +- `domain_non_goals` +- `tool_catalog_brief` +- `tool_usage_rules` +- `tool_required_args_rules` +- `tool_common_failures` +- `hard_constraints` +- `soft_objectives` +- `abort_conditions` +- `abort_handling_rules` +- `done_conditions` +- `abort_output_conditions` + +### 运行时任务简报 + +- `original_user_goal` +- `latest_user_instruction` +- `current_effective_goal` +- `current_phase` +- `current_round` +- `instance_constraints` +- `latest_state_summary` +- `latest_state_delta` +- `latest_risks` +- `recent_operation_summary` +- `recent_failure_patterns` +- `last_tool_name` +- `last_tool_arguments_summary` +- `last_tool_result_summary` +- `last_tool_success` +- `last_tool_state_change` +- `last_tool_takeaway` +- `current_round_goal` +- `recommended_next_action` + +## 排程领域的具体模块语义 + +如果当前领域是“粗排后的排程优化”,建议这样填: + +- `domain_name = schedule_optimization` +- `domain_primary_responsibility = 在粗排结果基础上优化排程质量` +- `domain_out_of_scope = 手工补排粗排遗漏任务` +- `domain_goals = 更均匀、更符合学习规律、更平衡每日负载` +- `domain_non_goals = 把 pending 任务一个个 place 进去` +- `abort_conditions = 粗排完成后仍有 pending 任务` +- `abort_handling_rules = 不再继续优化,不再 place,直接 abort` +- `done_conditions = 方案满足硬约束且整体分布合理` + +## 代码层建议的实施顺序 + +建议下一位助理按这个顺序做,风险最低: + +1. 先改粗排后 pinned 引导 +重点文件:`backend/newAgent/node/rough_build.go` +目标:删掉“pending 继续 place”的提示,换成“pending 是异常”的提示。 + +2. 再补 `abort` 动作语义 +重点文件: + - `backend/newAgent/node/execute.go` + - 相关 decision model 定义文件 + - 可能涉及 deliver / graph 分支 +目标:让 LLM 可以正规地终止异常流程,而不是只能 continue / done / ask_user / confirm。 + +3. 再做 prompt 结构重构 +重点文件: + - `backend/newAgent/prompt/base.go` + - `backend/newAgent/prompt/execute.go` + - 如有必要,可新增一个领域模块文件 +目标:把目前“system/tool/history/pinned/runtime prompt”重组为“通用内核 + 领域模块 + 任务简报”。 + +4. 最后再做历史瘦身 +目标: + - 同工具同参数结果只保留最近一份原文 + - 更早历史改摘要 + - assistant 废话不入 history + - 失败模式摘要化 + - 必要时接入 token budget + +## 关于历史瘦身,已达成的结论 + +下一位助理可以直接照这个原则做: + +- 不再把几十条 `assistant/tool` 原始流水账直接喂给模型 +- 把历史改成“状态快照 + 最近摘要 + 上一次结果 + 本轮目标” +- `tool result` 只保留: + - 最新一条原文 + - 更早的同类结果摘要 +- 重复查询要压缩: + - 同工具同参数只保留最新一条 +- assistant 过程话术要剔除: + - “我先查一下”“我将继续……”之类原则上不入模型历史 +- 保留最近失败模式: + - 例如 `place` 缺 `task_id` + - 例如 `find_free` 缺 `duration` + +## 测试与验证注意事项 + +- 运行 `go test` 后,必须清理项目根目录 `.gocache`。 +- 当前环境可能会因为网络限制导致 `go test` 拉依赖失败;之前已经出现过这种情况。 +- 项目要求: + - 注释、接口文案、说明、评审反馈都用中文 + - 文件编码 UTF-8(无 BOM) + - 不要把 agent 改回写库逻辑;当前用户明确要求 agent 操作只写内存,不写数据库 +- 代码中若改动复杂逻辑,注释要同步更新,且注释必须用中文 + +## 关键文件清单 + +- 执行节点与上下文打点:`backend/newAgent/node/execute.go` +- prompt 拼装基础:`backend/newAgent/prompt/base.go` +- execute prompt:`backend/newAgent/prompt/execute.go` +- 粗排节点:`backend/newAgent/node/rough_build.go` +- graph 节点装配:`backend/newAgent/node/agent_nodes.go` +- newAgent service 入口:`backend/service/agentsvc/agent_newagent.go` +- 旧链路 token budget 参考:`backend/service/agentsvc/agent.go` +- token budget 工具:`backend/pkg/token_budget.go` + +## 一句话总结给下一位助理 + +当前要做的,不是继续 patch 某个 prompt 文案,而是同时完成两件事: + +- 把“粗排后 pending 还让 LLM 手工补排”的错误业务语义彻底清掉 +- 把 `execute` 从“消息流水账喂模”重构成“通用执行内核 + 可插拔领域模块 + 运行时任务简报”的结构化 prompt + +## TODO Checklist + +### 粗排算法与异常语义 + +- [ ] 确认粗排算法本体是否真的会漏排 +- [ ] 确认 `placements` 写入 `ScheduleState` 后是否所有目标任务都已有初始落位 +- [ ] 删除 `rough_build` 节点里“pending 继续 place”的错误提示 +- [ ] 改成“粗排后 pending > 0 即异常”的提示语义 +- [ ] 在执行决策层补齐 `abort` 动作语义 + +### Prompt 重构 + +- [ ] 抽出通用执行内核 prompt +- [ ] 抽出领域模块 prompt +- [ ] 抽出运行时任务简报拼装逻辑 +- [ ] 保留最小必要 JSON 示例 +- [ ] 清除后端执行层语义对 LLM 的干扰 +- [ ] 让排程领域以模块方式接入,而不是写死在内核 + +### 历史瘦身 + +- [ ] 同工具同参数仅保留最新一条原文 +- [ ] 更早同类结果改为摘要 +- [ ] assistant 过程性废话不再进入模型历史 +- [ ] 最近失败模式摘要化保留 +- [ ] 必要时接入 token budget + diff --git a/backend/newAgent/graph/common_graph.go b/backend/newAgent/graph/common_graph.go index 67a4171..cc20088 100644 --- a/backend/newAgent/graph/common_graph.go +++ b/backend/newAgent/graph/common_graph.go @@ -189,6 +189,15 @@ func branchAfterPlan(_ context.Context, st *newagentmodel.AgentGraphState) (stri if flowState.Phase == newagentmodel.PhaseWaitingConfirm { return NodeConfirm, nil } + if flowState.Phase == newagentmodel.PhaseExecuting { + if flowState.NeedsRoughBuild && st.Deps.RoughBuildFunc != nil { + return NodeRoughBuild, nil + } + return NodeExecute, nil + } + if flowState.Phase == newagentmodel.PhaseDone { + return NodeDeliver, nil + } return NodePlan, nil } diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index 9d33315..c3acd78 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -147,10 +147,12 @@ func (d *AgentGraphDeps) ResolveDeliverClient() *newagentllm.Client { // 3. Request:当前这次请求的轻量输入; // 4. Deps:graph/node 层真正依赖的可插拔能力。 type AgentGraphRunInput struct { - RuntimeState *AgentRuntimeState - ConversationContext *ConversationContext - Request AgentGraphRequest - Deps AgentGraphDeps + RuntimeState *AgentRuntimeState + ConversationContext *ConversationContext + ScheduleState *newagenttools.ScheduleState + OriginalScheduleState *newagenttools.ScheduleState + Request AgentGraphRequest + Deps AgentGraphDeps } // AgentGraphState 是 graph 内部真正流转的运行态容器。 @@ -171,10 +173,12 @@ type AgentGraphState struct { // NewAgentGraphState 把入口参数整理成 graph 内部状态。 func NewAgentGraphState(input AgentGraphRunInput) *AgentGraphState { st := &AgentGraphState{ - RuntimeState: input.RuntimeState, - ConversationContext: input.ConversationContext, - Request: input.Request, - Deps: input.Deps, + RuntimeState: input.RuntimeState, + ConversationContext: input.ConversationContext, + Request: input.Request, + Deps: input.Deps, + ScheduleState: input.ScheduleState, + OriginalScheduleState: input.OriginalScheduleState, } st.Request.Normalize() st.EnsureRuntimeState() @@ -238,6 +242,12 @@ func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*newagenttoo return nil, nil } if s.ScheduleState != nil { + if s.OriginalScheduleState == nil { + // 1. 兼容老快照:历史 Redis 快照里可能还没带 original_state。 + // 2. 当前阶段虽然已经不落库,但后续若重新接回 diff 链,仍需要稳定的原始快照。 + // 3. 因此这里在“已恢复出 ScheduleState、但缺 original”时补一份克隆兜底。 + s.OriginalScheduleState = s.ScheduleState.Clone() + } return s.ScheduleState, nil } if s.Deps.ScheduleProvider == nil { diff --git a/backend/newAgent/model/state_store.go b/backend/newAgent/model/state_store.go index d9a7d21..4b90dca 100644 --- a/backend/newAgent/model/state_store.go +++ b/backend/newAgent/model/state_store.go @@ -14,8 +14,10 @@ import ( // 3. 不保存 Deps(依赖注入,每次由 Service 层重建); // 4. 不保存 ToolSchemas(每次请求由 Service 层重新注入)。 type AgentStateSnapshot struct { - RuntimeState *AgentRuntimeState `json:"runtime_state"` - ConversationContext *ConversationContext `json:"conversation_context"` + RuntimeState *AgentRuntimeState `json:"runtime_state"` + ConversationContext *ConversationContext `json:"conversation_context"` + ScheduleState *newagenttools.ScheduleState `json:"schedule_state,omitempty"` + OriginalScheduleState *newagenttools.ScheduleState `json:"original_schedule_state,omitempty"` } // AgentStateStore 定义 agent 状态持久化的最小接口。 diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index 033324a..b4d1111 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -85,6 +85,9 @@ func (n *AgentNodes) Confirm(ctx context.Context, st *newagentmodel.AgentGraphSt }, ); err != nil { return nil, err + } else if st.Deps.WriteSchedulePreview != nil && st.ScheduleState == nil { + flowState := st.EnsureFlowState() + log.Printf("[WARN] deliver: schedule state is nil, skip preview write chat=%s", flowState.ConversationID) } saveAgentState(ctx, st) @@ -111,6 +114,7 @@ func (n *AgentNodes) Plan(ctx context.Context, st *newagentmodel.AgentGraphState Client: st.Deps.ResolvePlanClient(), ChunkEmitter: st.EnsureChunkEmitter(), ResumeNode: "plan", + AlwaysExecute: st.Request.AlwaysExecute, }, ); err != nil { return nil, err @@ -293,8 +297,10 @@ func saveAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) { } snapshot := &newagentmodel.AgentStateSnapshot{ - RuntimeState: runtimeState, - ConversationContext: st.EnsureConversationContext(), + RuntimeState: runtimeState, + ConversationContext: st.EnsureConversationContext(), + ScheduleState: st.ScheduleState.Clone(), + OriginalScheduleState: st.OriginalScheduleState.Clone(), } _ = store.Save(ctx, flowState.ConversationID, snapshot) diff --git a/backend/newAgent/node/chat.go b/backend/newAgent/node/chat.go index c81e2b3..3ce718c 100644 --- a/backend/newAgent/node/chat.go +++ b/backend/newAgent/node/chat.go @@ -54,7 +54,7 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error { // 1. 有 pending interaction → 纯状态传递,处理恢复。 if runtimeState.HasPendingInteraction() { - return handleChatResume(input, runtimeState, conversationContext, emitter) + return handleChatResume(input, runtimeState, emitter) } // 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。 @@ -263,16 +263,13 @@ func handleRoutePlan( func handleChatResume( input ChatNodeInput, runtimeState *newagentmodel.AgentRuntimeState, - conversationContext *newagentmodel.ConversationContext, emitter *newagentstream.ChunkEmitter, ) error { pending := runtimeState.PendingInteraction flowState := runtimeState.EnsureCommonState() - // 把用户本轮输入写回历史(ask_user 回复、confirm 附言等)。 - if strings.TrimSpace(input.UserInput) != "" { - conversationContext.AppendHistory(schema.UserMessage(input.UserInput)) - } + // 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。 + // 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。 switch pending.Type { case newagentmodel.PendingInteractionTypeAskUser: diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index a721116..9aca7c9 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -38,8 +38,8 @@ const ( // 3. ConversationContext 提供历史对话与置顶上下文; // 4. ToolRegistry 提供工具注册表; // 5. ScheduleState 提供工具操作的内存数据源(可为 nil,由调用方按需加载); -// 6. SchedulePersistor 用于写工具执行后持久化变更; -// 7. OriginalScheduleState 是首次加载时的原始快照,用于 diff。 +// 6. SchedulePersistor 仍保留注入位,但当前阶段不调用,避免写库; +// 7. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。 type ExecuteNodeInput struct { RuntimeState *newagentmodel.AgentRuntimeState ConversationContext *newagentmodel.ConversationContext @@ -138,6 +138,15 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。 messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext) + log.Printf( + "[DEBUG] execute LLM context begin chat=%s round=%d message_count=%d\n%s\n[DEBUG] execute LLM context end chat=%s round=%d", + flowState.ConversationID, + flowState.RoundUsed, + len(messages), + formatExecuteLLMMessagesForDebug(messages), + flowState.ConversationID, + flowState.RoundUsed, + ) decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ExecuteDecision]( ctx, input.Client, @@ -316,18 +325,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return nil case newagentmodel.ExecuteActionConfirm: - // AlwaysExecute=true:跳过确认闸门,直接执行写工具并持久化,不走 confirm 节点。 + // AlwaysExecute=true:跳过确认闸门,直接执行内存写工具,不走 confirm 节点。 if input.AlwaysExecute && decision.ToolCall != nil { - if err := executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState); err != nil { - return err - } - if input.SchedulePersistor != nil && input.OriginalScheduleState != nil { - cs := runtimeState.EnsureCommonState() - if persistErr := input.SchedulePersistor.PersistScheduleChanges(ctx, input.OriginalScheduleState, input.ScheduleState, cs.UserID); persistErr != nil { - log.Printf("[WARN] execute always-execute 持久化失败: %v", persistErr) - } - } - return nil + return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState) } // AlwaysExecute=false(默认):暂存工具意图,设 Phase → 下游 confirm 节点接管。 return handleExecuteActionConfirm(decision, runtimeState, flowState) @@ -504,7 +504,19 @@ func executeToolCall( } // 2. 执行工具。 + beforeDigest := summarizeScheduleStateForDebug(scheduleState) result := registry.Execute(scheduleState, toolName, toolCall.Arguments) + afterDigest := summarizeScheduleStateForDebug(scheduleState) + log.Printf( + "[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", + flowState.ConversationID, + flowState.RoundUsed, + toolName, + marshalArgsForDebug(toolCall.Arguments), + beforeDigest, + afterDigest, + flattenForLog(result), + ) // 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。 const maxToolResultLen = 3000 @@ -558,7 +570,7 @@ func executeToolCall( // 1. 从 PendingConfirmTool 读取工具名和参数(已序列化); // 2. 反序列化参数后调用工具执行; // 3. 将结果追加到历史,清空 PendingConfirmTool; -// 4. 执行成功后调用 persistor 持久化变更; +// 4. 当前阶段只保留内存修改,不在这里落库; // 5. 不调用 LLM,直接返回让下一轮继续。 func executePendingTool( ctx context.Context, @@ -598,7 +610,20 @@ func executePendingTool( } // 4. 执行工具。 + beforeDigest := summarizeScheduleStateForDebug(scheduleState) result := registry.Execute(scheduleState, pending.ToolName, args) + afterDigest := summarizeScheduleStateForDebug(scheduleState) + flowState := runtimeState.EnsureCommonState() + log.Printf( + "[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", + flowState.ConversationID, + flowState.RoundUsed, + pending.ToolName, + marshalArgsForDebug(args), + beforeDigest, + afterDigest, + flattenForLog(result), + ) // 5. 将工具调用和结果以合法的 assistant+tool 消息对追加到历史。 // @@ -630,13 +655,6 @@ func executePendingTool( // 6. 清空临时邮箱,避免重复执行。 runtimeState.PendingConfirmTool = nil - // 7. 持久化变更(如果有 persistor)。 - if persistor != nil && originalState != nil { - if err := persistor.PersistScheduleChanges(ctx, originalState, scheduleState, runtimeState.UserID); err != nil { - return fmt.Errorf("持久化日程变更失败: %w", err) - } - } - return nil } @@ -671,3 +689,147 @@ func truncateText(text string, maxLen int) string { } return text[:maxLen-3] + "..." } + +// summarizeScheduleStateForDebug 返回内存日程状态的关键计数,用于判断工具是否真的修改了 state。 +func summarizeScheduleStateForDebug(state *newagenttools.ScheduleState) string { + if state == nil { + return "state=nil" + } + + total := len(state.Tasks) + pendingNoSlot := 0 + pendingWithSlot := 0 + existingTotal := 0 + taskItemWithSlot := 0 + eventWithSlot := 0 + + for i := range state.Tasks { + t := &state.Tasks[i] + hasSlot := len(t.Slots) > 0 + + switch t.Status { + case "pending": + if hasSlot { + pendingWithSlot++ + } else { + pendingNoSlot++ + } + case "existing": + existingTotal++ + } + + if hasSlot { + if t.Source == "task_item" { + taskItemWithSlot++ + } + if t.Source == "event" { + eventWithSlot++ + } + } + } + + return fmt.Sprintf( + "tasks=%d pending_no_slot=%d pending_with_slot=%d existing=%d task_item_with_slot=%d event_with_slot=%d", + total, + pendingNoSlot, + pendingWithSlot, + existingTotal, + taskItemWithSlot, + eventWithSlot, + ) +} + +// marshalArgsForDebug 将工具参数序列化为日志可读的短文本。 +func marshalArgsForDebug(args map[string]any) string { + if len(args) == 0 { + return "{}" + } + raw, err := json.Marshal(args) + if err != nil { + return "" + } + return string(raw) +} + +// flattenForLog 将多行文本压成单行,避免日志换行影响排查。 +func flattenForLog(text string) string { + text = strings.ReplaceAll(text, "\n", " ") + text = strings.ReplaceAll(text, "\r", " ") + return strings.TrimSpace(text) +} + +// formatExecuteLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。 +// +// 说明: +// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐; +// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断; +// 3. 仅用于调试打点,不参与业务决策。 +func formatExecuteLLMMessagesForDebug(messages []*schema.Message) string { + if len(messages) == 0 { + return "(empty messages)" + } + + var sb strings.Builder + for i, msg := range messages { + sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i)) + if msg == nil { + sb.WriteString("role: \n\n") + continue + } + + sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role)) + + if strings.TrimSpace(msg.ToolCallID) != "" { + sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID)) + } + if strings.TrimSpace(msg.ToolName) != "" { + sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName)) + } + + if len(msg.ToolCalls) > 0 { + sb.WriteString("tool_calls:\n") + for j, call := range msg.ToolCalls { + sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name)) + sb.WriteString(" arguments:\n") + sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " ")) + sb.WriteString("\n") + } + } + + if strings.TrimSpace(msg.ReasoningContent) != "" { + sb.WriteString("reasoning_content:\n") + sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " ")) + sb.WriteString("\n") + } + + sb.WriteString("content:\n") + sb.WriteString(indentMultilineForDebug(msg.Content, " ")) + sb.WriteString("\n") + + if len(msg.Extra) > 0 { + sb.WriteString("extra:\n") + raw, err := json.MarshalIndent(msg.Extra, "", " ") + if err != nil { + sb.WriteString(indentMultilineForDebug("", " ")) + } else { + sb.WriteString(indentMultilineForDebug(string(raw), " ")) + } + sb.WriteString("\n") + } + + sb.WriteString("\n") + } + return sb.String() +} + +// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。 +func indentMultilineForDebug(text, prefix string) string { + if text == "" { + return prefix + "" + } + lines := strings.Split(text, "\n") + for i := range lines { + lines[i] = prefix + lines[i] + } + return strings.Join(lines, "\n") +} diff --git a/backend/newAgent/node/plan.go b/backend/newAgent/node/plan.go index 382c668..0f2fdc2 100644 --- a/backend/newAgent/node/plan.go +++ b/backend/newAgent/node/plan.go @@ -33,6 +33,7 @@ type PlanNodeInput struct { Client *newagentllm.Client ChunkEmitter *newagentstream.ChunkEmitter ResumeNode string + AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点 } // RunPlanNode 执行一轮规划节点逻辑。 @@ -166,6 +167,18 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error { flowState.TaskClassIDs = decision.TaskClassIDs } } + // always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。 + // 这样可以与 Execute 节点的“写工具跳过确认”语义保持一致。 + if input.AlwaysExecute { + flowState.ConfirmPlan() + _ = emitter.EmitStatus( + planStatusBlockID, + planStageName, + "plan_auto_confirmed", + "计划已自动确认,开始执行。", + false, + ) + } return nil default: // 1. LLM 输出了不支持的 action,不应直接报错终止,而应给它修正机会。 diff --git a/backend/newAgent/node/rough_build.go b/backend/newAgent/node/rough_build.go index 89dc855..e369aa3 100644 --- a/backend/newAgent/node/rough_build.go +++ b/backend/newAgent/node/rough_build.go @@ -3,6 +3,8 @@ package newagentnode import ( "context" "fmt" + "strconv" + "strings" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" @@ -82,10 +84,18 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e // 8. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入验证和微调。 stillPending := countPendingTasks(scheduleState) + + // 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。 + idParts := make([]string, len(taskClassIDs)) + for i, id := range taskClassIDs { + idParts[i] = strconv.Itoa(id) + } + idStr := strings.Join(idParts, ", ") + var pinnedContent string if stillPending > 0 { pinnedContent = fmt.Sprintf( - "后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+ + "后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+ "注意:仍有 %d 个任务未被粗排覆盖,处于待安排(pending)状态,必须在微调阶段手动安排完毕。\n\n"+ "处理 pending 任务的正确操作顺序:\n"+ "1. 调用 get_overview 或 find_free 确认可用空位(不要反复调用 list_tasks,list_tasks 只能看任务列表,看不出空位)\n"+ @@ -93,14 +103,14 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e "3. 重复上述步骤,直到 get_overview 显示待安排任务剩余为 0\n\n"+ "微调完成的判定标准:所有 pending 任务均已 place(待安排任务剩余=0),且现有排课无明显失衡。\n"+ "无需再次触发粗排。", - len(placements), stillPending, + idStr, len(placements), stillPending, ) } else { pinnedContent = fmt.Sprintf( - "后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+ + "后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+ "请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+ "无需再次触发粗排。", - len(placements), + idStr, len(placements), ) } st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{ diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 6d537bf..3d5106a 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -2,6 +2,7 @@ package newagentprompt import ( "fmt" + "strconv" "strings" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" @@ -191,6 +192,17 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string { sb.WriteString(renderStateSummary(state)) sb.WriteString("\n") + // 明确列出任务类 IDs,与 Plan 阶段保持信息对称,避免 LLM 因 plan 步骤中引用了 ID + // 而在 Execute 阶段找不到显式来源,误触 rule 5(缺少关键上下文)→ ask_user。 + if state != nil && len(state.TaskClassIDs) > 0 { + parts := make([]string, len(state.TaskClassIDs)) + for i, id := range state.TaskClassIDs { + parts[i] = strconv.Itoa(id) + } + sb.WriteString(fmt.Sprintf("本次排课请求涉及的任务类 ID:[%s](上下文已完整,无需向用户追问)\n", strings.Join(parts, ", "))) + sb.WriteString("\n") + } + if state == nil || !state.HasPlan() { sb.WriteString("当前没有可执行的完整 plan,请不要盲目进入执行;如有需要请回退到规划阶段。\n") return strings.TrimSpace(sb.String()) @@ -221,7 +233,16 @@ func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string { sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n") sb.WriteString(renderStateSummary(state)) - sb.WriteString("\n\n") + sb.WriteString("\n") + + if state != nil && len(state.TaskClassIDs) > 0 { + parts := make([]string, len(state.TaskClassIDs)) + for i, id := range state.TaskClassIDs { + parts[i] = strconv.Itoa(id) + } + sb.WriteString(fmt.Sprintf("本次排课请求涉及的任务类 ID:[%s](上下文已完整,无需向用户追问)\n", strings.Join(parts, ", "))) + } + sb.WriteString("\n") sb.WriteString("判断规则:\n") sb.WriteString("- 需要查询/读取数据 → action=continue + tool_call(读工具)\n") diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index 712fba6..a53d5d2 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -139,8 +139,8 @@ func (e *ChunkEmitter) EmitAssistantText(blockID, stage, text string, includeRol if e == nil || e.emit == nil { return nil } - - text = strings.TrimSpace(text) + //这里如果不删掉,换行符会被吞了,导致文字黏连 + /* text = strings.TrimSpace(text)*/ if text == "" { return nil } @@ -509,9 +509,7 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { options = normalizePseudoStreamOptions(options) runes := []rune(text) if len(runes) <= options.MaxChunkRunes { - if hasTrailingNewline { - return []string{text + "\n"} - } + // text 经 TrimRight(" \t\r") 已保留结尾 \n,直接返回,不再追加。 return []string{text} } @@ -532,7 +530,9 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { continue } - chunk := strings.TrimSpace(string(runes[start : i+1])) + // 用 Trim(" \t\r") 代替 TrimSpace:保留 chunk 内的 \n(段落分隔符)。 + // TrimSpace 会把 flush 在 \n 边界时结尾的 \n、以及下一段开头的 \n 全部删掉,导致黏连。 + chunk := strings.Trim(string(runes[start:i+1]), " \t\r") if chunk != "" { chunks = append(chunks, chunk) } @@ -541,19 +541,17 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { } if start < len(runes) { - chunk := strings.TrimSpace(string(runes[start:])) + chunk := strings.Trim(string(runes[start:]), " \t\r") if chunk != "" { chunks = append(chunks, chunk) } } if len(chunks) == 0 { - if hasTrailingNewline { - return []string{text + "\n"} - } return []string{text} } - if hasTrailingNewline { + // 仅当最后一个 chunk 尚未以 \n 结尾时才追加,避免 Trim 修复后出现双换行。 + if hasTrailingNewline && !strings.HasSuffix(chunks[len(chunks)-1], "\n") { chunks[len(chunks)-1] += "\n" } return chunks diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index a5eaae6..ed161a1 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -86,7 +86,7 @@ func (s *AgentService) runNewAgentGraph( // 4. 从 StateStore 加载或创建 RuntimeState。 // 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext, // 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。 - runtimeState, savedConversationContext := s.loadOrCreateRuntimeState(requestCtx, chatID, userID) + runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID) // 5. 构造 ConversationContext。 // 优先使用快照中恢复的 ConversationContext(含工具调用/结果), @@ -161,10 +161,12 @@ func (s *AgentService) runNewAgentGraph( // 10. 构造 AgentGraphRunInput 并运行 graph。 runInput := newagentmodel.AgentGraphRunInput{ - RuntimeState: runtimeState, - ConversationContext: conversationContext, - Request: graphRequest, - Deps: deps, + RuntimeState: runtimeState, + ConversationContext: conversationContext, + ScheduleState: savedScheduleState, + OriginalScheduleState: savedOriginalScheduleState, + Request: graphRequest, + Deps: deps, } finalState, graphErr := newagentgraph.RunAgentGraph(requestCtx, runInput) @@ -211,13 +213,13 @@ func (s *AgentService) runNewAgentGraph( // 这些消息不会出现在 Redis LLM 历史缓存中; // 2. 恢复场景(confirm/ask_user)必须使用快照中的 ConversationContext,否则工具结果丢失, // 导致后续 LLM 调用收到非法的裸 Tool 消息,API 拒绝请求、连接断开。 -func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext) { - newRT := func() (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext) { +func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagenttools.ScheduleState, *newagenttools.ScheduleState) { + newRT := func() (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagenttools.ScheduleState, *newagenttools.ScheduleState) { rt := newagentmodel.NewAgentRuntimeState(nil) cs := rt.EnsureCommonState() cs.UserID = userID cs.ConversationID = chatID // saveAgentState 依赖此字段决定是否持久化 - return rt, nil + return rt, nil, nil, nil } if s.agentStateStore == nil { @@ -225,11 +227,13 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri } snapshot, ok, err := s.agentStateStore.Load(ctx, chatID) - log.Printf("[DEBUG] loadOrCreateRuntimeState chatID=%s ok=%v err=%v hasRuntime=%v hasPending=%v hasCtx=%v", + log.Printf("[DEBUG] loadOrCreateRuntimeState chatID=%s ok=%v err=%v hasRuntime=%v hasPending=%v hasCtx=%v hasSchedule=%v hasOriginal=%v", chatID, ok, err, snapshot != nil && snapshot.RuntimeState != nil, snapshot != nil && snapshot.RuntimeState != nil && snapshot.RuntimeState.HasPendingInteraction(), snapshot != nil && snapshot.ConversationContext != nil, + snapshot != nil && snapshot.ScheduleState != nil, + snapshot != nil && snapshot.OriginalScheduleState != nil, ) if err != nil { log.Printf("加载 agent 状态失败 chat=%s: %v", chatID, err) @@ -244,7 +248,14 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri // 不需要手动重置 Phase:所有请求统一先过 Chat 节点,Chat 会根据路由决策覆盖 Phase。 // 保留完整的 RuntimeState(PlanSteps、CurrentStep 等),支持连续对话调整日程。 - return snapshot.RuntimeState, snapshot.ConversationContext + originalScheduleState := snapshot.OriginalScheduleState + if snapshot.ScheduleState != nil && originalScheduleState == nil { + // 1. 兼容老快照:历史会话可能只存了 ScheduleState,没有 original 副本。 + // 2. 这里补一份克隆,保证后续节点拿到的仍是“恢复态 + 原始态”成对数据。 + // 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。 + originalScheduleState = snapshot.ScheduleState.Clone() + } + return snapshot.RuntimeState, snapshot.ConversationContext, snapshot.ScheduleState, originalScheduleState } return newRT() } @@ -458,14 +469,94 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul return nil } return func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error { + stateDigest := summarizeScheduleStateForPreviewDebug(state) preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "") if preview == nil { + log.Printf("[WARN] deliver preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest) return nil } + previewDigest := summarizeHybridEntriesForPreviewDebug(preview.HybridEntries) + log.Printf( + "[DEBUG] deliver preview write chat=%s user=%d state=%s preview=%s generated_at=%s", + conversationID, + userID, + stateDigest, + previewDigest, + preview.GeneratedAt.Format(time.RFC3339), + ) return s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, conversationID, preview) } } +// summarizeScheduleStateForPreviewDebug 统计 Deliver 写预览前的内存日程摘要。 +func summarizeScheduleStateForPreviewDebug(state *newagenttools.ScheduleState) string { + if state == nil { + return "state=nil" + } + + total := len(state.Tasks) + pendingNoSlot := 0 + pendingWithSlot := 0 + taskItemWithSlot := 0 + eventWithSlot := 0 + for i := range state.Tasks { + t := &state.Tasks[i] + hasSlot := len(t.Slots) > 0 + if t.Status == "pending" { + if hasSlot { + pendingWithSlot++ + } else { + pendingNoSlot++ + } + } + if hasSlot { + if t.Source == "task_item" { + taskItemWithSlot++ + } + if t.Source == "event" { + eventWithSlot++ + } + } + } + return fmt.Sprintf( + "tasks=%d pending_no_slot=%d pending_with_slot=%d task_item_with_slot=%d event_with_slot=%d", + total, + pendingNoSlot, + pendingWithSlot, + taskItemWithSlot, + eventWithSlot, + ) +} + +// summarizeHybridEntriesForPreviewDebug 统计预览转换后的 HybridEntries 摘要。 +func summarizeHybridEntriesForPreviewDebug(entries []model.HybridScheduleEntry) string { + existing := 0 + suggested := 0 + taskType := 0 + courseType := 0 + for _, e := range entries { + if e.Status == "suggested" { + suggested++ + } else { + existing++ + } + if e.Type == "task" { + taskType++ + } + if e.Type == "course" { + courseType++ + } + } + return fmt.Sprintf( + "entries=%d existing=%d suggested=%d task_type=%d course_type=%d", + len(entries), + existing, + suggested, + taskType, + courseType, + ) +} + // --- 依赖注入字段 --- // toolRegistry 由 cmd/start.go 注入 diff --git a/infra/schedule_preview_viewer.html b/infra/schedule_preview_viewer.html index 55866b3..63c9b95 100644 --- a/infra/schedule_preview_viewer.html +++ b/infra/schedule_preview_viewer.html @@ -19,9 +19,7 @@ --embedded: #1f8f4f; } - * { - box-sizing: border-box; - } + * { box-sizing: border-box; } body { margin: 0; @@ -47,10 +45,7 @@ overflow: hidden; } - .left { - display: flex; - flex-direction: column; - } + .left { display: flex; flex-direction: column; } .panel-header { padding: 12px 14px; @@ -58,18 +53,8 @@ background: linear-gradient(180deg, #f8faff 0%, #f5f8ff 100%); } - .panel-header h1 { - margin: 0; - font-size: 16px; - font-weight: 700; - } - - .panel-header p { - margin: 6px 0 0; - color: var(--sub); - font-size: 12px; - line-height: 1.45; - } + .panel-header h1 { margin: 0; font-size: 16px; font-weight: 700; } + .panel-header p { margin: 6px 0 0; color: var(--sub); font-size: 12px; line-height: 1.45; } .input-wrap { display: flex; @@ -94,11 +79,7 @@ background: #fbfcff; } - .btn-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - } + .btn-row { display: flex; gap: 8px; flex-wrap: wrap; } button { border: 1px solid var(--line); @@ -116,17 +97,9 @@ border-color: var(--accent); } - .error { - color: #c62828; - font-size: 12px; - min-height: 16px; - } + .error { color: #c62828; font-size: 12px; min-height: 16px; } - .right { - display: flex; - flex-direction: column; - min-height: 0; - } + .right { display: flex; flex-direction: column; min-height: 0; } .meta { padding: 12px 14px; @@ -144,11 +117,50 @@ font-size: 12px; } - .meta label { - font-weight: 600; - color: var(--text); + .meta label { font-weight: 600; color: var(--text); } + + /* ── Tab bar ── */ + .tab-bar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--line); + background: #f9faff; } + .tab { + padding: 8px 18px; + font-size: 13px; + font-weight: 500; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--sub); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + } + + .tab:hover { color: var(--accent); } + + .tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: transparent; + } + + .tab-count { + display: inline-block; + background: #e8eeff; + color: var(--accent); + border-radius: 10px; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + margin-left: 4px; + } + + .tab.active .tab-count { background: #d0dcff; } + select { border: 1px solid var(--line); border-radius: 8px; @@ -168,13 +180,27 @@ white-space: pre-wrap; } - .grid-wrap { - overflow: auto; - padding: 12px; - flex: 1; - min-height: 0; + .legend { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + font-size: 12px; + color: var(--sub); } + .legend.hidden { display: none; } + + .dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 4px; + } + + .grid-wrap { overflow: auto; padding: 12px; flex: 1; min-height: 0; } + .week-grid { min-width: 980px; display: grid; @@ -214,9 +240,7 @@ color: #56607b; } - .slot { - background: #fcfdff; - } + .slot { background: #fcfdff; } .event { margin: 2px; @@ -246,21 +270,10 @@ border-color: #177a42; } - .event .title { - font-weight: 700; - margin-bottom: 3px; - word-break: break-all; - } + .event .title { font-weight: 700; margin-bottom: 3px; word-break: break-all; } + .event .meta-text { opacity: 0.95; } - .event .meta-text { - opacity: 0.95; - } - - .embedded-list { - margin-top: 4px; - display: grid; - gap: 3px; - } + .embedded-list { margin-top: 4px; display: grid; gap: 3px; } .embedded-item { background: rgba(255, 255, 255, 0.17); @@ -270,21 +283,26 @@ font-size: 10px; } - .legend { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; - font-size: 12px; - color: var(--sub); + /* ── Hybrid-mode badges ── */ + .badge-row { margin-top: 3px; display: flex; gap: 4px; flex-wrap: wrap; } + + .ctx-tag { + display: inline-block; + padding: 1px 5px; + border-radius: 4px; + font-size: 10px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.38); } - .dot { - width: 10px; - height: 10px; - border-radius: 50%; + .embed-badge { display: inline-block; - margin-right: 4px; + padding: 1px 5px; + border-radius: 4px; + font-size: 10px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 230, 100, 0.5); + color: #ffe97a; } .unplaced { @@ -297,29 +315,21 @@ background: #fcfdff; } - .empty { - padding: 18px; - text-align: center; - color: #7a849a; - font-size: 13px; - } + .empty { padding: 18px; text-align: center; color: #7a849a; font-size: 13px; } @media (max-width: 960px) { - .page { - grid-template-columns: 1fr; - } - textarea { - min-height: 280px; - } + .page { grid-template-columns: 1fr; } + textarea { min-height: 280px; } }
+

排程预览 JSON 输入

-

粘贴 /api/v1/agent/schedule-preview 响应,点击“解析并渲染”。

+

粘贴 /api/v1/agent/schedule-preview 响应,点击"解析并渲染"。
同时支持 candidate_plans(时间表视图)与 hybrid_entries(节次混合视图)。

@@ -332,6 +342,7 @@
+
@@ -341,14 +352,36 @@ trace_id: - generated_at: -
+ + +
+ + +
+
这里会显示排程摘要。
-
- existing(课程/已存在安排) + + +
+ existing(课程/已有安排) suggested(建议任务) task(普通任务) 嵌入任务(显示在课程块内)
+ + +
+
先粘贴 JSON 再渲染课表。
@@ -358,15 +391,15 @@