diff --git a/backend/cmd/start.go b/backend/cmd/start.go index e37c976..e5776b2 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -288,7 +288,7 @@ func Start() { } return created.ID, nil }, - QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) { + QueryTasks: func(ctx context.Context, userID int, params newagentmodel.TaskQueryParams) ([]newagentmodel.TaskQueryResult, error) { req := newagentmodel.TaskQueryRequest{ UserID: userID, Quadrant: params.Quadrant, @@ -304,13 +304,13 @@ func Start() { if err != nil { return nil, err } - results := make([]newagenttools.TaskQueryResult, 0, len(records)) + results := make([]newagentmodel.TaskQueryResult, 0, len(records)) for _, r := range records { deadlineStr := "" if r.DeadlineAt != nil { deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04") } - results = append(results, newagenttools.TaskQueryResult{ + results = append(results, newagentmodel.TaskQueryResult{ ID: r.ID, Title: r.Title, PriorityGroup: r.PriorityGroup, diff --git a/backend/logic/refine_compound_ops.go b/backend/logic/refine_compound_ops.go deleted file mode 100644 index 40c75fd..0000000 --- a/backend/logic/refine_compound_ops.go +++ /dev/null @@ -1,473 +0,0 @@ -package logic - -import ( - "fmt" - "sort" - "strings" -) - -// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。 -// -// 职责边界: -// 1. 只承载“任务当前坐标 + 规划所需标签”; -// 2. 不承载冲突判断、窗口判断等执行期逻辑; -// 3. 由调用方保证 task_item_id 唯一且为正数。 -type RefineTaskCandidate struct { - TaskItemID int - Week int - DayOfWeek int - SectionFrom int - SectionTo int - Name string - ContextTag string - OriginRank int -} - -// RefineSlotCandidate 表示复合工具可选落点(坑位)。 -// -// 职责边界: -// 1. 只描述可候选的时段坐标; -// 2. 不描述“为什么可用”,可用性由调用方预先筛好; -// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。 -type RefineSlotCandidate struct { - Week int - DayOfWeek int - SectionFrom int - SectionTo int -} - -// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。 -type RefineMovePlanItem struct { - TaskItemID int - ToWeek int - ToDay int - ToSectionFrom int - ToSectionTo int -} - -// RefineCompositePlanOptions 是复合规划器的可选辅助输入。 -// -// 说明: -// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分; -// 2. key 约定为 "week-day",例如 "16-3"; -// 3. 未提供时,规划器按 0 基线处理。 -type RefineCompositePlanOptions struct { - ExistingDayLoad map[string]int -} - -// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。 -// -// 步骤化说明: -// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出; -// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度; -// 3. 同分时按时间更早优先,进一步保证确定性; -// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。 -func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) { - normalizedTasks, err := normalizeRefineTasks(tasks) - if err != nil { - return nil, err - } - normalizedSlots, err := normalizeRefineSlots(slots) - if err != nil { - return nil, err - } - if len(normalizedSlots) < len(normalizedTasks) { - return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots)) - } - - // 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。 - // 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。 - dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots)) - for key, value := range options.ExistingDayLoad { - if value <= 0 { - continue - } - dayLoad[strings.TrimSpace(key)] = value - } - - used := make([]bool, len(normalizedSlots)) - moves := make([]RefineMovePlanItem, 0, len(normalizedTasks)) - selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks)) - - for _, task := range normalizedTasks { - taskSpan := sectionSpan(task.SectionFrom, task.SectionTo) - bestIdx := -1 - bestScore := int(^uint(0) >> 1) // max int - - for idx, slot := range normalizedSlots { - if used[idx] { - continue - } - if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan { - continue - } - if slotOverlapsAny(slot, selectedSlots) { - continue - } - dayKey := composeDayKey(slot.Week, slot.DayOfWeek) - projectedLoad := dayLoad[dayKey] + 1 - // 1. projectedLoad 是主目标(越小越均衡); - // 2. idx 是次级目标(越早的坑位越优先,保证稳定)。 - score := projectedLoad*10000 + idx - if score < bestScore { - bestScore = score - bestIdx = idx - } - } - if bestIdx < 0 { - return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID) - } - - chosen := normalizedSlots[bestIdx] - used[bestIdx] = true - selectedSlots = append(selectedSlots, chosen) - dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++ - moves = append(moves, RefineMovePlanItem{ - TaskItemID: task.TaskItemID, - ToWeek: chosen.Week, - ToDay: chosen.DayOfWeek, - ToSectionFrom: chosen.SectionFrom, - ToSectionTo: chosen.SectionTo, - }) - } - return moves, nil -} - -// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。 -// -// 步骤化说明: -// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续; -// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序; -// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位; -// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。 -func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) { - normalizedTasks, err := normalizeRefineTasks(tasks) - if err != nil { - return nil, err - } - normalizedSlots, err := normalizeRefineSlots(slots) - if err != nil { - return nil, err - } - if len(normalizedSlots) < len(normalizedTasks) { - return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots)) - } - - type taskGroup struct { - ContextKey string - Tasks []RefineTaskCandidate - MinRank int - } - groupingKeys := buildMinContextGroupingKeys(normalizedTasks) - groupMap := make(map[string]*taskGroup) - groupOrder := make([]string, 0, len(normalizedTasks)) - - for _, task := range normalizedTasks { - key := groupingKeys[task.TaskItemID] - group, exists := groupMap[key] - if !exists { - group = &taskGroup{ - ContextKey: key, - MinRank: normalizedOriginRank(task), - } - groupMap[key] = group - groupOrder = append(groupOrder, key) - } - group.Tasks = append(group.Tasks, task) - if rank := normalizedOriginRank(task); rank < group.MinRank { - group.MinRank = rank - } - } - - groups := make([]taskGroup, 0, len(groupMap)) - for _, key := range groupOrder { - group := groupMap[key] - sort.SliceStable(group.Tasks, func(i, j int) bool { - return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0 - }) - groups = append(groups, *group) - } - sort.SliceStable(groups, func(i, j int) bool { - if len(groups[i].Tasks) != len(groups[j].Tasks) { - return len(groups[i].Tasks) > len(groups[j].Tasks) - } - if groups[i].MinRank != groups[j].MinRank { - return groups[i].MinRank < groups[j].MinRank - } - return groups[i].ContextKey < groups[j].ContextKey - }) - - orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks)) - for _, group := range groups { - orderedTasks = append(orderedTasks, group.Tasks...) - } - - used := make([]bool, len(normalizedSlots)) - moves := make([]RefineMovePlanItem, 0, len(orderedTasks)) - selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks)) - for _, task := range orderedTasks { - taskSpan := sectionSpan(task.SectionFrom, task.SectionTo) - chosenIdx := -1 - for idx, slot := range normalizedSlots { - if used[idx] { - continue - } - if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan { - continue - } - if slotOverlapsAny(slot, selectedSlots) { - continue - } - chosenIdx = idx - break - } - if chosenIdx < 0 { - return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID) - } - chosen := normalizedSlots[chosenIdx] - used[chosenIdx] = true - selectedSlots = append(selectedSlots, chosen) - moves = append(moves, RefineMovePlanItem{ - TaskItemID: task.TaskItemID, - ToWeek: chosen.Week, - ToDay: chosen.DayOfWeek, - ToSectionFrom: chosen.SectionFrom, - ToSectionTo: chosen.SectionTo, - }) - } - return moves, nil -} - -func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) { - if len(tasks) == 0 { - return nil, fmt.Errorf("任务列表为空") - } - normalized := make([]RefineTaskCandidate, 0, len(tasks)) - seen := make(map[int]struct{}, len(tasks)) - for _, task := range tasks { - if task.TaskItemID <= 0 { - return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID) - } - if _, exists := seen[task.TaskItemID]; exists { - return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID) - } - if !isValidDay(task.DayOfWeek) { - return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek) - } - if !isValidSection(task.SectionFrom, task.SectionTo) { - return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo) - } - seen[task.TaskItemID] = struct{}{} - normalized = append(normalized, task) - } - sort.SliceStable(normalized, func(i, j int) bool { - return compareTaskOrder(normalized[i], normalized[j]) < 0 - }) - return normalized, nil -} - -func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) { - if len(slots) == 0 { - return nil, fmt.Errorf("可用坑位为空") - } - normalized := make([]RefineSlotCandidate, 0, len(slots)) - seen := make(map[string]struct{}, len(slots)) - for _, slot := range slots { - if slot.Week <= 0 { - return nil, fmt.Errorf("存在非法 week=%d", slot.Week) - } - if !isValidDay(slot.DayOfWeek) { - return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek) - } - if !isValidSection(slot.SectionFrom, slot.SectionTo) { - return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo) - } - key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - normalized = append(normalized, slot) - } - sort.SliceStable(normalized, func(i, j int) bool { - if normalized[i].Week != normalized[j].Week { - return normalized[i].Week < normalized[j].Week - } - if normalized[i].DayOfWeek != normalized[j].DayOfWeek { - return normalized[i].DayOfWeek < normalized[j].DayOfWeek - } - if normalized[i].SectionFrom != normalized[j].SectionFrom { - return normalized[i].SectionFrom < normalized[j].SectionFrom - } - return normalized[i].SectionTo < normalized[j].SectionTo - }) - return normalized, nil -} - -func compareTaskOrder(a, b RefineTaskCandidate) int { - rankA := normalizedOriginRank(a) - rankB := normalizedOriginRank(b) - if rankA != rankB { - return rankA - rankB - } - if a.Week != b.Week { - return a.Week - b.Week - } - if a.DayOfWeek != b.DayOfWeek { - return a.DayOfWeek - b.DayOfWeek - } - if a.SectionFrom != b.SectionFrom { - return a.SectionFrom - b.SectionFrom - } - if a.SectionTo != b.SectionTo { - return a.SectionTo - b.SectionTo - } - return a.TaskItemID - b.TaskItemID -} - -func normalizedOriginRank(task RefineTaskCandidate) int { - if task.OriginRank > 0 { - return task.OriginRank - } - // 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。 - // 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。 - return 1_000_000 + task.TaskItemID -} - -func normalizeContextKey(tag string) string { - text := strings.TrimSpace(tag) - if text == "" { - return "General" - } - return text -} - -// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。 -// -// 步骤化说明: -// 1. 先优先使用现有 ContextTag,避免影响已稳定的显式标签链路; -// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic),说明标签对“同科目连续”帮助不足; -// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用; -// 4. 若任务名也无法识别,则继续回落到原 ContextTag,保证行为可预测。 -func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string { - keys := make(map[int]string, len(tasks)) - distinctExplicit := make(map[string]struct{}, len(tasks)) - distinctNonCoarse := make(map[string]struct{}, len(tasks)) - - for _, task := range tasks { - key := normalizeContextKey(task.ContextTag) - keys[task.TaskItemID] = key - distinctExplicit[key] = struct{}{} - if !isCoarseContextKey(key) { - distinctNonCoarse[key] = struct{}{} - } - } - - // 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义; - // 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。 - if len(distinctNonCoarse) >= 2 { - return keys - } - // 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用; - // 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。 - if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 { - return keys - } - - inferredKeys := make(map[int]string, len(tasks)) - distinctInferred := make(map[string]struct{}, len(tasks)) - for _, task := range tasks { - inferred := inferSubjectContextKeyFromTaskName(task.Name) - if inferred == "" { - inferred = keys[task.TaskItemID] - } - inferredKeys[task.TaskItemID] = inferred - distinctInferred[inferred] = struct{}{} - } - if len(distinctInferred) >= 2 { - return inferredKeys - } - return keys -} - -func isCoarseContextKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "", "general", "high-logic", "high_logic", "memory", "review": - return true - default: - return false - } -} - -func inferSubjectContextKeyFromTaskName(name string) string { - text := strings.ToLower(strings.TrimSpace(name)) - if text == "" { - return "" - } - - subjectKeywordGroups := []struct { - keywords []string - groupKey string - }{ - { - keywords: []string{ - "概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯", - "分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数", - }, - groupKey: "subject:probability", - }, - { - keywords: []string{ - "数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器", - "数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电", - }, - groupKey: "subject:digital_logic", - }, - { - keywords: []string{ - "命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数", - "图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推", - }, - groupKey: "subject:discrete_math", - }, - } - for _, group := range subjectKeywordGroups { - for _, keyword := range group.keywords { - if strings.Contains(text, keyword) { - return group.groupKey - } - } - } - return "" -} - -func composeDayKey(week, day int) string { - return fmt.Sprintf("%d-%d", week, day) -} - -func sectionSpan(from, to int) int { - return to - from + 1 -} - -func isValidDay(day int) bool { - return day >= 1 && day <= 7 -} - -func isValidSection(from, to int) bool { - if from < 1 || to > 12 { - return false - } - return from <= to -} - -func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool { - for _, current := range selected { - if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek { - continue - } - if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo { - return true - } - } - return false -} diff --git a/backend/logic/refine_compound_ops_test.go b/backend/logic/refine_compound_ops_test.go deleted file mode 100644 index 1c45ffa..0000000 --- a/backend/logic/refine_compound_ops_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package logic - -import ( - "sort" - "testing" -) - -func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) { - tasks := []RefineTaskCandidate{ - {TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1}, - {TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2}, - } - slots := []RefineSlotCandidate{ - {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2}, - {Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2}, - } - moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{ - ExistingDayLoad: map[string]int{ - composeDayKey(12, 1): 5, - composeDayKey(12, 2): 1, - composeDayKey(12, 3): 0, - }, - }) - if err != nil { - t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err) - } - if len(moves) != 2 { - t.Fatalf("期望移动 2 条,实际=%d", len(moves)) - } - - // 1. 低负载日(周三)应优先被填充; - // 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。 - weekDayByID := make(map[int][2]int, len(moves)) - for _, move := range moves { - weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay} - } - if got := weekDayByID[101]; got != [2]int{12, 3} { - t.Fatalf("任务101应优先落到 W12D3,实际=%v", got) - } - if got := weekDayByID[102]; got != [2]int{12, 2} { - t.Fatalf("任务102应落到 W12D2,实际=%v", got) - } -} - -func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) { - tasks := []RefineTaskCandidate{ - {TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1}, - {TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2}, - {TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3}, - } - slots := []RefineSlotCandidate{ - {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, - {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6}, - } - moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{}) - if err != nil { - t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err) - } - if len(moves) != 3 { - t.Fatalf("期望移动 3 条,实际=%d", len(moves)) - } - - // 1. “数学”有 2 条,分组后应先连续落在最早两个坑位; - // 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4(顺序由 origin_rank 决定)。 - sort.SliceStable(moves, func(i, j int) bool { - if moves[i].ToWeek != moves[j].ToWeek { - return moves[i].ToWeek < moves[j].ToWeek - } - if moves[i].ToDay != moves[j].ToDay { - return moves[i].ToDay < moves[j].ToDay - } - return moves[i].ToSectionFrom < moves[j].ToSectionFrom - }) - if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 { - t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves) - } - if moves[2].TaskItemID != 202 { - t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2]) - } -} - -func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) { - tasks := []RefineTaskCandidate{ - {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1}, - {TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2}, - {TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3}, - } - slots := []RefineSlotCandidate{ - {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, - {Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4}, - {Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6}, - } - moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{}) - if err != nil { - t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err) - } - if len(moves) != 3 { - t.Fatalf("期望移动 3 条,实际=%d", len(moves)) - } - - sort.SliceStable(moves, func(i, j int) bool { - if moves[i].ToWeek != moves[j].ToWeek { - return moves[i].ToWeek < moves[j].ToWeek - } - if moves[i].ToDay != moves[j].ToDay { - return moves[i].ToDay < moves[j].ToDay - } - return moves[i].ToSectionFrom < moves[j].ToSectionFrom - }) - if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 { - t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves) - } - if moves[2].TaskItemID != 302 { - t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2]) - } -} - -func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) { - tasks := []RefineTaskCandidate{ - {TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3 - } - slots := []RefineSlotCandidate{ - {Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2 - } - _, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{}) - if err == nil { - t.Fatalf("期望 span 不匹配时报错,实际 err=nil") - } -} diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go index 0b055fa..12b8f04 100644 --- a/backend/logic/smart_planning.go +++ b/backend/logic/smart_planning.go @@ -173,7 +173,7 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla if err != nil { return nil, err } - //3.把这些时间通过DTO函数回填到涉��周的 UserWeekSchedule 结构中,供前端展示 + // 3. 把这些时间通过 DTO 函数回填到涉及周的 UserWeekSchedule 结构中,供前端展示。 return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil } diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index 0432f0a..324b2fd 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -189,7 +189,7 @@ type CommonState struct { // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 // 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。 HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` - // UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。 + // UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径。 // 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。 UsedQuickNote bool `json:"used_quick_note,omitempty"` // HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。 diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index cf82c04..6e2b757 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -105,12 +105,12 @@ type AgentGraphDeps struct { // // 职责边界: // 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry,不走 ReAct 循环; -// 2. CreateTask 和 QueryTasks 的签名与 tools 包的 QuickNoteDeps / TaskQueryDeps 一致。 +// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链。 type QuickTaskDeps struct { // CreateTask 创建一条四象限任务,返回 task_id。 CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error) // QueryTasks 按条件查询用户任务列表。 - QueryTasks func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) + QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error) } // --- 记忆 pinned block 常量(供 agentsvc 和 node 层共享) --- diff --git a/backend/newAgent/model/taskquery_contract.go b/backend/newAgent/model/taskquery_contract.go new file mode 100644 index 0000000..18774b5 --- /dev/null +++ b/backend/newAgent/model/taskquery_contract.go @@ -0,0 +1,35 @@ +package model + +import "time" + +// TaskQueryParams 描述快捷任务查询路径传给业务层的内部查询参数。 +// +// 职责边界: +// 1. 这里只承载“查询条件”本身,不负责 args 解析、默认值填充和错误提示; +// 2. 所有字段均为轻量筛选语义,便于 quick_task 节点和 service 层直接复用; +// 3. 不承担 LLM 工具协议,因为 query_tasks 工具链已下线。 +type TaskQueryParams struct { + Quadrant *int + SortBy string // deadline | priority | id + Order string // asc | desc + Limit int + IncludeCompleted bool + Keyword string + DeadlineBefore *time.Time + DeadlineAfter *time.Time +} + +// TaskQueryResult 描述快捷任务查询返回给上层的轻量任务视图。 +// +// 职责边界: +// 1. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 newAgent 节点; +// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出; +// 3. 不负责序列化策略和文案渲染。 +type TaskQueryResult struct { + ID int `json:"id"` + Title string `json:"title"` + PriorityGroup int `json:"priority_group"` + PriorityLabel string `json:"priority_label"` + IsCompleted bool `json:"is_completed"` + DeadlineAt string `json:"deadline_at,omitempty"` +} diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index 38aa9b8..6cb7b68 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -27,7 +27,6 @@ const ( executeStatusBlockID = "execute.status" executeSpeakBlockID = "execute.speak" executePinnedKey = "execution_context" - toolMinContextSwitch = "min_context_switch" toolAnalyzeHealth = "analyze_health" executeHistoryKindKey = "newagent_history_kind" executeHistoryKindStepAdvanced = "execute_step_advanced" @@ -419,7 +418,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n // 非写工具的 confirm 动作自动降级为 continue。 - // 调用目的:quick_note_create 等非写工具不应走确认卡片流程; + // 调用目的:快捷随口记这类非日程写工具不应走确认卡片流程; // 即使 LLM 误输出 action=confirm,也在此处强制修正, // 确保 speak 正常推流和持久化,不会因 confirm 卡片跳过 persistVisibleAssistantMessage。 if decision.Action == newagentmodel.ExecuteActionConfirm && @@ -454,6 +453,25 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { firstChunk = false } + // 1. execute 正文若已经在流式阶段推给前端,normalizeSpeak 新补出来的尾部(最常见是末尾 \n) + // 不会自动回流到前端,只会留在 history / persist 中。 + // 2. 这会导致下一跳 deliver 首条正文直接接在 execute 最后一段后面,前端表现成两段文本黏连。 + // 3. 这里只补发“归一化后新增的尾巴”,不重发整段正文,也不改写中间内容,避免误伤已有流式体验。 + if speakStreamed { + streamedText := fullText.String() + if tail := buildExecuteNormalizedSpeakTail(streamedText, decision.Speak); tail != "" { + if emitErr := emitter.EmitAssistantText( + executeSpeakBlockID, + executeStageName, + tail, + firstChunk, + ); emitErr != nil { + return fmt.Errorf("执行文案尾部补发失败: %w", emitErr) + } + firstChunk = false + } + } + // 自省校验(仅 Plan 模式):next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。 // // 1. ReAct(无预定义步骤)下不强制 goal_check,避免 done 被错误拦截后进入循环; @@ -514,7 +532,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 继续当前步骤的 ReAct 循环。 // 若有工具调用意图,则执行工具并记录证据。 if decision.ToolCall != nil { - // 1. 写工具必须走 confirm;continue 只允许读工具。 + // 1. 所有写工具都必须走 confirm;continue 只允许读工具。 // 2. 若模型误输出 continue+写工具,这里先做纠偏,不直接执行写操作。 if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) { flowState.ConsecutiveCorrections++ @@ -533,7 +551,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { executeStatusBlockID, executeStageName, "executing", - fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;日程修改工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)), + fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)), false, ) llmOutput := decision.Speak @@ -544,7 +562,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { conversationContext, llmOutput, fmt.Sprintf("你输出了 action=continue,但工具 %q 属于写操作。", decision.ToolCall.Name), - "写操作必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。", + "所有写操作都必须输出 action=confirm,并附带同一个 tool_call;continue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。", ) return nil } @@ -1699,28 +1717,6 @@ func executeToolCall( } // 2. 执行工具。 - // 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch,并写回工具观察结果。 - if shouldBlockMinContextSwitch(flowState, toolName) { - blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。" - log.Printf( - "[WARN] execute tool blocked chat=%s round=%d tool=%s allow_reorder=%v", - flowState.ConversationID, - flowState.RoundUsed, - toolName, - flowState.AllowReorder, - ) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - toolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(toolCall.Arguments), - false, - ) - appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) - return nil - } if shouldForceFeasibilityNegotiation(flowState, registry, toolName) { blockedResult := buildInfeasibleBlockedResult(flowState) _ = emitter.EmitToolCallResult( @@ -1845,19 +1841,6 @@ func buildTemporarilyDisabledToolResult(toolName string) string { return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等基础微调工具。", strings.TrimSpace(toolName)) } -// shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。 -// -// 说明: -// 1. 仅当工具名为 min_context_switch 且未授权打乱顺序时返回 true; -// 2. 其余场景统一放行; -// 3. nil flowState 视为未命中拦截条件,避免因状态缺失导致误阻断。 -func shouldBlockMinContextSwitch(flowState *newagentmodel.CommonState, toolName string) bool { - if flowState == nil { - return false - } - return !flowState.AllowReorder && strings.EqualFold(strings.TrimSpace(toolName), toolMinContextSwitch) -} - // executePendingTool 执行用户已确认的写工具。 // // 职责边界: @@ -1920,22 +1903,6 @@ func executePendingTool( return nil } - // 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。 - if shouldBlockMinContextSwitch(flowState, pending.ToolName) { - blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。" - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - pending.ToolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(args), - false, - ) - appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) - runtimeState.PendingConfirmTool = nil - return nil - } if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) { blockedResult := buildInfeasibleBlockedResult(flowState) _ = emitter.EmitToolCallResult( @@ -2060,6 +2027,24 @@ func normalizeSpeak(speak string) string { return speak + "\n" } +// buildExecuteNormalizedSpeakTail 计算“归一化后新增、但前端尚未收到”的 execute 文案尾巴。 +// +// 职责边界: +// 1. 只处理“streamed 原文是 normalized 的前缀”这一保守场景,典型就是只缺末尾换行; +// 2. 不尝试回放中间格式差异,避免把整段已流式输出的正文再推一遍; +// 3. 若无法安全判断差额,则返回空串,交给现有行为继续执行。 +func buildExecuteNormalizedSpeakTail(streamed, normalized string) string { + streamed = strings.ReplaceAll(streamed, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r\n", "\n") + if streamed == "" || normalized == "" { + return "" + } + if !strings.HasPrefix(normalized, streamed) { + return "" + } + return normalized[len(streamed):] +} + // truncateText 截断文本到指定长度。 // // 用于状态推送时避免超长文本影响前端展示。 @@ -2397,15 +2382,12 @@ func resolveToolDisplayNameCN(toolName string) string { "get_task_info": "查看任务详情", "analyze_health": "综合体检", "analyze_rhythm": "分析学习节奏", - "analyze_tolerance": "分析容错空间", "web_search": "网页搜索", "web_fetch": "网页抓取", "move": "移动任务", "place": "放置任务", "swap": "交换任务", "batch_move": "批量移动任务", - "spread_even": "均匀分散任务", - "min_context_switch": "减少上下文切换", "unplace": "移除任务安排", "upsert_task_class": "写入任务类", "context_tools_add": "激活工具域", diff --git a/backend/newAgent/node/quick_task.go b/backend/newAgent/node/quick_task.go index fb93518..5a6a1da 100644 --- a/backend/newAgent/node/quick_task.go +++ b/backend/newAgent/node/quick_task.go @@ -14,7 +14,6 @@ import ( newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router" newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared" newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream" - newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" "github.com/cloudwego/eino/schema" ) @@ -272,7 +271,7 @@ func handleQuickTaskQuery( decision *quickTaskDecision, flowState *newagentmodel.CommonState, ) string { - params := newagenttools.TaskQueryParams{ + params := newagentmodel.TaskQueryParams{ SortBy: "deadline", Order: "asc", Limit: 5, @@ -316,7 +315,7 @@ func handleQuickTaskQuery( return sb.String() } -// quickNoteFallbackPriority 根据截止时间推断默认优先级,与 tools/quicknote.go 保持一致。 +// quickNoteFallbackPriority 根据截止时间推断默认优先级。 func quickNoteFallbackPriority(deadline *time.Time) int { if deadline != nil { if time.Until(*deadline) <= 48*time.Hour { diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 4c38aeb..3510c53 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -90,7 +90,7 @@ func buildExecutePromptWithFormatGuard(base string) string { 输出协议硬约束: 1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。 2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters,也不能一次输出多个 tool_call。 -3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空。 +3. action=ask_user / confirm 时,标签后必须有自然语言正文;action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具。 4. action=done 时不要携带 tool_call;action=next_plan / done 时,goal_check 必须是字符串。 5. 只有 action=abort 时才允许输出 abort 字段。 6. 标签内只放 JSON,不要放自然语言。 @@ -111,14 +111,27 @@ func buildExecuteStrictJSONUserPrompt() string { 执行提醒: - JSON 中不要包含 speak 字段;给用户看的话放在 标签之后 - 不要在 标签之前输出任何文字;哪怕只有一句“我先看下”也不行 -- 日程写工具(place/move/swap/batch_move/unplace)一律走 action=confirm +- 任何写工具都一律走 action=confirm,包括 upsert_task_class 与日程写工具(place/move/swap/batch_move/unplace);哪怕只是“按 validation.issues 重试一次”,也不能输出 continue + 写工具 - 若当前处于粗排后主动优化专用模式,先调 analyze_health,再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤 - 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user - 不要连续两轮调用“同一读工具 + 等价 arguments”;上一轮已成功返回时,下一轮必须换工具、进入 confirm,或明确说明阻塞 - 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done - web_search 仅用于通用学习资料补充,不可用于考试时间、DDL、个人时段等时间字段填充 +- 任何写工具在真正输出 action=confirm 前,都必须先做一次“写前检查”:确认参数已齐全、格式合法、业务前提已满足;若尚未通过检查,就先补齐/归一/生成/ask_user,不要把 validation 失败当成正常探索路径 - upsert_task_class 若返回 validation.ok=false,必须先按 validation.issues 补齐,再重试;禁止直接 done +- 对 upsert_task_class,写前至少检查:mode=auto 时日期边界是否已满足;subject_type / difficulty_level / cognitive_intensity 是否齐;difficulty_level 是否已映射到合法枚举;items 是否非空且顺序内容已生成;config 中已知约束字段是否已落到合法格式 +- 若像 items 这种内容本就由当前轮模型负责生成,就应先把内容生成齐、顺序排好,再写入;不要先写一个 items 为空的 taskclass 去让 validation 提醒你补内容 +- 处理 validation.issues 时先分类:若是用户关键信息确实缺失,才 action=ask_user;若是 schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射等内部表示问题,应静默改参后直接重试,不要把底层表示教学抛给用户 +- 像 config.excluded_slots 的半天块索引映射,默认属于内部表示修正:你应自己把“第1-2节 / 第11-12节”换算成合法块索引,不要为此 ask_user,不要长篇解释底层表示 +- 当前时间锚点只用于解析用户已经明确说出的相对时间(如“今天开始”“两周内”“下周一前”),不能反过来把“现在是今天”当成用户已经同意从今天开始,更不能据此默认生成 start_date / end_date +- 像 auto 模式缺 start_date/end_date 这类问题,先检查当前对话、历史、记忆、已知工具结果里是否已经出现可用日期;若已出现就静默补齐并重试,只有在上下文里确实没有时再 ask_user +- 若当前是首次创建/修正 taskclass,且上下文里并没有用户明确给出的开始日期、结束日期、日期范围、完成期限、或可直接换算出的相对时间承诺,就不要擅自写 start_date / end_date;此时若工具闭环确实要求这些字段,必须 ask_user +- 对 taskclass 来说,以下属于必须 ask_user 的关键信息:start_date、end_date、明确的日期范围、明确的开始时间承诺、明确的完成期限;这些会决定任务类的真实时间边界,不能由模型自行拍板 - subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user +- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这些默认都只是 taskclass 语义;不要因为信息完整就自动切进 schedule +- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许 context_tools_add domain="schedule"、触发 rough_build,或继续 schedule 链路 +- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应先生成或更新 taskclass,而不是主动排进日程 - 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程” +- 当前轮目标若是创建/修正 taskclass,就优先把 taskclass 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上 `) } diff --git a/backend/newAgent/prompt/execute_context.go b/backend/newAgent/prompt/execute_context.go index d87d60d..2595caf 100644 --- a/backend/newAgent/prompt/execute_context.go +++ b/backend/newAgent/prompt/execute_context.go @@ -231,9 +231,9 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C if state != nil { if state.AllowReorder { - lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。") + lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。") } else { - lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。") + lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。") } } @@ -269,7 +269,7 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C // // 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。 // 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。 -// 3. P1 阶段隐藏 min_context_switch,避免模型误用已禁能力。 +// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面。 func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string { if ctx == nil { return "" @@ -286,10 +286,6 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, sta if name == "" { continue } - if shouldHideMinContextSwitchForP1(state, name) { - continue - } - index++ desc := strings.TrimSpace(schemaItem.Desc) if desc == "" { @@ -329,7 +325,6 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool { "web_fetch", "analyze_health", "analyze_rhythm", - "analyze_tolerance", "upsert_task_class": return true default: @@ -340,7 +335,7 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool { func renderExecuteToolCallHint(toolName string) string { switch strings.ToLower(strings.TrimSpace(toolName)) { case "upsert_task_class": - return `{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,11],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}` + return `仅当用户或上下文已明确给出日期范围时,才允许写入 start_date/end_date;写前先检查 difficulty_level 已归一为 low/medium/high,items 已非空且内容顺序已生成完成:{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,6],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}` default: return "" } @@ -375,10 +370,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str return returnType, "交换完成:[35]... ↔ [36]..." case "batch_move": return returnType, "批量移动完成,2 个任务全部成功。" - case "spread_even": - return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。" - case "min_context_switch": - return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。" case "unplace": return returnType, "已将 [35]... 移除,恢复为待安排状态。" case "web_search": @@ -389,8 +380,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str return "string(JSON字符串)", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}` case "analyze_rhythm": return "string(JSON字符串)", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}` - case "analyze_tolerance": - return "string(JSON字符串)", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}` case "upsert_task_class": return "string(JSON字符串)", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}` default: @@ -564,9 +553,8 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool { func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string { record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{ - "analyze_health": {}, - "analyze_rhythm": {}, - "analyze_tolerance": {}, + "analyze_health": {}, + "analyze_rhythm": {}, }) if !ok { return "" @@ -582,8 +570,6 @@ func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext) "batch_move": {}, "unplace": {}, "queue_apply_head_move": {}, - "spread_even": {}, - "min_context_switch": {}, }) if !ok { return "" @@ -790,14 +776,13 @@ func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string { } } if !state.TaskClassUpsertLastSuccess { + lines = append(lines, "- 写前最少检查项:mode=auto 的 start_date/end_date、subject_type/difficulty_level/cognitive_intensity、difficulty_level 合法枚举、items 非空且内容已生成、config 约束字段合法。") + lines = append(lines, "- 先判断当前 issues 属于哪一类:若是 schema 字段名、字段位置、半天块索引、枚举值、日期格式、工具语义映射等内部表示问题,直接静默改参重试。") + lines = append(lines, "- 若 issue 指向 start_date/end_date 等字段,先检查当前对话、历史、记忆、最近工具结果里是否已出现可用值;只有确实没有时再 ask_user。") + lines = append(lines, "- 若缺的是 start_date/end_date/日期范围/开始日期承诺/完成期限,而这些值并未在上下文中出现,就必须 ask_user;不能把当前日期或默认周期当成用户已同意的时间边界。") + lines = append(lines, "- 若 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,就先在本轮静默归一/补齐/生成,再 confirm 重试;不要把 validation 当试错器。") + lines = append(lines, "- 若再次调用 upsert_task_class,动作必须是 confirm,不能输出 continue + tool_call。") lines = append(lines, "- 在 issues 处理完之前,不要用 done 收口。") } return strings.Join(lines, "\n") } - -func shouldHideMinContextSwitchForP1(state *newagentmodel.CommonState, toolName string) bool { - if strings.TrimSpace(toolName) != "min_context_switch" { - return false - } - return true -} diff --git a/backend/newAgent/prompt/execute_next_step_hint_v2.go b/backend/newAgent/prompt/execute_next_step_hint_v2.go index a1d7927..a1fc224 100644 --- a/backend/newAgent/prompt/execute_next_step_hint_v2.go +++ b/backend/newAgent/prompt/execute_next_step_hint_v2.go @@ -78,7 +78,7 @@ func renderExecuteNextStepHintV2( if roughBuildDone { return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务,再加 packs=["queue"]。` } - return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。` + return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass;只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。` } if activeDomain == "schedule" && @@ -97,7 +97,7 @@ func renderExecuteNextStepHintV2( if activeDomain == "taskclass" && state.TaskClassUpsertLastTried && !state.TaskClassUpsertLastSuccess { - return `先根据 validation.issues 补齐缺失字段,再重试 upsert_task_class,不要直接收口。` + return `先判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class,不要继续解释底层约束,更不要直接收口。` } return "" diff --git a/backend/newAgent/prompt/execute_rule_packs.go b/backend/newAgent/prompt/execute_rule_packs.go index 5ea0846..edeb3a7 100644 --- a/backend/newAgent/prompt/execute_rule_packs.go +++ b/backend/newAgent/prompt/execute_rule_packs.go @@ -178,9 +178,11 @@ func buildExecuteCoreMinPack() executeRulePack { Name: executeRulePackCoreMin, Content: strings.TrimSpace(fmt.Sprintf(` - 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。 -- 用户意图优先:只推进用户当前明确要求;未明确部分优先 ask_user。 +- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。 +- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass,不要主动切到 schedule。 +- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。 - 先事实后动作:优先读工具补齐事实,再决定下一步。 -- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirm;continue + 写工具无效。 +- 只要决定调用任何写工具,就必须输出 action=confirm;continue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。 - 输出格式固定:先 {JSON},再输出用户可见正文。`, buildExecuteNowAnchorLine())), } @@ -197,9 +199,9 @@ func buildExecuteSafetyHardPack() executeRulePack { Name: executeRulePackSafetyHard, Content: strings.TrimSpace(` - 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。 +- P1 阶段禁止调用 min_context_switch。 - 工具参数必须严格使用 schema 字段名,禁止自造别名。 - JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。 -- P1 阶段禁止调用 min_context_switch。 - 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`), } } @@ -210,6 +212,7 @@ func buildExecuteContextProtocolPack() executeRulePack { Content: strings.TrimSpace(` - msg0 动态区初始仅保留 context_tools_add / context_tools_remove。 - 需要业务工具前先 context_tools_add:排程用 domain="schedule",任务类写入用 domain="taskclass"。 +- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。 - schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"];core 固定注入,不要显式传 core。 - 只在业务方向切换时再 remove;done 后的动态区清理由系统自动完成,不必手动 remove。 - 如果目标工具当前不在可用列表,先 add 对应 domain / packs,再继续执行。`), @@ -232,7 +235,7 @@ func buildExecuteModeReActPack() executeRulePack { Name: executeRulePackModeReAct, Content: strings.TrimSpace(` - 当前为自由执行(ReAct)模式:可自主决定 continue / confirm / ask_user / done / abort。 -- 如果关键事实无法通过工具补齐,优先 ask_user,不做猜测落库。 +- 如果关键事实既无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user;不要把本可静默修正的内部表示问题转嫁给用户。 - 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`), } } @@ -242,6 +245,8 @@ func buildExecuteSchedulePack() executeRulePack { Name: executeRulePackDomainSchedule, Content: strings.TrimSpace(` - 当前业务域为 schedule:只处理当前目标任务类,不重排无关内容。 +- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。 +- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule;这些默认仍属于 taskclass 约束。 - existing 只作事实参考;真正可调对象优先看 suggested。 - 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`), } @@ -281,8 +286,28 @@ func buildExecuteTaskClassPack() executeRulePack { Name: executeRulePackDomainTaskClass, Content: strings.TrimSpace(` - taskclass 域只负责生成或修正任务类,不代表已经开始排程。 +- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 taskclass 语义中。 +- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应进入或停留 taskclass,而不是主动切 schedule,也通常不需要 ask_user。 +- 在真正调用 upsert_task_class 前,必须先做一轮写前检查;只有当参数已齐全、格式合法、业务前提已满足时,才允许输出 confirm。 +- 不要把 validation 失败当成正常试错器;validation 只用于兜底发现漏项,不应成为“先乱写一次看看后端报什么”的主流程。 +- upsert_task_class 写前最少检查项: + 1. mode=auto 时,task_class 顶层 start_date/end_date 是否已经满足。 + 2. subject_type / difficulty_level / cognitive_intensity 是否齐全。 + 3. difficulty_level 是否已归一到合法枚举 low/medium/high。 + 4. items 是否非空,且顺序与内容是否已在当前轮生成完成。 + 5. config 中已知约束字段是否已是合法格式,例如 excluded_slots 半天块索引、excluded_days_of_week 取值范围、total_slots/strategy 等。 +- 若像 items 这种内容本就由当前轮模型负责生成,就应先生成齐再写,不要把空 items 提交给 validation 去提醒你补课表内容。 - upsert_task_class 若返回 validation.ok=false,必须先处理 validation.issues,再考虑重试或 ask_user。 +- 先区分 issue 类型:schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射,属于内部表示修正,应静默改参后直接重试;真正缺少用户关键信息时,才 ask_user。 +- taskclass 里的“关键信息缺失”要收窄定义:真正必须 ask_user 的,是会决定任务类真实时间边界/时间承诺的字段,而不是内部表示问题。 +- 必须 ask_user 的时间参数/条件包括:start_date、end_date、明确日期范围、明确开始日期承诺、明确完成期限;如果这些信息在当前对话、历史、记忆里都不存在,就不能由你自行拍板。 +- 当前时间锚点只能用来解析用户已经说出的相对时间;若用户没说“今天开始 / 本周内 / 两周内 / 下周前”这类时间承诺,不能因为“今天是 2026-04-27”就默认 start_date=今天,也不能默认补一个 end_date。 +- 禁排时段、排除星期、总节数、难度、内容拆分授权,不等于用户已经给出了日期范围;这些信息再完整,也不能单独推出 start_date/end_date。 +- config.excluded_slots 使用 1~6 的半天块索引;像“第1-2节”应映射到 1,“第11-12节”应映射到 6。这类换算由你内部处理,不要把底层表示解释成主要回复内容。 +- 若 validation 指出 auto 模式缺 start_date/end_date,先检查当前对话、历史、记忆里是否已有日期范围;已有就静默补齐并重试,只有确实没有时再 ask_user。 - subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。 +- 只要再次调用 upsert_task_class,无论是首次写入还是失败后的重试,都必须走 action=confirm。 +- 当前轮目标若是创建/修正 taskclass,应优先追求静默闭环,不要把主要篇幅花在教育用户理解工具内部约束上。 - excluded_slots 取值应与系统节次定义一致;excluded_days_of_week 使用 1~7 表示周一到周日。`), } } @@ -301,6 +326,13 @@ func buildExecuteTaskClassRetryMicroPack() executeRulePack { Name: executeRulePackMicroTaskRetry, Content: strings.TrimSpace(` - 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。 +- 先回到“写前检查”再决定是否重试:确认 mode=auto 的日期边界、difficulty_level 合法枚举、subject_type/difficulty_level/cognitive_intensity 齐全、items 非空且已生成、config 约束字段合法。 +- 先判断 issue 是“用户关键信息缺失”还是“内部表示/工具语义修正”:前者才 ask_user,后者直接静默改参重试。 +- 如果 issue 最终落到 start_date / end_date / 日期范围 / 开始日期承诺 / 完成期限,而这些值在当前对话、历史、记忆、最近工具结果里都没有出现,就必须 ask_user;不要再拿当前时间锚点去替用户补。 +- 若用户只给了禁排时段、排除星期、总节数、难度、内容拆分授权,这仍不构成日期范围;不要把这类偏好误判成已经拿到了可写入的 start_date/end_date。 +- 如果 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,这都属于“写前本应整理好”的问题:应先在本轮静默归一/补齐/生成,再 confirm 重试,不要继续拿 validation 探路。 +- 若 issue 所需字段已在当前对话、历史、记忆或最近工具结果里出现,优先静默补齐,不要多轮解释后再写。 +- 重试 upsert_task_class 时仍然必须输出 action=confirm;不要输出 continue + tool_call。 - 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`), } } diff --git a/backend/newAgent/prompt/plan.go b/backend/newAgent/prompt/plan.go index ea32012..b091656 100644 --- a/backend/newAgent/prompt/plan.go +++ b/backend/newAgent/prompt/plan.go @@ -13,15 +13,24 @@ const planSystemPromptCore = ` 最高优先级规则: 1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。 -2. 事实边界:禁止伪造工具调用和执行结果。 +2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结论。 +3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。 规划规则: 1. 每轮只做一次决策(continue / ask_user / plan_done)。 2. 信息足够时优先 plan_done;信息不足时才 ask_user,且只问最小必要问题。 3. action=plan_done 时必须返回完整 plan_steps(不是增量)。 -4. plan_steps 使用自然语言描述目标与完成判定,不写执行结果。 -5. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids。 -6. 可在 plan_done 时附加 context_hook(执行阶段注入建议);规划阶段禁止调用 context_tools_add/remove。` +4. plan_steps 必须优先按“工具闭环”拆步,而不是按抽象语义拆步。 +5. 若一个目标可由单个工具闭环完成,优先生成单步计划;禁止把本可直接执行的工具动作,拆成“先分析、再设计、再确认、再执行”这类抽象多步。 +6. 每个 step 的 done_when 都应尽量贴近可观察证据,优先锚定工具回执、校验结果、查询 observation,而不是“方案完整”“分析完成”“用户应该满意”这类抽象描述。 +7. 只有单工具无法闭环,或当前步骤天然依赖上一步 observation / 用户补充信息时,才允许拆成多步。 +8. 先判断为完成目标“首个可执行闭环”最小需要的 domain / packs,再围绕这些工具写 steps,最后再产出 context_hook。context_hook 不是顺手填空,而是计划的自然推导结果。 +9. context_hook 只有一份,供 execute 首轮激活工具域使用;它应对齐“第一个可执行 step”的最小工具需求,而不是试图一次覆盖整份计划的所有后续能力。 +10. 用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass 语义,不等于已经要求排进日程。 +11. 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许把目标规划为 schedule;否则优先停留在 taskclass。 +12. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids;但仅当用户明确提出排程请求时才允许这样做。 +13. 可在 plan_done 时附加 context_hook(执行阶段注入建议);若用户尚未明确要求排程,则 context_hook.domain 不得写 schedule。规划阶段禁止调用 context_tools_add/remove。 +14. 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——这应判定为 taskclass 设计;planner 应优先理解为 taskclass 域可闭环的请求,通常单步或极少步即可,不应抽象拆成多轮。` // BuildPlanSystemPrompt 返回规划阶段系统提示词。 func BuildPlanSystemPrompt() string { @@ -33,10 +42,6 @@ func BuildPlanSystemPrompt() string { } // BuildPlanMessages 组装规划阶段的 messages。 -// -// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。 -// 2. msg1 展示真实对话,msg2 展示规划工作区,msg3 仅给最小执行指令与用户本轮输入。 -// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。 func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message { return buildUnifiedStageMessages( ctx, @@ -58,6 +63,9 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n") sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n") + sb.WriteString("请先判断最小工具闭环,再决定是否需要拆步;能单步就单步。\n") + sb.WriteString("若需要 context_hook,请先根据第一个可执行 step 所需的最小 domain / packs 推导,再写入 hook。\n") + sb.WriteString("禁止把本可直接落工具的动作,抽象写成“完成设计 / 确认方案 / 整理思路”之类空步骤。\n") sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n") trimmedInput := strings.TrimSpace(userInput) @@ -72,28 +80,38 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str // BuildPlanDecisionContractText 返回规划阶段的输出协议说明。 func BuildPlanDecisionContractText() string { - return strings.TrimSpace(fmt.Sprintf(` -输出协议(唯一口径): -1. 先输出:{JSON} -2. 再输出:给用户看的自然语言正文 - -JSON 字段: -- action:只能是 %s / %s / %s -- reason:给后端和日志看的简短说明 -- complexity:只能是 simple / moderate / complex -- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划 -- plan_steps[].content:步骤正文,必填 -- plan_steps[].done_when:可选,建议写完成判定 -- needs_rough_build:仅满足粗排识别条件时为 true,否则省略 -- task_class_ids:needs_rough_build=true 时必填,从上下文读取 -- context_hook:可选,仅用于给 execute 阶段提供注入建议 -- context_hook.domain:schedule / taskclass -- context_hook.packs:string 数组,可选;core 固定注入,不要填写 core -- context_hook.reason:可选,说明为何建议该注入 - -注意: -- JSON 中不要包含 speak 字段 -- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)`, + return strings.TrimSpace(fmt.Sprintf(strings.Join([]string{ + "输出协议(唯一口径):", + "1. 先输出:{JSON}", + "2. 再输出:给用户看的自然语言正文", + "", + "JSON 字段:", + "- action:只能是 %s / %s / %s", + "- reason:给后端和日志看的简短说明", + "- complexity:只能是 simple / moderate / complex", + "- plan_steps:仅当 action=%s 时允许返回,且必须是完整计划", + "- plan_steps[].content:步骤正文,必填", + "- plan_steps[].done_when:可选;若提供,必须尽量写成 observation / 工具回执可直接证明的完成判定", + "- needs_rough_build:仅满足粗排识别条件时为 true,否则省略", + "- task_class_ids:needs_rough_build=true 时必填,从上下文读取", + "- context_hook:可选,仅用于给 execute 阶段提供注入建议", + "- context_hook.domain:schedule / taskclass", + "- context_hook.packs:string 数组,可选;core 固定注入,不要填入 core", + "- context_hook.reason:可选,说明为何建议该注入", + "", + "注意:", + "- JSON 中不要包含 speak 字段", + "- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove)", + "- 写 plan_steps 前,先判断当前目标能否由单个工具或单个紧凑工具闭环完成;若能,优先输出单步计划", + "- 禁止把本可直接执行的工具动作,拆成抽象语义步骤,例如“先分析需求”“完成设计”“确认方案完整”", + "- 多步计划只应用于:上一步 observation 决定下一步;或确实需要先问用户补关键事实;或目标天然跨域", + "- context_hook 必须从 plan_steps 自然推导:优先对齐第一个可执行 step 的最小 domain / packs,不要脱离步骤单独拍脑袋生成", + "- 若用户只给出学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认属于 taskclass 设计;不要因此写 needs_rough_build=true,也不要把 context_hook.domain 设为 schedule", + "- 只有用户明确要求\"排进日程 / 给出具体时间安排 / 现在就排一版\"时,才允许输出 needs_rough_build=true 或 context_hook.domain=schedule", + "- 若首步本质上是任务类写入或修正,context_hook 通常应对齐 taskclass;若首步需要 schedule 查询/分析/修改,再按最小 packs 推导 schedule hook", + "- step 的 done_when 应优先锚定:查询结果已返回、validation 已通过、写工具已成功回执、粗排标记已产生、分析结论已可直接支撑下一步", + "- 例:\"我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节学习,周末也不想学,每节课内容你自己来\"——应规划为 taskclass,而不是 schedule,也通常不需要 ask_user", + }, "\n"), newagentmodel.PlanActionContinue, newagentmodel.PlanActionAskUser, newagentmodel.PlanActionDone, diff --git a/backend/newAgent/prompt/plan_context.go b/backend/newAgent/prompt/plan_context.go index 922190e..e30f0b8 100644 --- a/backend/newAgent/prompt/plan_context.go +++ b/backend/newAgent/prompt/plan_context.go @@ -16,13 +16,14 @@ func buildPlanConversationMessage(ctx *newagentmodel.ConversationContext) string // buildPlanWorkspace 渲染 plan 节点自己的工作区。 // // 设计说明: -// 1. 这里只保留“规划真正需要知道的东西”:已有计划、当前步骤、task_class_ids、任务类约束; -// 2. 不再复用通用胖状态摘要,避免把 execute / deliver 无关状态一起塞给 plan; -// 3. 若当前没有正式计划,则明确告诉模型“从零开始规划”,避免继续误沿用旧上下文。 +// 1. 这里既保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”; +// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆; +// 3. 工具摘要不展开全量 schema,只提供规划真正需要的:负责什么、不负责什么、常见闭环、完成证据、域切换条件。 func buildPlanWorkspace(state *newagentmodel.CommonState) string { lines := []string{"规划工作区:"} if state == nil { lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。") + lines = append(lines, buildPlanToolPlanningSummary()) return strings.Join(lines, "\n") } @@ -43,6 +44,7 @@ func buildPlanWorkspace(state *newagentmodel.CommonState) string { lines = append(lines, taskClassMeta) } + lines = append(lines, buildPlanToolPlanningSummary()) return strings.Join(lines, "\n") } @@ -142,6 +144,76 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string { return strings.Join(lines, "\n") } +// buildPlanToolPlanningSummary 生成“规划视角的工具摘要”。 +// +// 步骤化说明: +// 1. 先讲 domain:让 planner 先判断目标应该停留在哪个业务域; +// 2. 再讲 schedule packs:让 planner 知道若进入 schedule,该选最小哪组能力; +// 3. 最后讲 hook 推导规则:因为 context_hook 只有一份,必须和“首个可执行闭环”对齐。 +func buildPlanToolPlanningSummary() string { + sections := []string{ + "规划视角的工具摘要:", + buildPlanToolDomainTaskClassSummary(), + buildPlanToolDomainScheduleSummary(), + buildPlanToolPackSummary(), + buildPlanContextHookSummary(), + } + return strings.Join(sections, "\n") +} + +func buildPlanToolDomainTaskClassSummary() string { + lines := []string{ + "1. taskclass 域:", + "- 负责什么:创建 / 更新任务类,沉淀学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权、任务项结构。", + "- 不负责什么:不给出具体日期/节次落位,不负责把任务真正排进日程。", + "- 常见一步闭环:任务类设计或修正通常可由 taskclass 域单步闭环,核心写入动作为 upsert_task_class。", + "- 何时停留在本域:用户仍在描述目标、偏好、约束、拆分方式,而不是要求现在排进日程。", + "- 何时切到下一个域:只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”,或当前目标本身已变成排程执行。", + "- done_when 证据偏好:优先锚定 upsert_task_class 成功回执、validation.ok=true、validation.issues 已清空。", + } + return strings.Join(lines, "\n") +} + +func buildPlanToolDomainScheduleSummary() string { + lines := []string{ + "2. schedule 域:", + "- 负责什么:查询日程现状、粗排、具体落位、局部移动/交换、批量同规则调整、排程健康分析。", + "- 不负责什么:不凭空补考试时间、DDL、个人空闲、外部时间事实;这类信息拿不到时应 ask_user。", + "- 常见一步闭环:单次查询通常一个读工具即可闭环;单次移动/交换/放置通常一个写工具即可闭环;局部分析通常一个 analyze 工具即可闭环。", + "- 何时停留在本域:用户明确要求查询、安排、调整、优化当前日程。", + "- 何时先回 taskclass:如果用户还在定义“学什么、学多少、怎么拆、哪些时段不要学”,而不是要求立刻排程,应先停留在 taskclass。", + "- done_when 证据偏好:优先锚定查询 observation、写工具成功回执、rough_build_done 标记、analyze observation 已能直接支撑下一步。", + } + return strings.Join(lines, "\n") +} + +func buildPlanToolPackSummary() string { + lines := []string{ + "3. schedule packs 选择参考:", + "- detail_read:查看总览、查询区间、看任务详情;适合“先读事实再决定”的首步。", + "- mutation:place / move / swap / batch_move / unplace;适合真正落日程或调日程。", + "- analyze:analyze_health / analyze_rhythm;适合先判断是否还有优化空间、该往哪里动。", + "- queue:适合“按同一规则逐个处理一批任务”的计划,不必把整批任务细节都堆进 steps。", + "- web:仅补通用学习资料或通识信息;不用于补个人时间事实。", + "- deep_analyze:适合确实需要更深一层 schedule 分析时再加,默认不要为了“看起来完整”就提前注入。", + "- 选 pack 原则:只选首个可执行 step 真的需要的最小 packs,不要为了保险一次全带上。", + } + return strings.Join(lines, "\n") +} + +func buildPlanContextHookSummary() string { + lines := []string{ + "4. context_hook 推导规则:", + "- 先确定 steps,再看第一个可执行 step 需要哪个 domain / 哪组最小 packs,最后才写 hook。", + "- 若第一个可执行 step 本质上是任务类写入或修正,hook 通常应为 taskclass,且一般不需要 packs。", + "- 若第一个可执行 step 是 schedule 查询,hook 应为 schedule,并优先只带 detail_read。", + "- 若第一个可执行 step 是 schedule 分析,hook 应为 schedule,并优先带 analyze;若分析后立刻要落写,再补 mutation。", + "- 若第一个可执行 step 是批量同规则处理,hook 应在 schedule 基础上按需加 queue。", + "- hook 只有一份,不要求提前覆盖整份计划的所有后续能力;execute 可以在后续按计划再切域或补 packs。", + } + return strings.Join(lines, "\n") +} + func planSemanticValue(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/backend/newAgent/tools/quicknote.go b/backend/newAgent/tools/quicknote.go deleted file mode 100644 index f93b72c..0000000 --- a/backend/newAgent/tools/quicknote.go +++ /dev/null @@ -1,139 +0,0 @@ -package newagenttools - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared" - "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" -) - -// QuickNoteDeps 描述随口记工具所需的外部依赖。 -// -// 职责边界: -// 1. CreateTask 负责真正写库,工具层不直接依赖 DAO; -// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。 -type QuickNoteDeps struct { - // CreateTask 将解析后的任务字段写入数据库。 - // 调用目的:解耦工具层与 DAO 层,方便测试和替换。 - CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (taskID int, err error) -} - -// QuickNoteCreateResult 是 quick_note_create 工具的结构化返回。 -type QuickNoteCreateResult struct { - TaskID int `json:"task_id"` - Title string `json:"title"` - PriorityLabel string `json:"priority_label"` - DeadlineAt string `json:"deadline_at,omitempty"` - Message string `json:"message"` -} - -// quickNoteFallbackPriority 根据截止时间推断默认优先级。 -// -// 推断规则: -// 1. 有截止时间且距今 ≤48h → 1(重要且紧急); -// 2. 有截止时间且距今 >48h → 2(重要不紧急); -// 3. 无截止时间 → 3(简单不重要)。 -func quickNoteFallbackPriority(deadline *time.Time) int { - if deadline != nil { - if time.Until(*deadline) <= 48*time.Hour { - return newagentshared.QuickNotePriorityImportantUrgent - } - return newagentshared.QuickNotePriorityImportantNotUrgent - } - return newagentshared.QuickNotePrioritySimpleNotImportant -} - -// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。 -// -// 职责边界: -// 1. 负责参数校验、时间解析、优先级推断、调 deps 写库、组装返回; -// 2. 不负责 LLM 交互和会话管理。 -// 3. state 参数忽略——随口记不需要 ScheduleState,已注册到 scheduleFreeTools。 -func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) string { - _ = state - - // 1. 提取 _user_id(由 execute 节点在调用前注入)。 - userID := 0 - if uid, ok := args["_user_id"].(int); ok { - userID = uid - } - if userID <= 0 { - return "工具调用失败:无法识别用户身份。" - } - - // 2. 提取必填参数 title。 - title := "" - if t, ok := args["title"].(string); ok { - title = strings.TrimSpace(t) - } - if title == "" { - return "工具调用失败:缺少必填参数 title(任务标题)。" - } - - // 3. 提取可选参数 deadline_at,复用旧链路时间解析能力。 - var deadline *time.Time - if raw, ok := args["deadline_at"].(string); ok { - raw = strings.TrimSpace(raw) - if raw != "" { - // 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。 - parsed, err := newagentshared.ParseOptionalDeadline(raw) - if err != nil { - return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s)。支持格式:2026-04-20 18:00、明天下午3点、下周一上午9点。", err) - } - deadline = parsed - } - } - - // 4. 提取可选参数 priority_group;未提供时按截止时间自动推断。 - priorityGroup := 0 - if pg, ok := args["priority_group"].(float64); ok { - priorityGroup = int(pg) - } - if !newagentshared.IsValidTaskPriority(priorityGroup) { - priorityGroup = quickNoteFallbackPriority(deadline) - } - - // 5. 调用依赖写库。 - taskID, err := deps.CreateTask(userID, title, priorityGroup, deadline) - if err != nil { - return fmt.Sprintf("工具调用失败:写入任务时出错(%s)。", err) - } - if taskID <= 0 { - return "工具调用失败:写入任务后未返回有效 task_id。" - } - - // 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。 - priorityLabel := newagentshared.PriorityLabelCN(priorityGroup) - deadlineStr := "" - if deadline != nil { - deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04") - } - - result := QuickNoteCreateResult{ - TaskID: taskID, - Title: title, - PriorityLabel: priorityLabel, - DeadlineAt: deadlineStr, - } - - // 6.1 成功事实 + banter 提示:通过工具返回值引导 ReAct LLM 在 speak 中自然加入轻松跟进。 - if deadlineStr != "" { - result.Message = fmt.Sprintf("已记录:%s(%s,截止 %s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。", - title, priorityLabel, deadlineStr) - } else { - result.Message = fmt.Sprintf("已记录:%s(%s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。", - title, priorityLabel) - } - - jsonBytes, marshalErr := json.Marshal(result) - if marshalErr != nil { - // 6.2 JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。 - return result.Message - } - return string(jsonBytes) - } -} diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index 930c170..e92047e 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -52,14 +52,7 @@ type ToolRegistry struct { // 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字; // 2. execute 会在调用前统一阻断,并向模型返回纠错提示; // 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。 -var temporaryDisabledTools = map[string]bool{ - "min_context_switch": true, - "spread_even": true, - "analyze_load": true, - "analyze_subjects": true, - "analyze_context": true, - "analyze_tolerance": true, -} +var temporaryDisabledTools = map[string]bool{} // IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。 func IsTemporarilyDisabledTool(name string) bool { @@ -232,8 +225,6 @@ var writeTools = map[string]bool{ "swap": true, "batch_move": true, "queue_apply_head_move": true, - "spread_even": true, - "min_context_switch": true, "unplace": true, "upsert_task_class": true, } @@ -244,8 +235,6 @@ var scheduleMutationTools = map[string]bool{ "swap": true, "batch_move": true, "queue_apply_head_move": true, - "spread_even": true, - "min_context_switch": true, "unplace": true, } @@ -368,30 +357,6 @@ func registerScheduleReadTools(r *ToolRegistry) { } func registerScheduleAnalyzeTools(r *ToolRegistry) { - r.Register( - "analyze_load", - "分析整体负载分布(当前阶段已临时禁用,仅保留定义)。", - `{"name":"analyze_load","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"granularity":{"type":"string","enum":["day","week","time_of_day"]},"detail":{"type":"string","enum":["summary","full"]}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeLoad(state, args) - }, - ) - r.Register( - "analyze_subjects", - "分析学科分布与连贯性(当前阶段已临时禁用,仅保留定义)。", - `{"name":"analyze_subjects","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeSubjects(state, args) - }, - ) - r.Register( - "analyze_context", - "分析上下文切换与相邻关系(当前阶段已临时禁用,仅保留定义)。", - `{"name":"analyze_context","parameters":{"day_from":{"type":"int"},"day_to":{"type":"int"},"detail":{"type":"string","enum":["summary","day_detail"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeContext(state, args) - }, - ) r.Register( "analyze_rhythm", "分析学习节奏与切换情况。", @@ -400,14 +365,6 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) { return schedule.AnalyzeRhythm(state, args) }, ) - r.Register( - "analyze_tolerance", - "分析局部容错与调整空间。", - `{"name":"analyze_tolerance","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"min_usable_size":{"type":"int"},"min_daily_buffer":{"type":"int"},"detail":{"type":"string","enum":["summary","full"]}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeTolerance(state, args) - }, - ) r.Register( "analyze_health", "主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。", @@ -503,30 +460,6 @@ func registerScheduleMutationTools(r *ToolRegistry) { return schedule.QueueSkipHead(state, args) }, ) - r.Register( - "min_context_switch", - "在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。", - `{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args) - if err != nil { - return fmt.Sprintf("减少上下文切换失败:%s。", err.Error()) - } - return schedule.MinContextSwitch(state, taskIDs) - }, - ) - r.Register( - "spread_even", - "在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。", - `{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args) - if err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - return schedule.SpreadEven(state, taskIDs, args) - }, - ) r.Register( "unplace", "将一个已落位任务移除,恢复为待安排状态。task_id 必填。", diff --git a/backend/newAgent/tools/schedule/analyze_tools.go b/backend/newAgent/tools/schedule/analyze_tools.go index 35d4af8..c8963c7 100644 --- a/backend/newAgent/tools/schedule/analyze_tools.go +++ b/backend/newAgent/tools/schedule/analyze_tools.go @@ -165,26 +165,6 @@ type analyzeHealthMetrics struct { CanClose bool `json:"can_close"` } -// AnalyzeLoad 已退出主动优化主链路。 -func AnalyzeLoad(state *ScheduleState, args map[string]any) string { - return encodeAnalyzeFailure("analyze_load", "deprecated", "analyze_load 已退出主动优化链路") -} - -// AnalyzeSubjects 已被 analyze_rhythm 吸收。 -func AnalyzeSubjects(state *ScheduleState, args map[string]any) string { - return encodeAnalyzeFailure("analyze_subjects", "deprecated", "analyze_subjects 已被 analyze_rhythm 吸收") -} - -// AnalyzeContext 已被 analyze_rhythm 吸收。 -func AnalyzeContext(state *ScheduleState, args map[string]any) string { - return encodeAnalyzeFailure("analyze_context", "deprecated", "analyze_context 已被 analyze_rhythm 吸收") -} - -// AnalyzeTolerance 已退出主动优化主链路。 -func AnalyzeTolerance(state *ScheduleState, args map[string]any) string { - return encodeAnalyzeFailure("analyze_tolerance", "deprecated", "analyze_tolerance 已退出主动优化链路") -} - // AnalyzeRhythm 输出认知节奏层面的结构化观察。 func AnalyzeRhythm(state *ScheduleState, args map[string]any) string { if state == nil { @@ -958,164 +938,6 @@ func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyze }} } -func buildAnalyzeHealthDecision( - state *ScheduleState, - snapshot analyzeHealthSnapshot, -) analyzeHealthDecision { - base := buildAnalyzeHealthDecisionBase(state, snapshot) - decision := analyzeHealthDecision{ - ShouldContinueOptimize: base.ShouldContinueOptimize, - PrimaryProblem: base.PrimaryProblem, - ProblemScope: base.ProblemScope, - IsForcedImperfection: base.IsForcedImperfection, - RecommendedOperation: base.RecommendedOperation, - ImprovementSignal: buildHealthImprovementSignal( - snapshot.Rhythm, - snapshot.Tightness, - base.ProblemScope, - base.RecommendedOperation, - snapshot.Profile, - snapshot.Feasibility, - ), - } - - // 1. 只有“高认知相邻”这类当前 P1 真正能靠确定性候选修复的问题,才进入候选枚举。 - // 2. 若所有合法候选都只是平移/无增益/恶化,则直接回到 close,避免把 LLM 逼成苦力工。 - // 3. close 永远保留为兜底选项,让 LLM 可以自然收口,而不是为了完成任务感继续乱挪。 - problem, ok := pickPrimaryHealthProblem(state, snapshot) - if !ok || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil { - decision.Candidates = []analyzeHealthCandidate{ - buildHealthCloseCandidate("保持当前安排并收口:当前没有可继续处理的候选认知问题。", snapshot, base), - } - decision.ShouldContinueOptimize = false - decision.RecommendedOperation = "close" - decision.ImprovementSignal = buildHealthImprovementSignal( - snapshot.Rhythm, - snapshot.Tightness, - decision.ProblemScope, - decision.RecommendedOperation, - snapshot.Profile, - snapshot.Feasibility, - ) - return decision - } - - beneficial := buildHealthCandidatesForProblem(state, snapshot, problem) - if len(beneficial) == 0 { - decision.Candidates = []analyzeHealthCandidate{ - buildHealthCloseCandidate("保持当前安排并收口:当前所有合法 move / swap 都只会平移、无增益或恶化问题。", snapshot, base), - } - decision.ShouldContinueOptimize = false - decision.RecommendedOperation = "close" - if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" { - decision.IsForcedImperfection = true - } - decision.ImprovementSignal = buildHealthImprovementSignal( - snapshot.Rhythm, - snapshot.Tightness, - decision.ProblemScope, - decision.RecommendedOperation, - snapshot.Profile, - snapshot.Feasibility, - ) - return decision - } - - decision.Candidates = append(decision.Candidates, beneficial...) - decision.Candidates = append(decision.Candidates, - buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base), - ) - decision.ShouldContinueOptimize = true - decision.RecommendedOperation = strings.TrimSpace(beneficial[0].Tool) - decision.ImprovementSignal = buildHealthImprovementSignal( - snapshot.Rhythm, - snapshot.Tightness, - decision.ProblemScope, - decision.RecommendedOperation, - snapshot.Profile, - snapshot.Feasibility, - ) - return decision -} - -func pickPrimaryRhythmProblem( - rhythm analyzeRhythmMetrics, - tightness analyzeTightnessMetrics, -) (summary string, scope *analyzeProblemScope, operation string, ok bool) { - type rhythmCandidate struct { - score int - summary string - scope *analyzeProblemScope - preferSwap bool - } - - candidates := make([]rhythmCandidate, 0, len(rhythm.Days)*2) - for _, day := range rhythm.Days { - if day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) { - score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20) - candidates = append(candidates, rhythmCandidate{ - score: score, - summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex), - scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}}, - preferSwap: true, - }) - } - if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 { - score := 220 + day.SwitchCount*10 + int(day.Fragmentation*100) - candidates = append(candidates, rhythmCandidate{ - score: score, - summary: fmt.Sprintf("第 %d 天切换次数偏多,学习节奏明显发碎", day.DayIndex), - scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}}, - preferSwap: false, - }) - } - if day.MaxBlock >= 5 { - score := 140 + day.MaxBlock*10 - candidates = append(candidates, rhythmCandidate{ - score: score, - summary: fmt.Sprintf("第 %d 天连续同科目学习块过长,节奏略显单一", day.DayIndex), - scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}}, - preferSwap: false, - }) - } - } - if len(candidates) == 0 { - return "", nil, "close", false - } - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].score != candidates[j].score { - return candidates[i].score > candidates[j].score - } - leftDay := 1 << 30 - rightDay := 1 << 30 - if candidates[i].scope != nil && len(candidates[i].scope.DayRange) > 0 { - leftDay = candidates[i].scope.DayRange[0] - } - if candidates[j].scope != nil && len(candidates[j].scope.DayRange) > 0 { - rightDay = candidates[j].scope.DayRange[0] - } - return leftDay < rightDay - }) - best := candidates[0] - operation = chooseHealthOperation(tightness, best.preferSwap) - return best.summary, best.scope, operation, true -} - -func chooseHealthOperation(tightness analyzeTightnessMetrics, preferSwap bool) string { - switch { - case tightness.TightnessLevel == "locked": - return "close" - case preferSwap && tightness.CrossClassSwapOptions > 0: - return "swap" - case tightness.LocallyMovableTaskCount > 0: - return "move" - case tightness.CrossClassSwapOptions > 0: - return "swap" - default: - return "close" - } -} - func shouldTreatHeavyAdjacencyAsAcceptable(rhythm analyzeRhythmMetrics, day analyzeContextDay) bool { // 1. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”, // 这类情况不该因为“高认知相邻”四个字就被反复优化。 diff --git a/backend/newAgent/tools/schedule/compound_tools.go b/backend/newAgent/tools/schedule/compound_tools.go deleted file mode 100644 index bbb2f8c..0000000 --- a/backend/newAgent/tools/schedule/compound_tools.go +++ /dev/null @@ -1,707 +0,0 @@ -package schedule - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - - compositelogic "github.com/LoveLosita/smartflow/backend/logic" -) - -var spreadEvenAllowedArgs = []string{ - "task_ids", - "task_id", - "limit", - "allow_embed", - "day", - "day_start", - "day_end", - "day_scope", - "day_of_week", - "week", - "week_filter", - "week_from", - "week_to", - "slot_type", - "slot_types", - "exclude_sections", - "after_section", - "before_section", -} - -// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。 -type minContextSnapshot struct { - StateID int - Name string - ContextTag string - Slot TaskSlot -} - -// refineTaskCandidate 是复合规划器使用的任务输入。 -type refineTaskCandidate struct { - TaskID int - Week int - DayOfWeek int - SectionFrom int - SectionTo int - Name string - ContextTag string - OriginRank int -} - -// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。 -// -// 说明: -// 1. 当前阶段使用等值映射(logicID=stateID),保证行为不变; -// 2. 保留独立适配层,后续若切到真实 task_item_id,只需改这里; -// 3. 通过双向映射保证“入参转换 + 结果回填”一致。 -type compositeIDMapper struct { - stateToLogic map[int]int - logicToState map[int]int -} - -// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。 -func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) { - mapper := &compositeIDMapper{ - stateToLogic: make(map[int]int, len(stateIDs)), - logicToState: make(map[int]int, len(stateIDs)), - } - for _, stateID := range stateIDs { - if stateID <= 0 { - return nil, fmt.Errorf("存在非法 state_id=%d", stateID) - } - if _, exists := mapper.stateToLogic[stateID]; exists { - return nil, fmt.Errorf("state_id=%d 重复", stateID) - } - // 当前迁移阶段采用等值映射,先把“映射机制”跑通。 - logicID := stateID - mapper.stateToLogic[stateID] = logicID - mapper.logicToState[logicID] = stateID - } - return mapper, nil -} - -// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。 -// -// 职责边界: -// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排; -// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务; -// 3. 采用原子提交:任一校验失败则整体不生效。 -func MinContextSwitch(state *ScheduleState, taskIDs []int) string { - if state == nil { - return "减少上下文切换失败:日程状态为空。" - } - - // 1. 收集任务并做前置校验,确保规划输入可用。 - plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换") - if err != nil { - return err.Error() - } - logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper) - if err != nil { - return fmt.Sprintf("减少上下文切换失败:%s。", err.Error()) - } - - // 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。 - currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks) - plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{}) - if err != nil { - return fmt.Sprintf("减少上下文切换失败:%s。", err.Error()) - } - - // 3. 映射回工具态坐标并在提交前做完整校验。 - afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper) - if err != nil { - return fmt.Sprintf("减少上下文切换失败:%s。", err.Error()) - } - for taskID, after := range afterByID { - before := beforeByID[taskID] - if err := validateDay(state, after.Slot.Day); err != nil { - return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error()) - } - if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil { - return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error()) - } - if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil { - return fmt.Sprintf( - "减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。", - before.StateID, - before.Name, - formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd), - conflict.StateID, - conflict.Name, - ) - } - } - minContextProposals := make(map[int][]TaskSlot, len(afterByID)) - for taskID, after := range afterByID { - minContextProposals[taskID] = []TaskSlot{after.Slot} - } - if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil { - return fmt.Sprintf("减少上下文切换失败:%s。", err.Error()) - } - - // 4. 全量通过后再原子提交,避免半成品状态。 - clone := state.Clone() - for taskID, after := range afterByID { - task := clone.TaskByStateID(taskID) - if task == nil { - return fmt.Sprintf("减少上下文切换失败:任务ID %d 在提交阶段不存在。", taskID) - } - task.Slots = []TaskSlot{after.Slot} - } - state.Tasks = clone.Tasks - - beforeOrdered := sortMinContextSnapshots(beforeByID) - afterOrdered := sortMinContextSnapshots(afterByID) - beforeSwitches := countMinContextSwitches(beforeOrdered) - afterSwitches := countMinContextSwitches(afterOrdered) - - changedLines := make([]string, 0, len(beforeOrdered)) - affectedDays := make(map[int]bool, len(beforeOrdered)*2) - for _, before := range beforeOrdered { - after := afterByID[before.StateID] - if sameTaskSlot(before.Slot, after.Slot) { - continue - } - changedLines = append(changedLines, fmt.Sprintf( - " [%d]%s:%s -> %s", - before.StateID, - before.Name, - formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd), - formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd), - )) - affectedDays[before.Slot.Day] = true - affectedDays[after.Slot.Day] = true - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf( - "最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n", - len(beforeByID), beforeSwitches, afterSwitches, - )) - if len(changedLines) == 0 { - sb.WriteString("当前任务顺序已是较优结果,无需调整。") - return sb.String() - } - sb.WriteString("本次调整:\n") - for _, line := range changedLines { - sb.WriteString(line + "\n") - } - for _, day := range sortedKeys(affectedDays) { - sb.WriteString(formatDayOccupancy(state, day) + "\n") - } - return strings.TrimSpace(sb.String()) -} - -// SpreadEven 在给定任务集合内执行“均匀化铺开”。 -// -// 职责边界: -// 1. 仅处理 suggested 且已落位任务; -// 2. 先按筛选条件收集候选坑位,再调用确定性规划器; -// 3. 通过统一校验后原子提交,失败不落地。 -func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string { - if state == nil { - return "均匀化调整失败:日程状态为空。" - } - // 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。 - if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - - // 1. 先做任务侧校验,避免后续规划在脏输入上执行。 - plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整") - if err != nil { - return err.Error() - } - logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper) - if err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - - // 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。 - spanNeed := make(map[int]int, len(logicTasks)) - for _, task := range logicTasks { - spanNeed[task.SectionTo-task.SectionFrom+1]++ - } - candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed) - if err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - - // 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。 - dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots) - plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{ - ExistingDayLoad: dayLoadBaseline, - }) - if err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - - // 4. 回填 + 校验 + 原子提交。 - afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper) - if err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - for taskID, after := range afterByID { - before := beforeByID[taskID] - if err := validateDay(state, after.Slot.Day); err != nil { - return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error()) - } - if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil { - return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error()) - } - if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil { - return fmt.Sprintf( - "均匀化调整失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。", - before.StateID, - before.Name, - formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd), - conflict.StateID, - conflict.Name, - ) - } - } - spreadEvenProposals := make(map[int][]TaskSlot, len(afterByID)) - for taskID, after := range afterByID { - spreadEvenProposals[taskID] = []TaskSlot{after.Slot} - } - if err := validateLocalOrderBatchPlacement(state, spreadEvenProposals); err != nil { - return fmt.Sprintf("均匀化调整失败:%s。", err.Error()) - } - - clone := state.Clone() - for taskID, after := range afterByID { - task := clone.TaskByStateID(taskID) - if task == nil { - return fmt.Sprintf("均匀化调整失败:任务ID %d 在提交阶段不存在。", taskID) - } - task.Slots = []TaskSlot{after.Slot} - } - state.Tasks = clone.Tasks - - beforeOrdered := sortMinContextSnapshots(beforeByID) - changedLines := make([]string, 0, len(beforeOrdered)) - affectedDays := make(map[int]bool, len(beforeOrdered)*2) - for _, before := range beforeOrdered { - after := afterByID[before.StateID] - if sameTaskSlot(before.Slot, after.Slot) { - continue - } - changedLines = append(changedLines, fmt.Sprintf( - " [%d]%s:%s -> %s", - before.StateID, - before.Name, - formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd), - formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd), - )) - affectedDays[before.Slot.Day] = true - affectedDays[after.Slot.Day] = true - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf( - "均匀化调整完成:共处理 %d 个任务,候选坑位 %d 个。\n", - len(beforeByID), len(candidateSlots), - )) - if len(changedLines) == 0 { - sb.WriteString("规划结果与当前落位一致,无需调整。") - return sb.String() - } - sb.WriteString("本次调整:\n") - for _, line := range changedLines { - sb.WriteString(line + "\n") - } - for _, day := range sortedKeys(affectedDays) { - sb.WriteString(formatDayOccupancy(state, day) + "\n") - } - return strings.TrimSpace(sb.String()) -} - -func ParseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) { - return ParseCompositeTaskIDs(args) -} - -func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) { - return ParseCompositeTaskIDs(args) -} - -func ParseCompositeTaskIDs(args map[string]any) ([]int, error) { - if ids, ok := ArgsIntSlice(args, "task_ids"); ok && len(ids) > 0 { - return ids, nil - } - if id, ok := ArgsInt(args, "task_id"); ok { - return []int{id}, nil - } - return nil, fmt.Errorf("缺少必填参数 task_ids(兼容单值 task_id)") -} - -// collectCompositePlannerTasks 统一收集复合工具输入任务,并做“可移动 suggested”校验。 -func collectCompositePlannerTasks( - state *ScheduleState, - taskIDs []int, - toolLabel string, -) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) { - normalizedIDs := uniquePositiveInts(taskIDs) - if len(normalizedIDs) < 2 { - return nil, nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel) - } - - idMapper, err := buildCompositeIDMapper(normalizedIDs) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s失败:ID 映射构建失败:%s", toolLabel, err.Error()) - } - - plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs)) - beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs)) - excludeIDs := make([]int, 0, len(normalizedIDs)) - - for rank, taskID := range normalizedIDs { - task := state.TaskByStateID(taskID) - if task == nil { - return nil, nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID) - } - if !IsSuggestedTask(*task) { - return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name) - } - if err := checkLocked(*task); err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error()) - } - if len(task.Slots) != 1 { - return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots)) - } - - slot := task.Slots[0] - if err := validateDay(state, slot.Day); err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error()) - } - if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil { - return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error()) - } - week, dayOfWeek, ok := state.DayToWeekDay(slot.Day) - if !ok { - return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day) - } - - contextTag := normalizeMinContextTag(*task) - beforeByID[task.StateID] = minContextSnapshot{ - StateID: task.StateID, - Name: task.Name, - ContextTag: contextTag, - Slot: slot, - } - excludeIDs = append(excludeIDs, task.StateID) - plannerTasks = append(plannerTasks, refineTaskCandidate{ - TaskID: task.StateID, - Week: week, - DayOfWeek: dayOfWeek, - SectionFrom: slot.SlotStart, - SectionTo: slot.SlotEnd, - Name: strings.TrimSpace(task.Name), - ContextTag: contextTag, - OriginRank: rank + 1, - }) - } - - return plannerTasks, beforeByID, excludeIDs, idMapper, nil -} - -// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。 -func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) { - if len(tasks) == 0 { - return nil, fmt.Errorf("任务列表为空") - } - if idMapper == nil { - return nil, fmt.Errorf("ID 映射为空") - } - result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks)) - for _, task := range tasks { - logicID, ok := idMapper.stateToLogic[task.TaskID] - if !ok { - return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID) - } - result = append(result, compositelogic.RefineTaskCandidate{ - TaskItemID: logicID, - Week: task.Week, - DayOfWeek: task.DayOfWeek, - SectionFrom: task.SectionFrom, - SectionTo: task.SectionTo, - Name: task.Name, - ContextTag: task.ContextTag, - OriginRank: task.OriginRank, - }) - } - return result, nil -} - -func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate { - slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks)) - for _, task := range tasks { - slots = append(slots, compositelogic.RefineSlotCandidate{ - Week: task.Week, - DayOfWeek: task.DayOfWeek, - SectionFrom: task.SectionFrom, - SectionTo: task.SectionTo, - }) - } - return slots -} - -func buildAfterSnapshotsFromPlannedMoves( - state *ScheduleState, - beforeByID map[int]minContextSnapshot, - plannedMoves []compositelogic.RefineMovePlanItem, - idMapper *compositeIDMapper, -) (map[int]minContextSnapshot, error) { - if len(plannedMoves) == 0 { - return nil, fmt.Errorf("规划结果为空") - } - if idMapper == nil { - return nil, fmt.Errorf("ID 映射为空") - } - - moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves)) - for _, move := range plannedMoves { - stateID, ok := idMapper.logicToState[move.TaskItemID] - if !ok { - return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID) - } - if _, exists := moveByID[stateID]; exists { - return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID) - } - moveByID[stateID] = move - } - - afterByID := make(map[int]minContextSnapshot, len(beforeByID)) - for taskID, before := range beforeByID { - move, ok := moveByID[taskID] - if !ok { - return nil, fmt.Errorf("规划结果不完整:缺少任务 id=%d", taskID) - } - day, ok := state.WeekDayToDay(move.ToWeek, move.ToDay) - if !ok { - return nil, fmt.Errorf("任务 id=%d 目标 week/day 无法映射到 day_index:W%dD%d", taskID, move.ToWeek, move.ToDay) - } - afterByID[taskID] = minContextSnapshot{ - StateID: before.StateID, - Name: before.Name, - ContextTag: before.ContextTag, - Slot: TaskSlot{ - Day: day, - SlotStart: move.ToSectionFrom, - SlotEnd: move.ToSectionTo, - }, - } - } - return afterByID, nil -} - -func collectSpreadEvenCandidateSlotsBySpan( - state *ScheduleState, - args map[string]any, - spanNeed map[int]int, -) ([]compositelogic.RefineSlotCandidate, error) { - if len(spanNeed) == 0 { - return nil, fmt.Errorf("未识别到任务跨度需求") - } - - spans := make([]int, 0, len(spanNeed)) - for span := range spanNeed { - spans = append(spans, span) - } - sort.Ints(spans) - - allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16) - seen := make(map[string]struct{}, 64) - for _, span := range spans { - required := spanNeed[span] - queryArgs := buildSpreadEvenSlotQueryArgs(args, span, required) - raw := QueryAvailableSlots(state, queryArgs) - - var failed struct { - Error string `json:"error"` - } - _ = json.Unmarshal([]byte(raw), &failed) - if strings.TrimSpace(failed.Error) != "" { - return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, strings.TrimSpace(failed.Error)) - } - - var payload queryAvailableSlotsResult - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return nil, fmt.Errorf("解析跨度=%d 的候选坑位结果失败:%v", span, err) - } - if len(payload.Slots) < required { - return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots)) - } - - for _, slot := range payload.Slots { - key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SlotStart, slot.SlotEnd) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - allSlots = append(allSlots, compositelogic.RefineSlotCandidate{ - Week: slot.Week, - DayOfWeek: slot.DayOfWeek, - SectionFrom: slot.SlotStart, - SectionTo: slot.SlotEnd, - }) - } - } - return allSlots, nil -} - -func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) map[string]any { - query := make(map[string]any, 16) - query["span"] = span - - limit := required * 6 - if limit < required { - limit = required - } - if customLimit, ok := readIntAny(args, "limit"); ok && customLimit > limit { - limit = customLimit - } - query["limit"] = limit - query["allow_embed"] = readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding") - - for _, key := range []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "day_scope", "after_section", "before_section"} { - if value, ok := args[key]; ok { - query[key] = value - } - } - if week, ok := readIntAny(args, "to_week", "target_week", "new_week"); ok { - query["week"] = week - } - if day, ok := readIntAny(args, "to_day", "target_day", "target_day_of_week", "new_day"); ok { - query["day_of_week"] = []int{day} - } - - if values := uniquePositiveInts(readIntSliceAny(args, "week_filter", "weeks")); len(values) > 0 { - query["week_filter"] = values - } - if values := uniqueInts(readIntSliceAny(args, "day_of_week", "days", "day_filter")); len(values) > 0 { - query["day_of_week"] = values - } - if values := uniqueInts(readIntSliceAny(args, "exclude_sections", "exclude_section")); len(values) > 0 { - query["exclude_sections"] = values - } - - return query -} - -func buildSpreadEvenDayLoadBaseline( - state *ScheduleState, - excludeTaskIDs []int, - slots []compositelogic.RefineSlotCandidate, -) map[string]int { - if len(slots) == 0 { - return nil - } - - targetDays := make(map[string]struct{}, len(slots)) - for _, slot := range slots { - targetDays[composeDayKey(slot.Week, slot.DayOfWeek)] = struct{}{} - } - if len(targetDays) == 0 { - return nil - } - - excludeSet := make(map[int]struct{}, len(excludeTaskIDs)) - for _, id := range excludeTaskIDs { - excludeSet[id] = struct{}{} - } - - load := make(map[string]int, len(targetDays)) - for _, task := range state.Tasks { - if !IsSuggestedTask(task) { - continue - } - if _, excluded := excludeSet[task.StateID]; excluded { - continue - } - for _, slot := range task.Slots { - week, dayOfWeek, ok := state.DayToWeekDay(slot.Day) - if !ok { - continue - } - key := composeDayKey(week, dayOfWeek) - if _, inTarget := targetDays[key]; !inTarget { - continue - } - load[key]++ - } - } - return load -} - -func composeDayKey(week, day int) string { - return fmt.Sprintf("%d-%d", week, day) -} - -func uniquePositiveInts(values []int) []int { - seen := make(map[int]struct{}, len(values)) - result := make([]int, 0, len(values)) - for _, value := range values { - if value <= 0 { - continue - } - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - result = append(result, value) - } - return result -} - -func normalizeMinContextTag(task ScheduleTask) string { - if tag := strings.TrimSpace(task.Category); tag != "" { - return tag - } - if tag := strings.TrimSpace(task.Name); tag != "" { - return tag - } - return "General" -} - -func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot { - items := make([]minContextSnapshot, 0, len(snapshotByID)) - for _, item := range snapshotByID { - items = append(items, item) - } - sort.SliceStable(items, func(i, j int) bool { - if items[i].Slot.Day != items[j].Slot.Day { - return items[i].Slot.Day < items[j].Slot.Day - } - if items[i].Slot.SlotStart != items[j].Slot.SlotStart { - return items[i].Slot.SlotStart < items[j].Slot.SlotStart - } - if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd { - return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd - } - return items[i].StateID < items[j].StateID - }) - return items -} - -func countMinContextSwitches(ordered []minContextSnapshot) int { - if len(ordered) < 2 { - return 0 - } - switches := 0 - prevTag := strings.TrimSpace(ordered[0].ContextTag) - for i := 1; i < len(ordered); i++ { - currentTag := strings.TrimSpace(ordered[i].ContextTag) - if currentTag != prevTag { - switches++ - } - prevTag = currentTag - } - return switches -} - -func sameTaskSlot(a, b TaskSlot) bool { - return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd -} diff --git a/backend/newAgent/tools/schedule/order_constraints.go b/backend/newAgent/tools/schedule/order_constraints.go index 2db2a9c..202e07e 100644 --- a/backend/newAgent/tools/schedule/order_constraints.go +++ b/backend/newAgent/tools/schedule/order_constraints.go @@ -20,7 +20,7 @@ func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targ // validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。 // // 职责边界: -// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突; +// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免批量局部调整时出现伪冲突; // 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序; // 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。 func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error { diff --git a/backend/newAgent/tools/taskquery.go b/backend/newAgent/tools/taskquery.go deleted file mode 100644 index 4413b67..0000000 --- a/backend/newAgent/tools/taskquery.go +++ /dev/null @@ -1,320 +0,0 @@ -package newagenttools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" -) - -// ==================== 常量 ==================== - -const ( - // defaultTaskQueryLimit 是任务查询默认返回条数。 - defaultTaskQueryLimit = 5 - // maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。 - maxTaskQueryLimit = 20 -) - -// ==================== 优先级中文映射 ==================== - -// taskQueryPriorityLabelCN 将象限编号转为中文标签。 -// -// 职责边界: -// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。 -// 2. 不依赖旧链路 agentmodel.PriorityLabelCN,保持新工具自包含。 -func taskQueryPriorityLabelCN(priority int) string { - switch priority { - case 1: - return "重要且紧急" - case 2: - return "重要不紧急" - case 3: - return "简单不重要" - case 4: - return "复杂不重要" - default: - return "未知" - } -} - -// ==================== 类型定义 ==================== - -// TaskQueryDeps 描述任务查询工具所需的外部依赖。 -// -// 职责边界: -// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO; -// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。 -type TaskQueryDeps struct { - // QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。 - // 调用目的:解耦工具层与 DAO 层,方便测试和替换。 - QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error) -} - -// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。 -// -// 输入输出语义: -// 1. 所有筛选条件均为可选,Quadrant 为 nil 表示不限象限。 -// 2. 时间边界为 nil 表示不限时间范围。 -type TaskQueryParams struct { - Quadrant *int - SortBy string // deadline | priority | id - Order string // asc | desc - Limit int - IncludeCompleted bool - Keyword string - DeadlineBefore *time.Time - DeadlineAfter *time.Time -} - -// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。 -// -// 职责边界: -// 1. 只承载展示所需字段,避免暴露底层数据库结构。 -// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。 -type TaskQueryResult struct { - ID int `json:"id"` - Title string `json:"title"` - PriorityGroup int `json:"priority_group"` - PriorityLabel string `json:"priority_label"` - IsCompleted bool `json:"is_completed"` - DeadlineAt string `json:"deadline_at,omitempty"` -} - -// ==================== 时间解析 ==================== - -// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。 -var taskQueryTimeLayouts = []string{ - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02 15:04", - "2006-01-02", -} - -// parseTaskQueryBoundaryTime 解析截止时间上下界。 -// -// 职责边界: -// 1. isUpper=true 时,纯日期补到当天 23:59:59。 -// 2. isUpper=false 时,纯日期补到当天 00:00:00。 -// 3. 不支持的格式直接返回错误,由调用方决定是否回退。 -func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) { - text := strings.TrimSpace(raw) - if text == "" { - return nil, nil - } - - loc := time.Local - for _, layout := range taskQueryTimeLayouts { - var ( - parsed time.Time - err error - ) - if layout == time.RFC3339 { - parsed, err = time.Parse(layout, text) - if err == nil { - parsed = parsed.In(loc) - } - } else { - parsed, err = time.ParseInLocation(layout, text, loc) - } - if err != nil { - continue - } - - // 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。 - // 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束", - // 所以补 23:59:59;作为下界则补 00:00:00。 - if layout == "2006-01-02" { - if isUpper { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc) - } else { - parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc) - } - } - return &parsed, nil - } - return nil, fmt.Errorf("时间格式不支持: %s", text) -} - -// formatTaskQueryTime 将内部时间格式化为给模型展示的分钟级文本。 -func formatTaskQueryTime(value *time.Time) string { - if value == nil { - return "" - } - return value.In(time.Local).Format("2006-01-02 15:04") -} - -// ==================== 工具 Handler ==================== - -// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。 -// -// 职责边界: -// 1. 负责参数校验、时间解析、调 deps 查库、组装返回; -// 2. 不负责 LLM 交互和会话管理。 -// 3. state 参数忽略——任务查询不需要 ScheduleState,已注册到 scheduleFreeTools。 -func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) string { - _ = state - - // 1. 提取 _user_id(由 execute 节点在调用前注入)。 - userID := 0 - if uid, ok := args["_user_id"].(int); ok { - userID = uid - } - if userID <= 0 { - return "工具调用失败:无法识别用户身份。" - } - - // 2. 提取并校验查询参数。 - params, err := extractTaskQueryParams(args) - if err != nil { - return fmt.Sprintf("工具调用失败:%s", err) - } - - // 3. 调用依赖查库。 - results, err := deps.QueryTasks(context.Background(), userID, params) - if err != nil { - return fmt.Sprintf("工具调用失败:查询任务时出错(%s)。", err) - } - - // 4. 为每条结果填充优先级中文标签。 - for i := range results { - results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup) - } - - // 5. 返回结构化 JSON。 - if len(results) == 0 { - return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}` - } - - output := struct { - Total int `json:"total"` - Items []TaskQueryResult `json:"items"` - Message string `json:"message"` - }{ - Total: len(results), - Items: results, - Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)), - } - - jsonBytes, marshalErr := json.Marshal(output) - if marshalErr != nil { - // JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。 - return fmt.Sprintf("找到 %d 条匹配任务。", len(results)) - } - return string(jsonBytes) - } -} - -// extractTaskQueryParams 从 args 提取并校验任务查询参数。 -// -// 步骤说明: -// 1. 先准备默认值,保证空参数也能执行一次合理查询。 -// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。 -// 3. 若上下界冲突,则直接返回错误。 -func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) { - params := TaskQueryParams{ - SortBy: "deadline", - Order: "asc", - Limit: defaultTaskQueryLimit, - IncludeCompleted: false, - } - - // 2.1 象限:1~4,超出范围拒绝。 - if v, ok := args["quadrant"]; ok { - switch val := v.(type) { - case float64: - q := int(val) - if q < 1 || q > 4 { - return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q) - } - params.Quadrant = &q - case int: - if val < 1 || val > 4 { - return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val) - } - params.Quadrant = &val - } - } - - // 2.2 排序字段:仅支持 deadline/priority/id。 - if v, ok := args["sort_by"].(string); ok { - sortBy := strings.ToLower(strings.TrimSpace(v)) - if sortBy != "" { - switch sortBy { - case "deadline", "priority", "id": - params.SortBy = sortBy - default: - return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy) - } - } - } - - // 2.3 排序方向:仅支持 asc/desc。 - if v, ok := args["order"].(string); ok { - order := strings.ToLower(strings.TrimSpace(v)) - if order != "" { - switch order { - case "asc", "desc": - params.Order = order - default: - return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order) - } - } - } - - // 2.4 条数:默认 5,上限 20。 - if v, ok := args["limit"]; ok { - switch val := v.(type) { - case float64: - params.Limit = int(val) - case int: - params.Limit = val - } - } - if params.Limit <= 0 { - params.Limit = defaultTaskQueryLimit - } - if params.Limit > maxTaskQueryLimit { - params.Limit = maxTaskQueryLimit - } - - // 2.5 是否包含已完成任务。 - if v, ok := args["include_completed"]; ok { - switch val := v.(type) { - case bool: - params.IncludeCompleted = val - } - } - - // 2.6 关键词。 - if v, ok := args["keyword"].(string); ok { - params.Keyword = strings.TrimSpace(v) - } - - // 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。 - beforeRaw, _ := args["deadline_before"].(string) - before, err := parseTaskQueryBoundaryTime(beforeRaw, true) - if err != nil { - return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err) - } - params.DeadlineBefore = before - - afterRaw, _ := args["deadline_after"].(string) - after, err := parseTaskQueryBoundaryTime(afterRaw, false) - if err != nil { - return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err) - } - params.DeadlineAfter = after - - // 2.8 时间区间合法性校验:下界不能晚于上界。 - if params.DeadlineBefore != nil && params.DeadlineAfter != nil && - params.DeadlineAfter.After(*params.DeadlineBefore) { - return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before") - } - - return params, nil -} diff --git a/backend/newAgent/tools/tool_domain_map.go b/backend/newAgent/tools/tool_domain_map.go index 03d8823..120505c 100644 --- a/backend/newAgent/tools/tool_domain_map.go +++ b/backend/newAgent/tools/tool_domain_map.go @@ -54,16 +54,13 @@ var toolProfileByName = map[string]toolProfile{ "queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue}, "queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue}, - "place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "spread_even": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "min_context_switch": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, + "place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, + "move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, + "swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, + "batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, + "unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation}, - "analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze}, - "analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze}, + "analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze}, "web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb}, "web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb}, diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index 4f1af72..ef7b00b 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -253,7 +253,7 @@ func (s *AgentService) runNewAgentGraph( // 11.6. graph 完成后条件触发记忆抽取。 // 说明: - // 1. 只有本轮未使用 quick_note_create 时才触发记忆抽取; + // 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取; // 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。 if finalState != nil { cs := finalState.EnsureRuntimeState().EnsureCommonState() diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts index 49bd392..5ed40c6 100644 --- a/frontend/src/api/schedule_agent.ts +++ b/frontend/src/api/schedule_agent.ts @@ -19,7 +19,15 @@ export interface TimelineConfirmPayload { export interface TimelineEvent { id: number seq: number - kind: 'user_text' | 'assistant_text' | 'tool_call' | 'tool_result' | 'confirm_request' | 'schedule_completed' + kind: + | 'user_text' + | 'assistant_text' + | 'tool_call' + | 'tool_result' + | 'confirm_request' + | 'schedule_completed' + | 'interrupt' + | 'status' role?: 'user' | 'assistant' content?: string payload?: { diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 1f311dc..ff7dbbf 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -504,6 +504,22 @@ function appendToolTraceEvent( } ensureToolTraceBucket(messageId) + const normalizedDetail = detail.trim() + const normalizedToolName = toolName.trim() + const matchedPendingEvent = findMergeableToolTraceEvent( + messageId, + state, + normalizedSummary, + normalizedDetail, + normalizedToolName, + ) + if (matchedPendingEvent) { + matchedPendingEvent.state = state + matchedPendingEvent.summary = normalizedSummary + matchedPendingEvent.detail = normalizedDetail || matchedPendingEvent.detail + matchedPendingEvent.toolName = normalizedToolName || matchedPendingEvent.toolName + return + } const eventSeq = nextAssistantTimelineSeq() const eventId = `${messageId}:tool:${eventSeq}` @@ -517,12 +533,84 @@ function appendToolTraceEvent( seq: eventSeq, state, summary: normalizedSummary, - detail: detail.trim() || undefined, - toolName: toolName.trim() || undefined, + detail: normalizedDetail || undefined, + toolName: normalizedToolName || undefined, }) assistantTimelineLastKindMap[messageId] = 'tool' } +function isPendingToolTraceState(state: ToolTraceState) { + return state === 'called' +} + +function findMergeableToolTraceEvent( + messageId: string, + nextState: ToolTraceState, + summary: string, + detail: string, + toolName: string, +): ToolTraceEvent | null { + if (nextState === 'called') { + return null + } + + const pendingEvents = (toolTraceEventsMap[messageId] || []) + .slice() + .reverse() + .filter((event) => isPendingToolTraceState(event.state)) + + if (pendingEvents.length <= 0) { + return null + } + + const normalizedToolName = toolName.trim().toLowerCase() + const normalizedDetail = detail.trim() + const normalizedSummary = summary.trim() + + if (normalizedToolName && normalizedDetail) { + const exactMatch = pendingEvents.find((event) => { + return ( + `${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName && + `${event.detail || ''}`.trim() === normalizedDetail + ) + }) + if (exactMatch) { + return exactMatch + } + } + + if (normalizedToolName) { + const toolNameMatch = pendingEvents.find((event) => { + return `${event.toolName || ''}`.trim().toLowerCase() === normalizedToolName + }) + if (toolNameMatch) { + return toolNameMatch + } + } + + if (normalizedDetail) { + const detailMatch = pendingEvents.find((event) => { + return `${event.detail || ''}`.trim() === normalizedDetail + }) + if (detailMatch) { + return detailMatch + } + } + + if (normalizedSummary) { + const summaryMatch = pendingEvents.find((event) => event.summary === normalizedSummary) + if (summaryMatch) { + return summaryMatch + } + } + + if (pendingEvents.length === 1) { + return pendingEvents[0] + } + + return null +} + function appendStatusTraceEvent( messageId: string, code: string, @@ -725,9 +813,46 @@ function shouldSkipStatusEvent(code: string, stage = '') { if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) { return true } + + const hiddenStatusCodes = new Set([ + 'accepted', + 'ask_user', + 'planning', + 'resumed', + 'confirmed', + 'rejected', + 'executing', + 'summarizing', + 'done', + 'rough_building', + 'order_guard_initialized', + 'order_guard_passed', + 'order_guard_restored', + 'order_guard_restore_skipped', + 'context_compact_start', + 'context_compact_done', + 'plan_auto_confirmed', + ]) + + if (hiddenStatusCodes.has(code)) { + return true + } return false } +function isAssistantTimelineKind(kind: string) { + const assistantKinds = new Set([ + 'assistant_text', + 'tool_call', + 'tool_result', + 'confirm_request', + 'schedule_completed', + 'interrupt', + 'status', + ]) + return assistantKinds.has(kind) +} + function isToolTraceExpanded(eventId: string) { return toolTraceExpandedMap[eventId] === true } @@ -1582,12 +1707,12 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ const kind = String(event.kind || '').toLowerCase() const rawRole = String(event.role || '').toLowerCase() - // 如果 role 已明确为 user,或者 kind 包含 user 关键字 + // 1. timeline 重建时先识别显式 user 事件,避免把真正的用户输入吞进 assistant 回合。 + // 2. interrupt / status 这类 assistant 侧协议事件不能再掉进 user 兜底,否则会把 ask_user 正文切断。 + // 3. 这里仍保留 kind.includes('user') 的保守判断,只是把 assistant 白名单补齐到本轮真实协议。 let isUser = rawRole === 'user' || kind.includes('user') - // 终极兜底:只要不是明确的五大助手专属事件,就将其视为用户的消息回合边界 if (!isUser) { - const knownAssistantKinds = ['assistant_text', 'tool_call', 'tool_result', 'confirm_request', 'schedule_completed'] - if (!knownAssistantKinds.includes(kind)) { + if (!isAssistantTimelineKind(kind)) { isUser = true } } @@ -1620,6 +1745,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ switch (event.kind) { case 'assistant_text': + case 'interrupt': if (event.content) { const newContent = event.content const oldContent = currentAssistantMessage.content || '' @@ -1657,14 +1783,14 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ case 'tool_call': if (event.payload?.tool) { const t = event.payload.tool - appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name) + appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name) } break case 'tool_result': if (event.payload?.tool) { const t = event.payload.tool - appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name) + appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name) } break