Version: 0.9.4.dev.260407
后端: 1.粗排结果/预览语义修复(task_item suggested 保真 + existing/嵌入识别补全) - 更新conv/schedule_state.go:LoadScheduleState 补齐 event.rel_id / schedules.embedded_task_id / task_item.embedded_time 三种“已落位”信号;嵌入任务强制 existing + 继承 host slots;补充 task_item duration/name/slot helper;Diff 相关英文注释改中文 - 更新conv/schedule_preview.go:预览层新增 shouldMarkSuggestedInPreview,pending 任务与 source=task_item 的建议态任务统一输出 suggested 2.newAgent 状态快照增强(ScheduleState/OriginalScheduleState 跨轮恢复) - 更新model/state_store.go:AgentStateSnapshot 新增 ScheduleState / OriginalScheduleState - 更新model/graph_run_state.go:AgentGraphRunInput/AgentGraphState 接入两份 schedule 状态;恢复旧快照时自动补 original clone - 更新service/agentsvc/agent_newagent.go:loadOrCreateRuntimeState 返回并恢复 schedule/original;runNewAgentGraph 透传到 graph - 更新node/agent_nodes.go:saveAgentState 一并保存 schedule/original 到 Redis 快照 3.Execute 链路纠偏(只写内存不落库 + 完整打点 + 恢复消息去重) - 更新node/execute.go:AlwaysExecute/confirm resume 路径取消 PersistScheduleChanges,仅保留内存写;新增 execute LLM 完整上下文日志;新增工具调用前后 state 摘要日志;thinking 模式改为 enabled - 更新node/chat.go:pending resume 不再重复写入同一轮 user message - 更新service/agentsvc/agent_newagent.go:新增 deliver preview write/state 摘要日志,便于排查 suggested 丢失问题 4.AlwaysExecute 贯通 Plan→Graph→Execute - 更新node/plan.go:PlanNodeInput 新增 AlwaysExecute;plan_done 后支持自动确认直接进入执行 - 更新graph/common_graph.go:branchAfterPlan 支持 PhaseExecuting/PhaseDone 分支 5.排课上下文补强(显式注入 task_class_ids,减少 Execute 误 ask_user) - 更新prompt/execute.go:Plan/ReAct 两种 execute prompt 都显式写入任务类 ID,声明“上下文已完整,无需追问” - 更新node/rough_build.go:粗排完成 pinned block 显式标注任务类 ID,避免 Execute 找不到 ID 来源 6.流式输出与预览调试工具修复 - 更新stream/emitter.go:保留换行,修复 pseudo stream 分片后文本黏连/双换行问题 - 更新infra/schedule_preview_viewer.html:升级预览工具,支持 candidate_plans / hybrid_entries 前端:无 仓库: 1.更新了infra内的html,适应了获取日程接口
This commit is contained in:
@@ -60,7 +60,7 @@ func ScheduleStateToPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Status 映射:existing 不变,pending(有位置)= suggested。
|
// Status 映射:existing 不变,pending(有位置)= suggested。
|
||||||
if t.Status == "pending" {
|
if shouldMarkSuggestedInPreview(*t) {
|
||||||
entry.Status = "suggested"
|
entry.Status = "suggested"
|
||||||
} else {
|
} else {
|
||||||
entry.Status = "existing"
|
entry.Status = "existing"
|
||||||
@@ -109,3 +109,19 @@ func ScheduleStateToPreview(
|
|||||||
GeneratedAt: time.Now(),
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,22 +7,18 @@ import (
|
|||||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WindowDay represents a single day in the planning window.
|
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
|
||||||
type WindowDay struct {
|
type WindowDay struct {
|
||||||
Week int
|
Week int
|
||||||
DayOfWeek int
|
DayOfWeek int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Load: DB → State ====================
|
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 newAgent 工具层可直接操作的 ScheduleState。
|
||||||
|
|
||||||
// LoadScheduleState builds a ScheduleState from database query results.
|
|
||||||
//
|
//
|
||||||
// Parameters:
|
// 职责边界:
|
||||||
// - schedules: existing Schedule records in the window (must preload Event and EmbeddedTask)
|
// 1. 只负责数据映射与状态归一,不做数据库读写;
|
||||||
// - taskClasses: TaskClasses being scheduled (must preload Items)
|
// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time;
|
||||||
// - extraItemCategories: optional TaskClassItem.ID → category name,
|
// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。
|
||||||
// for task events not belonging to the provided taskClasses
|
|
||||||
// - windowDays: ordered (week, day_of_week) pairs defining the planning window
|
|
||||||
func LoadScheduleState(
|
func LoadScheduleState(
|
||||||
schedules []model.Schedule,
|
schedules []model.Schedule,
|
||||||
taskClasses []model.TaskClass,
|
taskClasses []model.TaskClass,
|
||||||
@@ -37,27 +33,28 @@ func LoadScheduleState(
|
|||||||
Tasks: make([]newagenttools.ScheduleTask, 0),
|
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))
|
dayLookup := make(map[[2]int]int, len(windowDays))
|
||||||
for i, wd := range windowDays {
|
for i, wd := range windowDays {
|
||||||
idx := i + 1
|
dayIndex := i + 1
|
||||||
state.Window.DayMapping[i] = newagenttools.DayMapping{
|
state.Window.DayMapping[i] = newagenttools.DayMapping{
|
||||||
DayIndex: idx,
|
DayIndex: dayIndex,
|
||||||
Week: wd.Week,
|
Week: wd.Week,
|
||||||
DayOfWeek: wd.DayOfWeek,
|
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 ---
|
// 2. 构建 task_item -> 分类名映射。
|
||||||
// extraItemCategories first (lower priority), then taskClasses overwrites (higher priority).
|
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||||||
|
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||||||
itemCategoryLookup := make(map[int]string)
|
itemCategoryLookup := make(map[int]string)
|
||||||
for id, name := range extraItemCategories {
|
for id, name := range extraItemCategories {
|
||||||
itemCategoryLookup[id] = name
|
itemCategoryLookup[id] = name
|
||||||
}
|
}
|
||||||
for _, tc := range taskClasses {
|
for _, tc := range taskClasses {
|
||||||
catName := "任务"
|
catName := "任务"
|
||||||
if tc.Name != nil {
|
if tc.Name != nil && *tc.Name != "" {
|
||||||
catName = *tc.Name
|
catName = *tc.Name
|
||||||
}
|
}
|
||||||
for _, item := range tc.Items {
|
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 {
|
type slotGroup struct {
|
||||||
week int
|
week int
|
||||||
dayOfWeek int
|
dayOfWeek int
|
||||||
sections []int
|
sections []int
|
||||||
}
|
}
|
||||||
|
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
|
||||||
eventSlotMap := make(map[int][]slotGroup) // eventID → groups
|
|
||||||
eventInfo := make(map[int]*model.ScheduleEvent)
|
eventInfo := make(map[int]*model.ScheduleEvent)
|
||||||
|
|
||||||
for i := range schedules {
|
for i := range schedules {
|
||||||
@@ -104,46 +100,45 @@ func LoadScheduleState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextStateID := 1
|
nextStateID := 1
|
||||||
eventStateIDs := make(map[int]int) // eventID → stateID
|
eventStateIDs := make(map[int]int) // eventID -> stateID
|
||||||
|
|
||||||
for eventID, groups := range eventSlotMap {
|
for eventID, groups := range eventSlotMap {
|
||||||
event := eventInfo[eventID]
|
event := eventInfo[eventID]
|
||||||
|
if event == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Category
|
|
||||||
category := "课程"
|
category := "课程"
|
||||||
if event.Type == "task" {
|
if event.Type == "task" {
|
||||||
category = "任务"
|
category = "任务"
|
||||||
if event.RelID != nil {
|
if event.RelID != nil {
|
||||||
if cat, ok := itemCategoryLookup[*event.RelID]; ok {
|
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
|
||||||
category = cat
|
category = cat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locked: course + not embeddable
|
|
||||||
locked := event.Type == "course" && !event.CanBeEmbedded
|
locked := event.Type == "course" && !event.CanBeEmbedded
|
||||||
|
|
||||||
// Compress sections into slot ranges
|
|
||||||
var slots []newagenttools.TaskSlot
|
var slots []newagenttools.TaskSlot
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
|
if len(g.sections) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sort.Ints(g.sections)
|
sort.Ints(g.sections)
|
||||||
start, end := g.sections[0], g.sections[0]
|
start, end := g.sections[0], g.sections[0]
|
||||||
for _, sec := range g.sections[1:] {
|
for _, sec := range g.sections[1:] {
|
||||||
if sec == end+1 {
|
if sec == end+1 {
|
||||||
end = sec
|
end = sec
|
||||||
} else {
|
continue
|
||||||
|
}
|
||||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||||
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||||
}
|
}
|
||||||
start, end = sec, sec
|
start, end = sec, sec
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||||
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
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 {
|
sort.Slice(slots, func(i, j int) bool {
|
||||||
if slots[i].Day != slots[j].Day {
|
if slots[i].Day != slots[j].Day {
|
||||||
return slots[i].Day < slots[j].Day
|
return slots[i].Day < slots[j].Day
|
||||||
@@ -168,32 +163,91 @@ func LoadScheduleState(
|
|||||||
nextStateID++
|
nextStateID++
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 4: Process pending task items → pending tasks ---
|
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
|
||||||
itemStateIDs := make(map[int]int) // TaskClassItem.ID → stateID
|
// 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 {
|
for _, tc := range taskClasses {
|
||||||
catName := "任务"
|
catName := "任务"
|
||||||
if tc.Name != nil {
|
if tc.Name != nil && *tc.Name != "" {
|
||||||
catName = *tc.Name
|
catName = *tc.Name
|
||||||
}
|
}
|
||||||
catID := tc.ID
|
defaultDuration := estimateTaskItemDuration(tc)
|
||||||
|
|
||||||
pendingCount := 0
|
pendingCount := 0
|
||||||
|
|
||||||
for _, item := range tc.Items {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
duration := 2
|
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
|
||||||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
hostSlots := []newagenttools.TaskSlot(nil)
|
||||||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
|
||||||
duration = d
|
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 slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
|
||||||
if item.Content != nil {
|
stateID := nextStateID
|
||||||
name = *item.Content
|
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
|
stateID := nextStateID
|
||||||
@@ -201,11 +255,11 @@ func LoadScheduleState(
|
|||||||
StateID: stateID,
|
StateID: stateID,
|
||||||
Source: "task_item",
|
Source: "task_item",
|
||||||
SourceID: item.ID,
|
SourceID: item.ID,
|
||||||
Name: name,
|
Name: taskItemName(item),
|
||||||
Category: catName,
|
Category: catName,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
Duration: duration,
|
Duration: defaultDuration,
|
||||||
CategoryID: catID,
|
CategoryID: tc.ID,
|
||||||
TaskClassID: tc.ID,
|
TaskClassID: tc.ID,
|
||||||
})
|
})
|
||||||
itemStateIDs[item.ID] = stateID
|
itemStateIDs[item.ID] = stateID
|
||||||
@@ -213,7 +267,7 @@ func LoadScheduleState(
|
|||||||
pendingCount++
|
pendingCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有待安排 item 的任务类才暴露约束给 LLM。
|
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
|
||||||
if pendingCount > 0 {
|
if pendingCount > 0 {
|
||||||
meta := newagenttools.TaskClassMeta{
|
meta := newagenttools.TaskClassMeta{
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
@@ -241,92 +295,181 @@ func LoadScheduleState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 5: Resolve embed relationships ---
|
// 6. 统一回填嵌入关系:
|
||||||
// Extend itemStateIDs with existing task events (rel_id → stateID)
|
// 6.1 host 记录 EmbeddedBy;
|
||||||
for eventID, stateID := range eventStateIDs {
|
// 6.2 guest 记录 EmbedHost;
|
||||||
event := eventInfo[eventID]
|
// 6.3 guest 强制 existing + host slots,防止“嵌入任务残留 pending”。
|
||||||
if event.Type == "task" && event.RelID != nil {
|
|
||||||
if _, exists := itemStateIDs[*event.RelID]; !exists {
|
|
||||||
itemStateIDs[*event.RelID] = stateID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range schedules {
|
for i := range schedules {
|
||||||
s := &schedules[i]
|
s := &schedules[i]
|
||||||
if s.EmbeddedTaskID == nil || s.Event == nil {
|
if s.EmbeddedTaskID == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hostStateID, ok := eventStateIDs[s.EventID]
|
hostStateID, ok := eventStateIDs[s.EventID]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guestStateID, ok := itemStateIDs[*s.EmbeddedTaskID]
|
hostTask := state.TaskByStateID(hostStateID)
|
||||||
|
itemID := *s.EmbeddedTaskID
|
||||||
|
|
||||||
|
guestStateID, ok := itemStateIDs[itemID]
|
||||||
if !ok {
|
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 {
|
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||||||
v := guestStateID
|
v := guestStateID
|
||||||
hostTask.EmbeddedBy = &v
|
hostTask.EmbeddedBy = &v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guest: record which host it's embedded into, copy host's slots
|
|
||||||
guestTask := state.TaskByStateID(guestStateID)
|
guestTask := state.TaskByStateID(guestStateID)
|
||||||
if guestTask != nil && guestTask.EmbedHost == nil {
|
if guestTask == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if guestTask.EmbedHost == nil {
|
||||||
v := hostStateID
|
v := hostStateID
|
||||||
guestTask.EmbedHost = &v
|
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
|
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
|
type ScheduleChangeType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ChangePlace ScheduleChangeType = "place" // pending → placed
|
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
|
||||||
ChangeMove ScheduleChangeType = "move" // slots relocated
|
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
|
||||||
ChangeUnplace ScheduleChangeType = "unplace" // placed → pending
|
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 {
|
type SlotCoord struct {
|
||||||
Week int
|
Week int
|
||||||
DayOfWeek int
|
DayOfWeek int
|
||||||
Section int
|
Section int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleChange represents a single task change between original and modified state.
|
// ScheduleChange 描述单个任务在前后状态间的变化。
|
||||||
type ScheduleChange struct {
|
type ScheduleChange struct {
|
||||||
Type ScheduleChangeType
|
Type ScheduleChangeType
|
||||||
StateID int
|
StateID int
|
||||||
Source string // "event" | "task_item"
|
Source string // "event" | "task_item"
|
||||||
SourceID int // ScheduleEvent.ID or TaskClassItem.ID
|
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
|
||||||
EventType string // "course" | "task" (source=event only)
|
EventType string // 仅 source=event 时有意义(course/task)
|
||||||
CategoryID int // source=task_item only
|
CategoryID int // 仅 source=task_item 时有意义
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
// For place/move: new slot positions (expanded to individual sections)
|
// place/move 的新位置(展开到逐节坐标)。
|
||||||
NewCoords []SlotCoord
|
NewCoords []SlotCoord
|
||||||
// For move/unplace: old slot positions
|
// move/unplace 的旧位置(展开到逐节坐标)。
|
||||||
OldCoords []SlotCoord
|
OldCoords []SlotCoord
|
||||||
|
|
||||||
// HostEventID: source=task_item 嵌入路径时,宿主课程的 schedule_event.id。
|
// HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。
|
||||||
// Place/Unplace:当前操作位置的宿主 EventID(0 表示非嵌入)。
|
|
||||||
// Move:新位置的宿主 EventID。
|
|
||||||
HostEventID int
|
HostEventID int
|
||||||
// OldHostEventID: Move 时旧位置的宿主 EventID(0 表示旧位置非嵌入)。
|
// OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。
|
||||||
OldHostEventID int
|
OldHostEventID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffScheduleState compares original and modified ScheduleState,
|
// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。
|
||||||
// returning the changes that need to be persisted to the database.
|
|
||||||
func DiffScheduleState(
|
func DiffScheduleState(
|
||||||
original *newagenttools.ScheduleState,
|
original *newagenttools.ScheduleState,
|
||||||
modified *newagenttools.ScheduleState,
|
modified *newagenttools.ScheduleState,
|
||||||
@@ -347,7 +490,6 @@ func DiffScheduleState(
|
|||||||
hadSlots := orig != nil && len(orig.Slots) > 0
|
hadSlots := orig != nil && len(orig.Slots) > 0
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
// Place: pending → has slots
|
|
||||||
case wasPending && hasSlots:
|
case wasPending && hasSlots:
|
||||||
changes = append(changes, ScheduleChange{
|
changes = append(changes, ScheduleChange{
|
||||||
Type: ChangePlace,
|
Type: ChangePlace,
|
||||||
@@ -360,8 +502,6 @@ func DiffScheduleState(
|
|||||||
NewCoords: expandToCoords(mod.Slots, modified),
|
NewCoords: expandToCoords(mod.Slots, modified),
|
||||||
HostEventID: resolveHostEventID(mod, modified),
|
HostEventID: resolveHostEventID(mod, modified),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Move: had slots → different slots
|
|
||||||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||||||
changes = append(changes, ScheduleChange{
|
changes = append(changes, ScheduleChange{
|
||||||
Type: ChangeMove,
|
Type: ChangeMove,
|
||||||
@@ -376,8 +516,6 @@ func DiffScheduleState(
|
|||||||
HostEventID: resolveHostEventID(mod, modified),
|
HostEventID: resolveHostEventID(mod, modified),
|
||||||
OldHostEventID: resolveHostEventID(orig, original),
|
OldHostEventID: resolveHostEventID(orig, original),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unplace: had slots → no slots
|
|
||||||
case hadSlots && !hasSlots:
|
case hadSlots && !hasSlots:
|
||||||
changes = append(changes, ScheduleChange{
|
changes = append(changes, ScheduleChange{
|
||||||
Type: ChangeUnplace,
|
Type: ChangeUnplace,
|
||||||
@@ -395,7 +533,7 @@ func DiffScheduleState(
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexByStateID creates a map of stateID → *ScheduleTask.
|
// indexByStateID 将任务列表按 state_id 建立索引。
|
||||||
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
|
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
|
||||||
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
|
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
|
||||||
for i := range state.Tasks {
|
for i := range state.Tasks {
|
||||||
@@ -404,7 +542,7 @@ func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.S
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// slotsEqual compares two TaskSlot slices for equality.
|
// slotsEqual 判断两个压缩槽位切片是否完全一致。
|
||||||
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
|
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
@@ -417,9 +555,18 @@ func slotsEqual(a, b []newagenttools.TaskSlot) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveHostEventID 从任务的 EmbedHost 字段反查宿主的 ScheduleEvent.ID。
|
// cloneTaskSlots 深拷贝槽位切片。
|
||||||
// 用于 DiffScheduleState 在生成 ScheduleChange 时记录嵌入路径的宿主 EventID。
|
func cloneTaskSlots(src []newagenttools.TaskSlot) []newagenttools.TaskSlot {
|
||||||
// 若任务非嵌入(EmbedHost == nil)或宿主不存在,返回 0。
|
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 {
|
func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.ScheduleState) int {
|
||||||
if task == nil || task.EmbedHost == nil {
|
if task == nil || task.EmbedHost == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -431,7 +578,7 @@ func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.S
|
|||||||
return host.SourceID
|
return host.SourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandToCoords converts compressed TaskSlots to individual SlotCoords.
|
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
|
||||||
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
|
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
|
||||||
var coords []SlotCoord
|
var coords []SlotCoord
|
||||||
for _, slot := range slots {
|
for _, slot := range slots {
|
||||||
|
|||||||
322
backend/newAgent/HANDOFF_粗排修复与Prompt重构.md
Normal file
322
backend/newAgent/HANDOFF_粗排修复与Prompt重构.md
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -189,6 +189,15 @@ func branchAfterPlan(_ context.Context, st *newagentmodel.AgentGraphState) (stri
|
|||||||
if flowState.Phase == newagentmodel.PhaseWaitingConfirm {
|
if flowState.Phase == newagentmodel.PhaseWaitingConfirm {
|
||||||
return NodeConfirm, nil
|
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
|
return NodePlan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ func (d *AgentGraphDeps) ResolveDeliverClient() *newagentllm.Client {
|
|||||||
type AgentGraphRunInput struct {
|
type AgentGraphRunInput struct {
|
||||||
RuntimeState *AgentRuntimeState
|
RuntimeState *AgentRuntimeState
|
||||||
ConversationContext *ConversationContext
|
ConversationContext *ConversationContext
|
||||||
|
ScheduleState *newagenttools.ScheduleState
|
||||||
|
OriginalScheduleState *newagenttools.ScheduleState
|
||||||
Request AgentGraphRequest
|
Request AgentGraphRequest
|
||||||
Deps AgentGraphDeps
|
Deps AgentGraphDeps
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,8 @@ func NewAgentGraphState(input AgentGraphRunInput) *AgentGraphState {
|
|||||||
ConversationContext: input.ConversationContext,
|
ConversationContext: input.ConversationContext,
|
||||||
Request: input.Request,
|
Request: input.Request,
|
||||||
Deps: input.Deps,
|
Deps: input.Deps,
|
||||||
|
ScheduleState: input.ScheduleState,
|
||||||
|
OriginalScheduleState: input.OriginalScheduleState,
|
||||||
}
|
}
|
||||||
st.Request.Normalize()
|
st.Request.Normalize()
|
||||||
st.EnsureRuntimeState()
|
st.EnsureRuntimeState()
|
||||||
@@ -238,6 +242,12 @@ func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*newagenttoo
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if s.ScheduleState != 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
|
return s.ScheduleState, nil
|
||||||
}
|
}
|
||||||
if s.Deps.ScheduleProvider == nil {
|
if s.Deps.ScheduleProvider == nil {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
type AgentStateSnapshot struct {
|
type AgentStateSnapshot struct {
|
||||||
RuntimeState *AgentRuntimeState `json:"runtime_state"`
|
RuntimeState *AgentRuntimeState `json:"runtime_state"`
|
||||||
ConversationContext *ConversationContext `json:"conversation_context"`
|
ConversationContext *ConversationContext `json:"conversation_context"`
|
||||||
|
ScheduleState *newagenttools.ScheduleState `json:"schedule_state,omitempty"`
|
||||||
|
OriginalScheduleState *newagenttools.ScheduleState `json:"original_schedule_state,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentStateStore 定义 agent 状态持久化的最小接口。
|
// AgentStateStore 定义 agent 状态持久化的最小接口。
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ func (n *AgentNodes) Confirm(ctx context.Context, st *newagentmodel.AgentGraphSt
|
|||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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)
|
saveAgentState(ctx, st)
|
||||||
@@ -111,6 +114,7 @@ func (n *AgentNodes) Plan(ctx context.Context, st *newagentmodel.AgentGraphState
|
|||||||
Client: st.Deps.ResolvePlanClient(),
|
Client: st.Deps.ResolvePlanClient(),
|
||||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||||
ResumeNode: "plan",
|
ResumeNode: "plan",
|
||||||
|
AlwaysExecute: st.Request.AlwaysExecute,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -295,6 +299,8 @@ func saveAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) {
|
|||||||
snapshot := &newagentmodel.AgentStateSnapshot{
|
snapshot := &newagentmodel.AgentStateSnapshot{
|
||||||
RuntimeState: runtimeState,
|
RuntimeState: runtimeState,
|
||||||
ConversationContext: st.EnsureConversationContext(),
|
ConversationContext: st.EnsureConversationContext(),
|
||||||
|
ScheduleState: st.ScheduleState.Clone(),
|
||||||
|
OriginalScheduleState: st.OriginalScheduleState.Clone(),
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = store.Save(ctx, flowState.ConversationID, snapshot)
|
_ = store.Save(ctx, flowState.ConversationID, snapshot)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
|||||||
|
|
||||||
// 1. 有 pending interaction → 纯状态传递,处理恢复。
|
// 1. 有 pending interaction → 纯状态传递,处理恢复。
|
||||||
if runtimeState.HasPendingInteraction() {
|
if runtimeState.HasPendingInteraction() {
|
||||||
return handleChatResume(input, runtimeState, conversationContext, emitter)
|
return handleChatResume(input, runtimeState, emitter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。
|
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。
|
||||||
@@ -263,16 +263,13 @@ func handleRoutePlan(
|
|||||||
func handleChatResume(
|
func handleChatResume(
|
||||||
input ChatNodeInput,
|
input ChatNodeInput,
|
||||||
runtimeState *newagentmodel.AgentRuntimeState,
|
runtimeState *newagentmodel.AgentRuntimeState,
|
||||||
conversationContext *newagentmodel.ConversationContext,
|
|
||||||
emitter *newagentstream.ChunkEmitter,
|
emitter *newagentstream.ChunkEmitter,
|
||||||
) error {
|
) error {
|
||||||
pending := runtimeState.PendingInteraction
|
pending := runtimeState.PendingInteraction
|
||||||
flowState := runtimeState.EnsureCommonState()
|
flowState := runtimeState.EnsureCommonState()
|
||||||
|
|
||||||
// 把用户本轮输入写回历史(ask_user 回复、confirm 附言等)。
|
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。
|
||||||
if strings.TrimSpace(input.UserInput) != "" {
|
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
|
||||||
conversationContext.AppendHistory(schema.UserMessage(input.UserInput))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch pending.Type {
|
switch pending.Type {
|
||||||
case newagentmodel.PendingInteractionTypeAskUser:
|
case newagentmodel.PendingInteractionTypeAskUser:
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const (
|
|||||||
// 3. ConversationContext 提供历史对话与置顶上下文;
|
// 3. ConversationContext 提供历史对话与置顶上下文;
|
||||||
// 4. ToolRegistry 提供工具注册表;
|
// 4. ToolRegistry 提供工具注册表;
|
||||||
// 5. ScheduleState 提供工具操作的内存数据源(可为 nil,由调用方按需加载);
|
// 5. ScheduleState 提供工具操作的内存数据源(可为 nil,由调用方按需加载);
|
||||||
// 6. SchedulePersistor 用于写工具执行后持久化变更;
|
// 6. SchedulePersistor 仍保留注入位,但当前阶段不调用,避免写库;
|
||||||
// 7. OriginalScheduleState 是首次加载时的原始快照,用于 diff。
|
// 7. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。
|
||||||
type ExecuteNodeInput struct {
|
type ExecuteNodeInput struct {
|
||||||
RuntimeState *newagentmodel.AgentRuntimeState
|
RuntimeState *newagentmodel.AgentRuntimeState
|
||||||
ConversationContext *newagentmodel.ConversationContext
|
ConversationContext *newagentmodel.ConversationContext
|
||||||
@@ -138,6 +138,15 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
|||||||
|
|
||||||
// 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。
|
// 5. 构造本轮执行输入,请求 LLM 输出 ExecuteDecision。
|
||||||
messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext)
|
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](
|
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ExecuteDecision](
|
||||||
ctx,
|
ctx,
|
||||||
input.Client,
|
input.Client,
|
||||||
@@ -316,18 +325,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
|||||||
return nil
|
return nil
|
||||||
|
|
||||||
case newagentmodel.ExecuteActionConfirm:
|
case newagentmodel.ExecuteActionConfirm:
|
||||||
// AlwaysExecute=true:跳过确认闸门,直接执行写工具并持久化,不走 confirm 节点。
|
// AlwaysExecute=true:跳过确认闸门,直接执行内存写工具,不走 confirm 节点。
|
||||||
if input.AlwaysExecute && decision.ToolCall != nil {
|
if input.AlwaysExecute && decision.ToolCall != nil {
|
||||||
if err := executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState); err != nil {
|
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
// AlwaysExecute=false(默认):暂存工具意图,设 Phase → 下游 confirm 节点接管。
|
// AlwaysExecute=false(默认):暂存工具意图,设 Phase → 下游 confirm 节点接管。
|
||||||
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
||||||
@@ -504,7 +504,19 @@ func executeToolCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 执行工具。
|
// 2. 执行工具。
|
||||||
|
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
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 调用返回空或超限。
|
// 2.5 截断过大的工具结果,防止上下文膨胀导致后续 LLM 调用返回空或超限。
|
||||||
const maxToolResultLen = 3000
|
const maxToolResultLen = 3000
|
||||||
@@ -558,7 +570,7 @@ func executeToolCall(
|
|||||||
// 1. 从 PendingConfirmTool 读取工具名和参数(已序列化);
|
// 1. 从 PendingConfirmTool 读取工具名和参数(已序列化);
|
||||||
// 2. 反序列化参数后调用工具执行;
|
// 2. 反序列化参数后调用工具执行;
|
||||||
// 3. 将结果追加到历史,清空 PendingConfirmTool;
|
// 3. 将结果追加到历史,清空 PendingConfirmTool;
|
||||||
// 4. 执行成功后调用 persistor 持久化变更;
|
// 4. 当前阶段只保留内存修改,不在这里落库;
|
||||||
// 5. 不调用 LLM,直接返回让下一轮继续。
|
// 5. 不调用 LLM,直接返回让下一轮继续。
|
||||||
func executePendingTool(
|
func executePendingTool(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -598,7 +610,20 @@ func executePendingTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 执行工具。
|
// 4. 执行工具。
|
||||||
|
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
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 消息对追加到历史。
|
// 5. 将工具调用和结果以合法的 assistant+tool 消息对追加到历史。
|
||||||
//
|
//
|
||||||
@@ -630,13 +655,6 @@ func executePendingTool(
|
|||||||
// 6. 清空临时邮箱,避免重复执行。
|
// 6. 清空临时邮箱,避免重复执行。
|
||||||
runtimeState.PendingConfirmTool = nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,3 +689,147 @@ func truncateText(text string, maxLen int) string {
|
|||||||
}
|
}
|
||||||
return text[:maxLen-3] + "..."
|
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 "<marshal_error>"
|
||||||
|
}
|
||||||
|
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: <nil>\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("<marshal_error>", " "))
|
||||||
|
} 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 + "<empty>"
|
||||||
|
}
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = prefix + lines[i]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type PlanNodeInput struct {
|
|||||||
Client *newagentllm.Client
|
Client *newagentllm.Client
|
||||||
ChunkEmitter *newagentstream.ChunkEmitter
|
ChunkEmitter *newagentstream.ChunkEmitter
|
||||||
ResumeNode string
|
ResumeNode string
|
||||||
|
AlwaysExecute bool // true 时计划生成后自动确认,不进入 confirm 节点
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPlanNode 执行一轮规划节点逻辑。
|
// RunPlanNode 执行一轮规划节点逻辑。
|
||||||
@@ -166,6 +167,18 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
|||||||
flowState.TaskClassIDs = decision.TaskClassIDs
|
flowState.TaskClassIDs = decision.TaskClassIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
|
||||||
|
// 这样可以与 Execute 节点的“写工具跳过确认”语义保持一致。
|
||||||
|
if input.AlwaysExecute {
|
||||||
|
flowState.ConfirmPlan()
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
planStatusBlockID,
|
||||||
|
planStageName,
|
||||||
|
"plan_auto_confirmed",
|
||||||
|
"计划已自动确认,开始执行。",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
// 1. LLM 输出了不支持的 action,不应直接报错终止,而应给它修正机会。
|
// 1. LLM 输出了不支持的 action,不应直接报错终止,而应给它修正机会。
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package newagentnode
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
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 直接进入验证和微调。
|
// 8. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入验证和微调。
|
||||||
stillPending := countPendingTasks(scheduleState)
|
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
|
var pinnedContent string
|
||||||
if stillPending > 0 {
|
if stillPending > 0 {
|
||||||
pinnedContent = fmt.Sprintf(
|
pinnedContent = fmt.Sprintf(
|
||||||
"后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
|
"后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
|
||||||
"注意:仍有 %d 个任务未被粗排覆盖,处于待安排(pending)状态,必须在微调阶段手动安排完毕。\n\n"+
|
"注意:仍有 %d 个任务未被粗排覆盖,处于待安排(pending)状态,必须在微调阶段手动安排完毕。\n\n"+
|
||||||
"处理 pending 任务的正确操作顺序:\n"+
|
"处理 pending 任务的正确操作顺序:\n"+
|
||||||
"1. 调用 get_overview 或 find_free 确认可用空位(不要反复调用 list_tasks,list_tasks 只能看任务列表,看不出空位)\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"+
|
"3. 重复上述步骤,直到 get_overview 显示待安排任务剩余为 0\n\n"+
|
||||||
"微调完成的判定标准:所有 pending 任务均已 place(待安排任务剩余=0),且现有排课无明显失衡。\n"+
|
"微调完成的判定标准:所有 pending 任务均已 place(待安排任务剩余=0),且现有排课无明显失衡。\n"+
|
||||||
"无需再次触发粗排。",
|
"无需再次触发粗排。",
|
||||||
len(placements), stillPending,
|
idStr, len(placements), stillPending,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
pinnedContent = fmt.Sprintf(
|
pinnedContent = fmt.Sprintf(
|
||||||
"后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+
|
"后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+
|
||||||
"请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+
|
"请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+
|
||||||
"无需再次触发粗排。",
|
"无需再次触发粗排。",
|
||||||
len(placements),
|
idStr, len(placements),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{
|
st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package newagentprompt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
@@ -191,6 +192,17 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string {
|
|||||||
sb.WriteString(renderStateSummary(state))
|
sb.WriteString(renderStateSummary(state))
|
||||||
sb.WriteString("\n")
|
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() {
|
if state == nil || !state.HasPlan() {
|
||||||
sb.WriteString("当前没有可执行的完整 plan,请不要盲目进入执行;如有需要请回退到规划阶段。\n")
|
sb.WriteString("当前没有可执行的完整 plan,请不要盲目进入执行;如有需要请回退到规划阶段。\n")
|
||||||
return strings.TrimSpace(sb.String())
|
return strings.TrimSpace(sb.String())
|
||||||
@@ -221,7 +233,16 @@ func BuildExecuteReActUserPrompt(state *newagentmodel.CommonState) string {
|
|||||||
sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n")
|
sb.WriteString("请根据用户意图直接使用工具完成请求。\n\n")
|
||||||
|
|
||||||
sb.WriteString(renderStateSummary(state))
|
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("判断规则:\n")
|
||||||
sb.WriteString("- 需要查询/读取数据 → action=continue + tool_call(读工具)\n")
|
sb.WriteString("- 需要查询/读取数据 → action=continue + tool_call(读工具)\n")
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ func (e *ChunkEmitter) EmitAssistantText(blockID, stage, text string, includeRol
|
|||||||
if e == nil || e.emit == nil {
|
if e == nil || e.emit == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
//这里如果不删掉,换行符会被吞了,导致文字黏连
|
||||||
text = strings.TrimSpace(text)
|
/* text = strings.TrimSpace(text)*/
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -509,9 +509,7 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
|
|||||||
options = normalizePseudoStreamOptions(options)
|
options = normalizePseudoStreamOptions(options)
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
if len(runes) <= options.MaxChunkRunes {
|
if len(runes) <= options.MaxChunkRunes {
|
||||||
if hasTrailingNewline {
|
// text 经 TrimRight(" \t\r") 已保留结尾 \n,直接返回,不再追加。
|
||||||
return []string{text + "\n"}
|
|
||||||
}
|
|
||||||
return []string{text}
|
return []string{text}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,7 +530,9 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
|
|||||||
continue
|
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 != "" {
|
if chunk != "" {
|
||||||
chunks = append(chunks, chunk)
|
chunks = append(chunks, chunk)
|
||||||
}
|
}
|
||||||
@@ -541,19 +541,17 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if start < len(runes) {
|
if start < len(runes) {
|
||||||
chunk := strings.TrimSpace(string(runes[start:]))
|
chunk := strings.Trim(string(runes[start:]), " \t\r")
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
chunks = append(chunks, chunk)
|
chunks = append(chunks, chunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chunks) == 0 {
|
if len(chunks) == 0 {
|
||||||
if hasTrailingNewline {
|
|
||||||
return []string{text + "\n"}
|
|
||||||
}
|
|
||||||
return []string{text}
|
return []string{text}
|
||||||
}
|
}
|
||||||
if hasTrailingNewline {
|
// 仅当最后一个 chunk 尚未以 \n 结尾时才追加,避免 Trim 修复后出现双换行。
|
||||||
|
if hasTrailingNewline && !strings.HasSuffix(chunks[len(chunks)-1], "\n") {
|
||||||
chunks[len(chunks)-1] += "\n"
|
chunks[len(chunks)-1] += "\n"
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
// 4. 从 StateStore 加载或创建 RuntimeState。
|
// 4. 从 StateStore 加载或创建 RuntimeState。
|
||||||
// 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext,
|
// 恢复场景(confirm/ask_user)同时拿到快照中保存的 ConversationContext,
|
||||||
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
|
// 其中包含工具调用/结果等中间消息,保证后续 LLM 调用的消息链完整。
|
||||||
runtimeState, savedConversationContext := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
|
runtimeState, savedConversationContext, savedScheduleState, savedOriginalScheduleState := s.loadOrCreateRuntimeState(requestCtx, chatID, userID)
|
||||||
|
|
||||||
// 5. 构造 ConversationContext。
|
// 5. 构造 ConversationContext。
|
||||||
// 优先使用快照中恢复的 ConversationContext(含工具调用/结果),
|
// 优先使用快照中恢复的 ConversationContext(含工具调用/结果),
|
||||||
@@ -163,6 +163,8 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
runInput := newagentmodel.AgentGraphRunInput{
|
runInput := newagentmodel.AgentGraphRunInput{
|
||||||
RuntimeState: runtimeState,
|
RuntimeState: runtimeState,
|
||||||
ConversationContext: conversationContext,
|
ConversationContext: conversationContext,
|
||||||
|
ScheduleState: savedScheduleState,
|
||||||
|
OriginalScheduleState: savedOriginalScheduleState,
|
||||||
Request: graphRequest,
|
Request: graphRequest,
|
||||||
Deps: deps,
|
Deps: deps,
|
||||||
}
|
}
|
||||||
@@ -211,13 +213,13 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
// 这些消息不会出现在 Redis LLM 历史缓存中;
|
// 这些消息不会出现在 Redis LLM 历史缓存中;
|
||||||
// 2. 恢复场景(confirm/ask_user)必须使用快照中的 ConversationContext,否则工具结果丢失,
|
// 2. 恢复场景(confirm/ask_user)必须使用快照中的 ConversationContext,否则工具结果丢失,
|
||||||
// 导致后续 LLM 调用收到非法的裸 Tool 消息,API 拒绝请求、连接断开。
|
// 导致后续 LLM 调用收到非法的裸 Tool 消息,API 拒绝请求、连接断开。
|
||||||
func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID string, userID int) (*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) {
|
newRT := func() (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagenttools.ScheduleState, *newagenttools.ScheduleState) {
|
||||||
rt := newagentmodel.NewAgentRuntimeState(nil)
|
rt := newagentmodel.NewAgentRuntimeState(nil)
|
||||||
cs := rt.EnsureCommonState()
|
cs := rt.EnsureCommonState()
|
||||||
cs.UserID = userID
|
cs.UserID = userID
|
||||||
cs.ConversationID = chatID // saveAgentState 依赖此字段决定是否持久化
|
cs.ConversationID = chatID // saveAgentState 依赖此字段决定是否持久化
|
||||||
return rt, nil
|
return rt, nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.agentStateStore == 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)
|
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,
|
chatID, ok, err,
|
||||||
snapshot != nil && snapshot.RuntimeState != nil,
|
snapshot != nil && snapshot.RuntimeState != nil,
|
||||||
snapshot != nil && snapshot.RuntimeState != nil && snapshot.RuntimeState.HasPendingInteraction(),
|
snapshot != nil && snapshot.RuntimeState != nil && snapshot.RuntimeState.HasPendingInteraction(),
|
||||||
snapshot != nil && snapshot.ConversationContext != nil,
|
snapshot != nil && snapshot.ConversationContext != nil,
|
||||||
|
snapshot != nil && snapshot.ScheduleState != nil,
|
||||||
|
snapshot != nil && snapshot.OriginalScheduleState != nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("加载 agent 状态失败 chat=%s: %v", chatID, err)
|
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。
|
// 不需要手动重置 Phase:所有请求统一先过 Chat 节点,Chat 会根据路由决策覆盖 Phase。
|
||||||
// 保留完整的 RuntimeState(PlanSteps、CurrentStep 等),支持连续对话调整日程。
|
// 保留完整的 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()
|
return newRT()
|
||||||
}
|
}
|
||||||
@@ -458,14 +469,94 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error {
|
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, "")
|
preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
|
||||||
if preview == nil {
|
if preview == nil {
|
||||||
|
log.Printf("[WARN] deliver preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
|
||||||
return nil
|
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)
|
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 注入
|
// toolRegistry 由 cmd/start.go 注入
|
||||||
|
|||||||
@@ -19,9 +19,7 @@
|
|||||||
--embedded: #1f8f4f;
|
--embedded: #1f8f4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -47,10 +45,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left { display: flex; flex-direction: column; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -58,18 +53,8 @@
|
|||||||
background: linear-gradient(180deg, #f8faff 0%, #f5f8ff 100%);
|
background: linear-gradient(180deg, #f8faff 0%, #f5f8ff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h1 {
|
.panel-header h1 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||||
margin: 0;
|
.panel-header p { margin: 6px 0 0; color: var(--sub); font-size: 12px; line-height: 1.45; }
|
||||||
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 {
|
.input-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -94,11 +79,7 @@
|
|||||||
background: #fbfcff;
|
background: #fbfcff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-row {
|
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -116,17 +97,9 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error { color: #c62828; font-size: 12px; min-height: 16px; }
|
||||||
color: #c62828;
|
|
||||||
font-size: 12px;
|
|
||||||
min-height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
.right { display: flex; flex-direction: column; min-height: 0; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -144,11 +117,50 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta label {
|
.meta label { font-weight: 600; color: var(--text); }
|
||||||
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 {
|
select {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -168,13 +180,27 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-wrap {
|
.legend {
|
||||||
overflow: auto;
|
display: flex;
|
||||||
padding: 12px;
|
gap: 8px;
|
||||||
flex: 1;
|
align-items: center;
|
||||||
min-height: 0;
|
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 {
|
.week-grid {
|
||||||
min-width: 980px;
|
min-width: 980px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -214,9 +240,7 @@
|
|||||||
color: #56607b;
|
color: #56607b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot {
|
.slot { background: #fcfdff; }
|
||||||
background: #fcfdff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event {
|
.event {
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -246,21 +270,10 @@
|
|||||||
border-color: #177a42;
|
border-color: #177a42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event .title {
|
.event .title { font-weight: 700; margin-bottom: 3px; word-break: break-all; }
|
||||||
font-weight: 700;
|
.event .meta-text { opacity: 0.95; }
|
||||||
margin-bottom: 3px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event .meta-text {
|
.embedded-list { margin-top: 4px; display: grid; gap: 3px; }
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embedded-list {
|
|
||||||
margin-top: 4px;
|
|
||||||
display: grid;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embedded-item {
|
.embedded-item {
|
||||||
background: rgba(255, 255, 255, 0.17);
|
background: rgba(255, 255, 255, 0.17);
|
||||||
@@ -270,21 +283,26 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
/* ── Hybrid-mode badges ── */
|
||||||
display: flex;
|
.badge-row { margin-top: 3px; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
.ctx-tag {
|
||||||
flex-wrap: wrap;
|
display: inline-block;
|
||||||
font-size: 12px;
|
padding: 1px 5px;
|
||||||
color: var(--sub);
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.embed-badge {
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
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 {
|
.unplaced {
|
||||||
@@ -297,29 +315,21 @@
|
|||||||
background: #fcfdff;
|
background: #fcfdff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty { padding: 18px; text-align: center; color: #7a849a; font-size: 13px; }
|
||||||
padding: 18px;
|
|
||||||
text-align: center;
|
|
||||||
color: #7a849a;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.page {
|
.page { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
textarea { min-height: 280px; }
|
||||||
}
|
|
||||||
textarea {
|
|
||||||
min-height: 280px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<!-- ── Left: JSON input ── -->
|
||||||
<div class="panel left">
|
<div class="panel left">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h1>排程预览 JSON 输入</h1>
|
<h1>排程预览 JSON 输入</h1>
|
||||||
<p>粘贴 <code>/api/v1/agent/schedule-preview</code> 响应,点击“解析并渲染”。</p>
|
<p>粘贴 <code>/api/v1/agent/schedule-preview</code> 响应,点击"解析并渲染"。<br>同时支持 <code>candidate_plans</code>(时间表视图)与 <code>hybrid_entries</code>(节次混合视图)。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<textarea id="jsonInput" spellcheck="false"></textarea>
|
<textarea id="jsonInput" spellcheck="false"></textarea>
|
||||||
@@ -332,6 +342,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Right: preview ── -->
|
||||||
<div class="panel right">
|
<div class="panel right">
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
@@ -341,14 +352,36 @@
|
|||||||
<span id="traceMeta">trace_id: -</span>
|
<span id="traceMeta">trace_id: -</span>
|
||||||
<span id="timeMeta">generated_at: -</span>
|
<span id="timeMeta">generated_at: -</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab active" id="tabCandidate">
|
||||||
|
候选方案 <span class="tab-count" id="cntCandidate">0周</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" id="tabHybrid">
|
||||||
|
混合视图 <span class="tab-count" id="cntHybrid">0条</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="summary" id="summaryText">这里会显示排程摘要。</div>
|
<div class="summary" id="summaryText">这里会显示排程摘要。</div>
|
||||||
<div class="legend">
|
|
||||||
<span><i class="dot" style="background:#2f63de"></i>existing(课程/已存在安排)</span>
|
<!-- Legend: candidate mode -->
|
||||||
|
<div class="legend" id="legendCandidate">
|
||||||
|
<span><i class="dot" style="background:#2f63de"></i>existing(课程/已有安排)</span>
|
||||||
<span><i class="dot" style="background:#f27d07"></i>suggested(建议任务)</span>
|
<span><i class="dot" style="background:#f27d07"></i>suggested(建议任务)</span>
|
||||||
<span><i class="dot" style="background:#1d924f"></i>task(普通任务)</span>
|
<span><i class="dot" style="background:#1d924f"></i>task(普通任务)</span>
|
||||||
<span><i class="dot" style="background:#1f8f4f"></i>嵌入任务(显示在课程块内)</span>
|
<span><i class="dot" style="background:#1f8f4f"></i>嵌入任务(显示在课程块内)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend: hybrid mode -->
|
||||||
|
<div class="legend hidden" id="legendHybrid">
|
||||||
|
<span><i class="dot" style="background:#2f63de"></i>existing(已有课程)</span>
|
||||||
|
<span><i class="dot" style="background:#f27d07"></i>suggested(建议任务)</span>
|
||||||
|
<span style="border:1px solid #ffe97a;border-radius:4px;padding:1px 6px;font-size:11px;color:#b08000;">可嵌入</span> 课程可接收嵌入任务
|
||||||
|
<span style="background:#e8eeff;border-radius:4px;padding:1px 6px;font-size:11px;color:#446;">High-Logic / Memory / Review / General</span> 认知类型标签
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-wrap" id="gridWrap">
|
<div class="grid-wrap" id="gridWrap">
|
||||||
<div class="empty">先粘贴 JSON 再渲染课表。</div>
|
<div class="empty">先粘贴 JSON 再渲染课表。</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,11 +408,15 @@
|
|||||||
Object.entries(SECTION_TIME).map(([s, [start]]) => [start, Number(s)])
|
Object.entries(SECTION_TIME).map(([s, [start]]) => [start, Number(s)])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── state ──
|
||||||
const state = {
|
const state = {
|
||||||
raw: null,
|
raw: null,
|
||||||
weekMap: new Map()
|
mode: "candidate", // "candidate" | "hybrid"
|
||||||
|
weekMap: new Map(), // candidate_plans → week → events[]
|
||||||
|
hybridWeekMap: new Map() // hybrid_entries → week → entries[]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── DOM refs ──
|
||||||
const jsonInput = document.getElementById("jsonInput");
|
const jsonInput = document.getElementById("jsonInput");
|
||||||
const renderBtn = document.getElementById("renderBtn");
|
const renderBtn = document.getElementById("renderBtn");
|
||||||
const exampleBtn = document.getElementById("exampleBtn");
|
const exampleBtn = document.getElementById("exampleBtn");
|
||||||
@@ -391,15 +428,18 @@
|
|||||||
const traceMeta = document.getElementById("traceMeta");
|
const traceMeta = document.getElementById("traceMeta");
|
||||||
const timeMeta = document.getElementById("timeMeta");
|
const timeMeta = document.getElementById("timeMeta");
|
||||||
const gridWrap = document.getElementById("gridWrap");
|
const gridWrap = document.getElementById("gridWrap");
|
||||||
|
const tabCandidate = document.getElementById("tabCandidate");
|
||||||
|
const tabHybrid = document.getElementById("tabHybrid");
|
||||||
|
const cntCandidate = document.getElementById("cntCandidate");
|
||||||
|
const cntHybrid = document.getElementById("cntHybrid");
|
||||||
|
const legendCandidate = document.getElementById("legendCandidate");
|
||||||
|
const legendHybrid = document.getElementById("legendHybrid");
|
||||||
|
|
||||||
function setError(message) {
|
// ── helpers ──
|
||||||
errorText.textContent = message || "";
|
function setError(msg) { errorText.textContent = msg || ""; }
|
||||||
}
|
function isObject(v) { return v && typeof v === "object" && !Array.isArray(v); }
|
||||||
|
|
||||||
function isObject(v) {
|
|
||||||
return v && typeof v === "object" && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── candidate_plans normalizer ──
|
||||||
function normalizeEvent(raw, index) {
|
function normalizeEvent(raw, index) {
|
||||||
const e = isObject(raw) ? raw : {};
|
const e = isObject(raw) ? raw : {};
|
||||||
const day = Number(e.day_of_week);
|
const day = Number(e.day_of_week);
|
||||||
@@ -408,7 +448,6 @@
|
|||||||
const sectionFrom = Number.isFinite(guessedFrom) ? guessedFrom : null;
|
const sectionFrom = Number.isFinite(guessedFrom) ? guessedFrom : null;
|
||||||
const sectionTo = sectionFrom !== null ? sectionFrom + span - 1 : null;
|
const sectionTo = sectionFrom !== null ? sectionFrom + span - 1 : null;
|
||||||
const embedded = isObject(e.embedded_task_info) ? e.embedded_task_info : null;
|
const embedded = isObject(e.embedded_task_info) ? e.embedded_task_info : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
__index: index,
|
__index: index,
|
||||||
id: e.id,
|
id: e.id,
|
||||||
@@ -423,35 +462,67 @@
|
|||||||
sectionFrom,
|
sectionFrom,
|
||||||
sectionTo,
|
sectionTo,
|
||||||
embeddedTaskInfo: embedded,
|
embeddedTaskInfo: embedded,
|
||||||
embeddedTasks: []
|
embeddedTasks: [],
|
||||||
|
// hybrid fields not used in this path
|
||||||
|
contextTag: "",
|
||||||
|
canBeEmbedded: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInput(text) {
|
// ── hybrid_entries normalizer ──
|
||||||
const root = JSON.parse(text);
|
function normalizeHybridEntry(raw, index) {
|
||||||
if (!isObject(root)) {
|
const e = isObject(raw) ? raw : {};
|
||||||
throw new Error("根对象必须是 JSON Object。");
|
const day = Number(e.day_of_week);
|
||||||
|
const sf = Number(e.section_from);
|
||||||
|
const st = Number(e.section_to);
|
||||||
|
const validSf = Number.isFinite(sf) && sf >= 1 && sf <= 12;
|
||||||
|
const validSt = Number.isFinite(st) && st >= 1 && st <= 12;
|
||||||
|
return {
|
||||||
|
__index: index,
|
||||||
|
id: e.event_id || e.task_item_id || null,
|
||||||
|
order: 0,
|
||||||
|
dayOfWeek: Number.isFinite(day) && day >= 1 && day <= 7 ? day : null,
|
||||||
|
name: String(e.name || "未命名"),
|
||||||
|
startTime: (validSf && SECTION_TIME[sf]) ? SECTION_TIME[sf][0] : "",
|
||||||
|
endTime: (validSt && SECTION_TIME[st]) ? SECTION_TIME[st][1] : "",
|
||||||
|
type: String(e.type || ""),
|
||||||
|
status: String(e.status || ""),
|
||||||
|
span: (validSf && validSt) ? st - sf + 1 : 1,
|
||||||
|
sectionFrom: validSf ? sf : null,
|
||||||
|
sectionTo: validSt ? st : null,
|
||||||
|
embeddedTaskInfo: null,
|
||||||
|
embeddedTasks: [],
|
||||||
|
// hybrid-specific
|
||||||
|
contextTag: String(e.context_tag || ""),
|
||||||
|
canBeEmbedded: Boolean(e.can_be_embedded),
|
||||||
|
blockForSuggested: Boolean(e.block_for_suggested),
|
||||||
|
taskItemId: e.task_item_id || null,
|
||||||
|
eventId: e.event_id || null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 兼容两种结构:
|
// ── parse API response ──
|
||||||
// 1.1 新结构:{ status, info, data: { ...真正字段... } }
|
function parseInput(text) {
|
||||||
// 1.2 旧结构:{ conversation_id, summary, candidate_plans, ... }
|
const root = JSON.parse(text);
|
||||||
// 2. 若 data 存在则优先使用 data,避免误读外层字段。
|
if (!isObject(root)) throw new Error("根对象必须是 JSON Object。");
|
||||||
const payload = isObject(root.data) ? root.data : root;
|
const payload = isObject(root.data) ? root.data : root;
|
||||||
|
|
||||||
const candidatePlans = Array.isArray(payload.candidate_plans) ? payload.candidate_plans : [];
|
const hasCandidates = Array.isArray(payload.candidate_plans);
|
||||||
if (!Array.isArray(payload.candidate_plans)) {
|
const hasHybrid = Array.isArray(payload.hybrid_entries);
|
||||||
throw new Error("缺少 data.candidate_plans 数组。");
|
if (!hasCandidates && !hasHybrid) {
|
||||||
|
throw new Error("缺少 candidate_plans 或 hybrid_entries 数组。");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
conversationId: String(payload.conversation_id || ""),
|
conversationId: String(payload.conversation_id || ""),
|
||||||
traceId: String(payload.trace_id || ""),
|
traceId: String(payload.trace_id || ""),
|
||||||
generatedAt: String(payload.generated_at || ""),
|
generatedAt: String(payload.generated_at || ""),
|
||||||
summary: String(payload.summary || ""),
|
summary: String(payload.summary || ""),
|
||||||
candidatePlans
|
candidatePlans: hasCandidates ? payload.candidate_plans : [],
|
||||||
|
hybridEntries: hasHybrid ? payload.hybrid_entries : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── build week maps ──
|
||||||
function buildWeekMap(candidatePlans) {
|
function buildWeekMap(candidatePlans) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const plan of candidatePlans) {
|
for (const plan of candidatePlans) {
|
||||||
@@ -460,23 +531,35 @@
|
|||||||
const events = Array.isArray(plan?.events) ? plan.events : [];
|
const events = Array.isArray(plan?.events) ? plan.events : [];
|
||||||
map.set(
|
map.set(
|
||||||
weekNo,
|
weekNo,
|
||||||
events.map((e, i) => normalizeEvent(e, i)).filter((e) => Number.isFinite(e.dayOfWeek) && e.dayOfWeek >= 1 && e.dayOfWeek <= 7)
|
events
|
||||||
|
.map((e, i) => normalizeEvent(e, i))
|
||||||
|
.filter((e) => Number.isFinite(e.dayOfWeek) && e.dayOfWeek >= 1 && e.dayOfWeek <= 7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHybridWeekMap(hybridEntries) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const raw of hybridEntries) {
|
||||||
|
const weekNo = Number(raw?.week);
|
||||||
|
if (!Number.isFinite(weekNo)) continue;
|
||||||
|
const entry = normalizeHybridEntry(raw, 0);
|
||||||
|
if (!Number.isFinite(entry.dayOfWeek)) continue;
|
||||||
|
if (!map.has(weekNo)) map.set(weekNo, []);
|
||||||
|
map.get(weekNo).push(entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── merge overlapping events (candidate mode) ──
|
||||||
function mergeEmbeddedEvents(events) {
|
function mergeEmbeddedEvents(events) {
|
||||||
// 1. 先按“同一天 + 同时间段”聚合,识别课程与任务重叠场景。
|
|
||||||
// 2. 如果同槽位同时出现 existing 课程 + 任务,则把任务并入课程卡片中显示。
|
|
||||||
// 3. 兜底:不满足合并条件的事件保持原样,避免误吞数据。
|
|
||||||
const slotMap = new Map();
|
const slotMap = new Map();
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
|
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
|
||||||
if (!slotMap.has(key)) slotMap.set(key, []);
|
if (!slotMap.has(key)) slotMap.set(key, []);
|
||||||
slotMap.get(key).push(e);
|
slotMap.get(key).push(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = [];
|
const merged = [];
|
||||||
for (const list of slotMap.values()) {
|
for (const list of slotMap.values()) {
|
||||||
const course = list.find((e) => e.type === "course" || e.status === "existing");
|
const course = list.find((e) => e.type === "course" || e.status === "existing");
|
||||||
@@ -484,33 +567,17 @@
|
|||||||
if (course && tasks.length > 0) {
|
if (course && tasks.length > 0) {
|
||||||
const taskLines = [];
|
const taskLines = [];
|
||||||
if (course.embeddedTaskInfo?.name) {
|
if (course.embeddedTaskInfo?.name) {
|
||||||
taskLines.push({
|
taskLines.push({ id: course.embeddedTaskInfo.id, name: course.embeddedTaskInfo.name, type: course.embeddedTaskInfo.type || "task", from: "embedded_task_info" });
|
||||||
id: course.embeddedTaskInfo.id,
|
|
||||||
name: course.embeddedTaskInfo.name,
|
|
||||||
type: course.embeddedTaskInfo.type || "task",
|
|
||||||
from: "embedded_task_info"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
taskLines.push({
|
taskLines.push({ id: t.id, name: t.name, type: t.type || "task", status: t.status || "", from: "overlap_event" });
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
type: t.type || "task",
|
|
||||||
status: t.status || "",
|
|
||||||
from: "overlap_event"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
course.embeddedTasks = taskLines;
|
course.embeddedTasks = taskLines;
|
||||||
merged.push(course);
|
merged.push(course);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (course && course.embeddedTaskInfo?.name) {
|
if (course && course.embeddedTaskInfo?.name) {
|
||||||
course.embeddedTasks = [{
|
course.embeddedTasks = [{ id: course.embeddedTaskInfo.id, name: course.embeddedTaskInfo.name, type: course.embeddedTaskInfo.type || "task", from: "embedded_task_info" }];
|
||||||
id: course.embeddedTaskInfo.id,
|
|
||||||
name: course.embeddedTaskInfo.name,
|
|
||||||
type: course.embeddedTaskInfo.type || "task",
|
|
||||||
from: "embedded_task_info"
|
|
||||||
}];
|
|
||||||
merged.push(course);
|
merged.push(course);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -519,16 +586,42 @@
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── merge overlapping entries (hybrid mode) ──
|
||||||
|
// existing 课程 canBeEmbedded=true 且同槽有 suggested 任务 → 嵌入显示
|
||||||
|
function mergeHybridEntries(entries) {
|
||||||
|
const slotMap = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
|
||||||
|
if (!slotMap.has(key)) slotMap.set(key, []);
|
||||||
|
slotMap.get(key).push(e);
|
||||||
|
}
|
||||||
|
const merged = [];
|
||||||
|
for (const list of slotMap.values()) {
|
||||||
|
const course = list.find((e) => e.type === "course" && e.status === "existing" && e.canBeEmbedded);
|
||||||
|
const others = list.filter((e) => e !== course);
|
||||||
|
if (course && others.length > 0) {
|
||||||
|
course.embeddedTasks = others.map((t) => ({
|
||||||
|
id: t.taskItemId,
|
||||||
|
name: t.name,
|
||||||
|
type: t.type || "task",
|
||||||
|
status: t.status || "",
|
||||||
|
contextTag: t.contextTag
|
||||||
|
}));
|
||||||
|
merged.push(course);
|
||||||
|
} else {
|
||||||
|
merged.push(...list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lane assignment (shared) ──
|
||||||
function assignLanesByDay(events) {
|
function assignLanesByDay(events) {
|
||||||
// 1. 按“每天”独立分配 lane,避免跨天互相影响宽度。
|
|
||||||
// 2. 用贪心法给重叠区间分轨:能复用轨道就复用,否则开新轨道。
|
|
||||||
// 3. 最后给每个事件附上 dayLaneCount,渲染时按轨道等分宽度。
|
|
||||||
const byDay = new Map();
|
const byDay = new Map();
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (!byDay.has(e.dayOfWeek)) byDay.set(e.dayOfWeek, []);
|
if (!byDay.has(e.dayOfWeek)) byDay.set(e.dayOfWeek, []);
|
||||||
byDay.get(e.dayOfWeek).push(e);
|
byDay.get(e.dayOfWeek).push(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [, dayEvents] of byDay) {
|
for (const [, dayEvents] of byDay) {
|
||||||
dayEvents.sort((a, b) => {
|
dayEvents.sort((a, b) => {
|
||||||
if (a.sectionFrom !== b.sectionFrom) return a.sectionFrom - b.sectionFrom;
|
if (a.sectionFrom !== b.sectionFrom) return a.sectionFrom - b.sectionFrom;
|
||||||
@@ -539,15 +632,9 @@
|
|||||||
for (const e of dayEvents) {
|
for (const e of dayEvents) {
|
||||||
let lane = -1;
|
let lane = -1;
|
||||||
for (let i = 0; i < laneEnds.length; i += 1) {
|
for (let i = 0; i < laneEnds.length; i += 1) {
|
||||||
if (e.sectionFrom > laneEnds[i]) {
|
if (e.sectionFrom > laneEnds[i]) { lane = i; break; }
|
||||||
lane = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lane === -1) {
|
|
||||||
lane = laneEnds.length;
|
|
||||||
laneEnds.push(0);
|
|
||||||
}
|
}
|
||||||
|
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
|
||||||
laneEnds[lane] = e.sectionTo;
|
laneEnds[lane] = e.sectionTo;
|
||||||
e.__lane = lane;
|
e.__lane = lane;
|
||||||
}
|
}
|
||||||
@@ -556,15 +643,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWeekGrid(events) {
|
// ── build grid DOM ──
|
||||||
|
function buildWeekGrid(events, mode) {
|
||||||
const weekGrid = document.createElement("div");
|
const weekGrid = document.createElement("div");
|
||||||
weekGrid.className = "week-grid";
|
weekGrid.className = "week-grid";
|
||||||
|
|
||||||
|
// header row
|
||||||
const topLeft = document.createElement("div");
|
const topLeft = document.createElement("div");
|
||||||
topLeft.className = "cell head";
|
topLeft.className = "cell head";
|
||||||
topLeft.textContent = "节次";
|
topLeft.textContent = "节次";
|
||||||
weekGrid.appendChild(topLeft);
|
weekGrid.appendChild(topLeft);
|
||||||
|
|
||||||
for (let i = 0; i < 7; i += 1) {
|
for (let i = 0; i < 7; i += 1) {
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
head.className = "cell head";
|
head.className = "cell head";
|
||||||
@@ -574,15 +662,15 @@
|
|||||||
weekGrid.appendChild(head);
|
weekGrid.appendChild(head);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// section rows + blank slots
|
||||||
for (let section = 1; section <= 12; section += 1) {
|
for (let section = 1; section <= 12; section += 1) {
|
||||||
const [start, end] = SECTION_TIME[section];
|
const [start, end] = SECTION_TIME[section];
|
||||||
const label = document.createElement("div");
|
const label = document.createElement("div");
|
||||||
label.className = "cell section";
|
label.className = "cell section";
|
||||||
label.style.gridColumn = "1";
|
label.style.gridColumn = "1";
|
||||||
label.style.gridRow = String(section + 1);
|
label.style.gridRow = String(section + 1);
|
||||||
label.innerHTML = `<div>${section}</div><div>${start}-${end}</div>`;
|
label.innerHTML = `<div>${section}</div><div>${start}–${end}</div>`;
|
||||||
weekGrid.appendChild(label);
|
weekGrid.appendChild(label);
|
||||||
|
|
||||||
for (let day = 1; day <= 7; day += 1) {
|
for (let day = 1; day <= 7; day += 1) {
|
||||||
const slot = document.createElement("div");
|
const slot = document.createElement("div");
|
||||||
slot.className = "cell slot";
|
slot.className = "cell slot";
|
||||||
@@ -592,6 +680,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// event cards
|
||||||
const unplaced = [];
|
const unplaced = [];
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (!Number.isFinite(e.sectionFrom) || !Number.isFinite(e.sectionTo)) {
|
if (!Number.isFinite(e.sectionFrom) || !Number.isFinite(e.sectionTo)) {
|
||||||
@@ -609,35 +698,60 @@
|
|||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "event";
|
card.className = "event";
|
||||||
if (e.status === "existing" || e.type === "course") {
|
if (e.status === "existing" || e.type === "course") card.classList.add("existing");
|
||||||
card.classList.add("existing");
|
else if (e.status === "suggested") card.classList.add("suggested");
|
||||||
} else if (e.status === "suggested") {
|
else card.classList.add("task");
|
||||||
card.classList.add("suggested");
|
|
||||||
} else {
|
|
||||||
card.classList.add("task");
|
|
||||||
}
|
|
||||||
card.style.gridColumn = String(col);
|
card.style.gridColumn = String(col);
|
||||||
card.style.gridRow = `${rowStart}/${rowEnd}`;
|
card.style.gridRow = `${rowStart}/${rowEnd}`;
|
||||||
card.style.width = width;
|
card.style.width = width;
|
||||||
card.style.marginLeft = left;
|
card.style.marginLeft = left;
|
||||||
|
|
||||||
|
// title
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "title";
|
header.className = "title";
|
||||||
header.textContent = e.name;
|
header.textContent = e.name;
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
|
|
||||||
|
// detail line
|
||||||
const detail = document.createElement("div");
|
const detail = document.createElement("div");
|
||||||
detail.className = "meta-text";
|
detail.className = "meta-text";
|
||||||
detail.textContent = `${e.startTime || "-"} - ${e.endTime || "-"} | ${e.type || "-"} | ${e.status || "-"}`;
|
if (mode === "hybrid") {
|
||||||
|
detail.textContent = `第 ${e.sectionFrom}–${e.sectionTo} 节 | ${e.type || "-"} | ${e.status || "-"}`;
|
||||||
|
} else {
|
||||||
|
detail.textContent = `${e.startTime || "-"} – ${e.endTime || "-"} | ${e.type || "-"} | ${e.status || "-"}`;
|
||||||
|
}
|
||||||
card.appendChild(detail);
|
card.appendChild(detail);
|
||||||
|
|
||||||
|
// hybrid badges (context_tag / can_be_embedded)
|
||||||
|
if (mode === "hybrid" && (e.contextTag || e.canBeEmbedded)) {
|
||||||
|
const badgeRow = document.createElement("div");
|
||||||
|
badgeRow.className = "badge-row";
|
||||||
|
if (e.contextTag) {
|
||||||
|
const ct = document.createElement("span");
|
||||||
|
ct.className = "ctx-tag";
|
||||||
|
ct.textContent = e.contextTag;
|
||||||
|
badgeRow.appendChild(ct);
|
||||||
|
}
|
||||||
|
if (e.canBeEmbedded) {
|
||||||
|
const eb = document.createElement("span");
|
||||||
|
eb.className = "embed-badge";
|
||||||
|
eb.textContent = "可嵌入";
|
||||||
|
badgeRow.appendChild(eb);
|
||||||
|
}
|
||||||
|
card.appendChild(badgeRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedded tasks
|
||||||
if (Array.isArray(e.embeddedTasks) && e.embeddedTasks.length > 0) {
|
if (Array.isArray(e.embeddedTasks) && e.embeddedTasks.length > 0) {
|
||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
list.className = "embedded-list";
|
list.className = "embedded-list";
|
||||||
for (const task of e.embeddedTasks) {
|
for (const task of e.embeddedTasks) {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "embedded-item";
|
item.className = "embedded-item";
|
||||||
item.textContent = `嵌入:${task.name}${task.status ? `(${task.status})` : ""}`;
|
const ctPart = task.contextTag ? ` [${task.contextTag}]` : "";
|
||||||
|
const statPart = task.status ? `(${task.status})` : "";
|
||||||
|
item.textContent = `嵌入:${task.name}${ctPart}${statPart}`;
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
}
|
}
|
||||||
card.appendChild(list);
|
card.appendChild(list);
|
||||||
@@ -652,15 +766,26 @@
|
|||||||
if (unplaced.length > 0) {
|
if (unplaced.length > 0) {
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = "unplaced";
|
box.className = "unplaced";
|
||||||
box.innerHTML = `<strong>未定位到节次的事件(无法映射 start_time)</strong><br>${unplaced.map((e) => `- D${e.dayOfWeek} ${e.name} (${e.startTime || "?"} - ${e.endTime || "?"})`).join("<br>")}`;
|
const rows = unplaced.map((e) => {
|
||||||
|
const coord = mode === "hybrid"
|
||||||
|
? `节次=${e.sectionFrom ?? "?"}–${e.sectionTo ?? "?"}`
|
||||||
|
: `${e.startTime || "?"}–${e.endTime || "?"}`;
|
||||||
|
return `- D${e.dayOfWeek} ${e.name} (${coord})`;
|
||||||
|
});
|
||||||
|
box.innerHTML = `<strong>未定位到节次的条目(坐标无效)</strong><br>${rows.join("<br>")}`;
|
||||||
container.appendChild(box);
|
container.appendChild(box);
|
||||||
}
|
}
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── render week ──
|
||||||
function renderWeek(weekNo) {
|
function renderWeek(weekNo) {
|
||||||
const weekEvents = state.weekMap.get(weekNo) || [];
|
const map = state.mode === "hybrid" ? state.hybridWeekMap : state.weekMap;
|
||||||
const merged = mergeEmbeddedEvents(weekEvents.map((e) => ({ ...e })));
|
const raw = (map.get(weekNo) || []).map((e) => ({ ...e }));
|
||||||
|
const merged = state.mode === "hybrid"
|
||||||
|
? mergeHybridEntries(raw)
|
||||||
|
: mergeEmbeddedEvents(raw);
|
||||||
|
|
||||||
assignLanesByDay(merged.filter((e) => Number.isFinite(e.sectionFrom) && Number.isFinite(e.sectionTo)));
|
assignLanesByDay(merged.filter((e) => Number.isFinite(e.sectionFrom) && Number.isFinite(e.sectionTo)));
|
||||||
|
|
||||||
gridWrap.innerHTML = "";
|
gridWrap.innerHTML = "";
|
||||||
@@ -668,18 +793,12 @@
|
|||||||
gridWrap.innerHTML = `<div class="empty">第 ${weekNo} 周没有可显示的事件。</div>`;
|
gridWrap.innerHTML = `<div class="empty">第 ${weekNo} 周没有可显示的事件。</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gridWrap.appendChild(buildWeekGrid(merged));
|
gridWrap.appendChild(buildWeekGrid(merged, state.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillMeta(parsed) {
|
// ── populate week selector ──
|
||||||
conversationMeta.textContent = `conversation_id: ${parsed.conversationId || "-"}`;
|
function fillWeekSelector(weeks) {
|
||||||
traceMeta.textContent = `trace_id: ${parsed.traceId || "-"}`;
|
const currentWeek = Number(weekSelect.value);
|
||||||
timeMeta.textContent = `generated_at: ${parsed.generatedAt || "-"}`;
|
|
||||||
summaryText.textContent = parsed.summary || "(无摘要)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillWeekSelector() {
|
|
||||||
const weeks = Array.from(state.weekMap.keys()).sort((a, b) => a - b);
|
|
||||||
weekSelect.innerHTML = "";
|
weekSelect.innerHTML = "";
|
||||||
for (const week of weeks) {
|
for (const week of weeks) {
|
||||||
const op = document.createElement("option");
|
const op = document.createElement("option");
|
||||||
@@ -687,28 +806,72 @@
|
|||||||
op.textContent = `第 ${week} 周`;
|
op.textContent = `第 ${week} 周`;
|
||||||
weekSelect.appendChild(op);
|
weekSelect.appendChild(op);
|
||||||
}
|
}
|
||||||
if (weeks.length > 0) renderWeek(weeks[0]);
|
// keep current week if available in new map
|
||||||
|
if (weeks.includes(currentWeek)) weekSelect.value = String(currentWeek);
|
||||||
|
if (weeks.length > 0) renderWeek(Number(weekSelect.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── switch tab mode ──
|
||||||
|
function switchMode(newMode) {
|
||||||
|
state.mode = newMode;
|
||||||
|
tabCandidate.classList.toggle("active", newMode === "candidate");
|
||||||
|
tabHybrid.classList.toggle("active", newMode === "hybrid");
|
||||||
|
legendCandidate.classList.toggle("hidden", newMode !== "candidate");
|
||||||
|
legendHybrid.classList.toggle("hidden", newMode !== "hybrid");
|
||||||
|
|
||||||
|
const map = newMode === "hybrid" ? state.hybridWeekMap : state.weekMap;
|
||||||
|
if (map.size === 0) {
|
||||||
|
weekSelect.innerHTML = "";
|
||||||
|
const label = newMode === "hybrid" ? "hybrid_entries" : "candidate_plans";
|
||||||
|
gridWrap.innerHTML = `<div class="empty">${label} 为空,当前没有可渲染的周课表。</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weeks = Array.from(map.keys()).sort((a, b) => a - b);
|
||||||
|
fillWeekSelector(weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fill meta header ──
|
||||||
|
function fillMeta(parsed) {
|
||||||
|
conversationMeta.textContent = `conversation_id: ${parsed.conversationId || "-"}`;
|
||||||
|
traceMeta.textContent = `trace_id: ${parsed.traceId || "-"}`;
|
||||||
|
timeMeta.textContent = `generated_at: ${parsed.generatedAt || "-"}`;
|
||||||
|
summaryText.textContent = parsed.summary || "(无摘要)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── update tab count badges ──
|
||||||
|
function updateTabCounts(weekMap, hybridWeekMap, hybridTotal) {
|
||||||
|
cntCandidate.textContent = `${weekMap.size} 周`;
|
||||||
|
cntHybrid.textContent = `${hybridWeekMap.size} 周 · ${hybridTotal} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main render entry ──
|
||||||
function renderFromInput() {
|
function renderFromInput() {
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const parsed = parseInput(jsonInput.value.trim());
|
const parsed = parseInput(jsonInput.value.trim());
|
||||||
const map = buildWeekMap(parsed.candidatePlans);
|
const weekMap = buildWeekMap(parsed.candidatePlans);
|
||||||
|
const hybridWeekMap = buildHybridWeekMap(parsed.hybridEntries);
|
||||||
|
|
||||||
state.raw = parsed;
|
state.raw = parsed;
|
||||||
state.weekMap = map;
|
state.weekMap = weekMap;
|
||||||
|
state.hybridWeekMap = hybridWeekMap;
|
||||||
|
|
||||||
fillMeta(parsed);
|
fillMeta(parsed);
|
||||||
if (map.size === 0) {
|
updateTabCounts(weekMap, hybridWeekMap, parsed.hybridEntries.length);
|
||||||
weekSelect.innerHTML = "";
|
|
||||||
gridWrap.innerHTML = `<div class="empty">candidate_plans 为空,当前没有可渲染的周课表。</div>`;
|
// auto-select tab: prefer current mode if it has data, else pick whichever has data
|
||||||
} else {
|
let targetMode = state.mode;
|
||||||
fillWeekSelector();
|
const currentHasData = targetMode === "hybrid" ? hybridWeekMap.size > 0 : weekMap.size > 0;
|
||||||
|
if (!currentHasData) {
|
||||||
|
targetMode = weekMap.size > 0 ? "candidate" : (hybridWeekMap.size > 0 ? "hybrid" : state.mode);
|
||||||
}
|
}
|
||||||
|
switchMode(targetMode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`解析失败:${err.message || err}`);
|
setError(`解析失败:${err.message || err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── example data ──
|
||||||
function setExample() {
|
function setExample() {
|
||||||
jsonInput.value = JSON.stringify({
|
jsonInput.value = JSON.stringify({
|
||||||
status: "10000",
|
status: "10000",
|
||||||
@@ -717,7 +880,8 @@
|
|||||||
conversation_id: "demo-conv-001",
|
conversation_id: "demo-conv-001",
|
||||||
trace_id: "demo-trace-001",
|
trace_id: "demo-trace-001",
|
||||||
summary: "本周建议将高强度任务集中在上午,课程空档用于轻量复习。",
|
summary: "本周建议将高强度任务集中在上午,课程空档用于轻量复习。",
|
||||||
generated_at: "2026-03-22T15:12:00+08:00",
|
generated_at: "2026-04-07T15:12:00+08:00",
|
||||||
|
|
||||||
candidate_plans: [
|
candidate_plans: [
|
||||||
{
|
{
|
||||||
week: 13,
|
week: 13,
|
||||||
@@ -727,41 +891,51 @@
|
|||||||
{ day_of_week: 1, name: "数据结构复习", start_time: "10:15", end_time: "11:55", type: "task", span: 2, status: "suggested" },
|
{ day_of_week: 1, name: "数据结构复习", start_time: "10:15", end_time: "11:55", type: "task", span: 2, status: "suggested" },
|
||||||
{ day_of_week: 2, name: "英语阅读", start_time: "19:00", end_time: "20:40", type: "task", span: 2, status: "suggested" },
|
{ day_of_week: 2, name: "英语阅读", start_time: "19:00", end_time: "20:40", type: "task", span: 2, status: "suggested" },
|
||||||
{
|
{
|
||||||
day_of_week: 3,
|
day_of_week: 3, name: "离散数学", start_time: "14:00", end_time: "15:40",
|
||||||
name: "离散数学",
|
type: "course", span: 2, status: "existing",
|
||||||
start_time: "14:00",
|
|
||||||
end_time: "15:40",
|
|
||||||
type: "course",
|
|
||||||
span: 2,
|
|
||||||
status: "existing",
|
|
||||||
embedded_task_info: { id: 99, name: "离散小测回顾", type: "task" }
|
embedded_task_info: { id: 99, name: "离散小测回顾", type: "task" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
hybrid_entries: [
|
||||||
|
{ week: 13, day_of_week: 1, section_from: 1, section_to: 2, name: "高等数学", type: "course", status: "existing", event_id: 101, can_be_embedded: false, block_for_suggested: true },
|
||||||
|
{ week: 13, day_of_week: 1, section_from: 1, section_to: 2, name: "数电错题整理", type: "task", status: "suggested", task_item_id: 201, context_tag: "Review", block_for_suggested: true },
|
||||||
|
{ week: 13, day_of_week: 1, section_from: 3, section_to: 4, name: "数据结构复习", type: "task", status: "suggested", task_item_id: 202, context_tag: "High-Logic", block_for_suggested: true },
|
||||||
|
{ week: 13, day_of_week: 2, section_from: 9, section_to: 10, name: "英语阅读", type: "task", status: "suggested", task_item_id: 203, context_tag: "Memory", block_for_suggested: true },
|
||||||
|
{ week: 13, day_of_week: 3, section_from: 5, section_to: 6, name: "离散数学", type: "course", status: "existing", event_id: 102, can_be_embedded: true, block_for_suggested: false },
|
||||||
|
{ week: 13, day_of_week: 3, section_from: 5, section_to: 6, name: "离散小测回顾", type: "task", status: "suggested", task_item_id: 204, context_tag: "Review", block_for_suggested: false },
|
||||||
|
{ week: 13, day_of_week: 4, section_from: 7, section_to: 8, name: "操作系统", type: "course", status: "existing", event_id: 103, can_be_embedded: false, block_for_suggested: true },
|
||||||
|
{ week: 13, day_of_week: 5, section_from: 3, section_to: 4, name: "概率论刷题", type: "task", status: "suggested", task_item_id: 205, context_tag: "High-Logic", block_for_suggested: true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── event listeners ──
|
||||||
renderBtn.addEventListener("click", renderFromInput);
|
renderBtn.addEventListener("click", renderFromInput);
|
||||||
weekSelect.addEventListener("change", () => renderWeek(Number(weekSelect.value)));
|
weekSelect.addEventListener("change", () => renderWeek(Number(weekSelect.value)));
|
||||||
exampleBtn.addEventListener("click", () => {
|
tabCandidate.addEventListener("click", () => { if (state.mode !== "candidate") switchMode("candidate"); });
|
||||||
setExample();
|
tabHybrid.addEventListener("click", () => { if (state.mode !== "hybrid") switchMode("hybrid"); });
|
||||||
renderFromInput();
|
exampleBtn.addEventListener("click", () => { setExample(); renderFromInput(); });
|
||||||
});
|
|
||||||
clearBtn.addEventListener("click", () => {
|
clearBtn.addEventListener("click", () => {
|
||||||
jsonInput.value = "";
|
jsonInput.value = "";
|
||||||
setError("");
|
setError("");
|
||||||
state.raw = null;
|
state.raw = null;
|
||||||
state.weekMap = new Map();
|
state.weekMap = new Map();
|
||||||
|
state.hybridWeekMap = new Map();
|
||||||
summaryText.textContent = "这里会显示排程摘要。";
|
summaryText.textContent = "这里会显示排程摘要。";
|
||||||
conversationMeta.textContent = "conversation_id: -";
|
conversationMeta.textContent = "conversation_id: -";
|
||||||
traceMeta.textContent = "trace_id: -";
|
traceMeta.textContent = "trace_id: -";
|
||||||
timeMeta.textContent = "generated_at: -";
|
timeMeta.textContent = "generated_at: -";
|
||||||
|
cntCandidate.textContent = "0周";
|
||||||
|
cntHybrid.textContent = "0条";
|
||||||
weekSelect.innerHTML = "";
|
weekSelect.innerHTML = "";
|
||||||
gridWrap.innerHTML = `<div class="empty">先粘贴 JSON 再渲染课表。</div>`;
|
gridWrap.innerHTML = `<div class="empty">先粘贴 JSON 再渲染课表。</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// init with example
|
||||||
setExample();
|
setExample();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user