Version: 0.7.5.dev.260324
🐛 fix(agent/schedulerefine): 修复复合微调分支链路问题,并将 MinContextSwitch 重构为固定坑位重排语义 - 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题 - ✅ 保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果 - 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch - 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效 - ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变” - 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突 - 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题 - 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败 📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程 ♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent - 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构 - 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责 - 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象 - 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力 - 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖 - 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路 - ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法 - 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件 - 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定 - 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档 ♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt
This commit is contained in:
@@ -5,30 +5,4 @@ const (
|
||||
SystemPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。
|
||||
你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。
|
||||
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。`
|
||||
|
||||
// SmartAssistantPrompt 合并了分诊与对话能力的超级提示词
|
||||
SmartAssistantPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。
|
||||
### 你的双重职责:
|
||||
1. **直接对话**:如果用户是闲聊、查询简单信息或进行通用问答,请直接以专业且幽默的口吻回复。
|
||||
2. **决策路由**:如果用户提出需要“安排日程”、“解决冲突”或涉及“3D Atomic TimeGrid”的操作,请在回复中明确你的计划,并准备调用相应的排程工具。
|
||||
### 核心约束:
|
||||
- 始终保持对“稳扎稳打(Steady)模式”的敬畏,压缩率不得超过 15%。
|
||||
- 针对重邮场景(如:红岩网校、南山教学楼)提供有温度的建议。
|
||||
### 输出格式:
|
||||
- 如果涉及排程工具调用,请先简要说明你的调整思路,再执行动作。`
|
||||
|
||||
// SchedulerPromptTemplate 排程专家 (Scheduler):核心算法 Agent
|
||||
// 这里注入 3D Grid 和 Steady 模式的约束
|
||||
SchedulerPromptTemplate = `你是一位精通“三维原子时间网格(3D Atomic TimeGrid)”的顶级排程架构师。
|
||||
在处理用户的排程请求时,你必须遵循以下硬性逻辑约束:
|
||||
1. 稳扎稳打(Steady)模式:任务步长(Step)的动态分配必须保守,压缩率严禁超过原始时长的 15%。
|
||||
2. 逻辑空间投影(Logical Space Mapping):当发生时空重叠时,优先尝试在逻辑向量维度平移,而非直接删除冲突任务。
|
||||
3. 冲突自愈:若发现网格冲突,请主动提出“缩放任务块”或“重新锚定时间点”的自愈方案。
|
||||
|
||||
请以极其严谨的态度处理每一秒钟的分配。`
|
||||
|
||||
// DefaultPromptTemplate 通用助手 (Assistant):也就是你之前占位的那个
|
||||
DefaultPromptTemplate = `你是一位时间管理大师、日程安排专家兼个人助理。
|
||||
你的目标是协助用户高效安排日程。请确保你的回答简洁明了,直接针对用户的需求进行回复。
|
||||
如果用户提到重邮(CQUPT)相关内容(如:南山、红岩网校、卓越工程师班),请表现出你的亲切感。`
|
||||
)
|
||||
|
||||
85
backend/agent/schedulerefine/composite_route_test.go
Normal file
85
backend/agent/schedulerefine/composite_route_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package schedulerefine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
func TestRefineToolSpreadEvenRespectsCanonicalRouteFilters(t *testing.T) {
|
||||
entries := []model.HybridScheduleEntry{
|
||||
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
|
||||
// 1. 这里放一个更早周次的 existing 条目,用来把可查询窗口拉到 W11;
|
||||
// 2. 若复合工具内部丢了 week_filter/day_of_week,就会优先落到更早的 W11D1,而不是目标 W12D3。
|
||||
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 11, DayOfWeek: 5, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
|
||||
}
|
||||
params := map[string]any{
|
||||
"task_item_ids": []int{1},
|
||||
"week_filter": []int{12},
|
||||
"day_of_week": []int{3},
|
||||
"allow_embed": false,
|
||||
}
|
||||
|
||||
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, refineToolPolicy{
|
||||
OriginOrderMap: map[int]int{1: 1},
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
|
||||
}
|
||||
|
||||
idx := findSuggestedByID(nextEntries, 1)
|
||||
if idx < 0 {
|
||||
t.Fatalf("未找到 task_item_id=1")
|
||||
}
|
||||
got := nextEntries[idx]
|
||||
if got.Week != 12 || got.DayOfWeek != 3 {
|
||||
t.Fatalf("期望复合工具严格遵守 week_filter/day_of_week,实际落点=W%dD%d", got.Week, got.DayOfWeek)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompositeRouteNodeAllowsHandoffWithoutDeterministicObjective(t *testing.T) {
|
||||
entries := []model.HybridScheduleEntry{
|
||||
{TaskItemID: 11, Name: "任务11", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学"},
|
||||
{TaskItemID: 12, Name: "任务12", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法"},
|
||||
{TaskItemID: 13, Name: "任务13", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学"},
|
||||
}
|
||||
st := &ScheduleRefineState{
|
||||
UserMessage: "把这些任务按最少上下文切换整理一下",
|
||||
HybridEntries: cloneHybridEntries(entries),
|
||||
InitialHybridEntries: cloneHybridEntries(entries),
|
||||
WorksetTaskIDs: []int{11, 12, 13},
|
||||
RequiredCompositeTool: "MinContextSwitch",
|
||||
CompositeRetryMax: 0,
|
||||
ExecuteMax: 4,
|
||||
OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3},
|
||||
CompositeToolCalled: map[string]bool{
|
||||
"SpreadEven": false,
|
||||
"MinContextSwitch": false,
|
||||
},
|
||||
CompositeToolSuccess: map[string]bool{
|
||||
"SpreadEven": false,
|
||||
"MinContextSwitch": false,
|
||||
},
|
||||
}
|
||||
|
||||
stageLogs := make([]string, 0, 8)
|
||||
nextState, err := runCompositeRouteNode(context.Background(), st, func(stage, detail string) {
|
||||
stageLogs = append(stageLogs, stage+"|"+detail)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runCompositeRouteNode 返回错误: %v", err)
|
||||
}
|
||||
if nextState == nil {
|
||||
t.Fatalf("runCompositeRouteNode 返回 nil state")
|
||||
}
|
||||
if !nextState.CompositeRouteSucceeded {
|
||||
t.Fatalf("期望复合分支在缺少 deterministic objective 时直接出站,实际 CompositeRouteSucceeded=false, stages=%v, action_logs=%v", stageLogs, nextState.ActionLogs)
|
||||
}
|
||||
if nextState.DisableCompositeTools {
|
||||
t.Fatalf("期望复合分支直接进入终审,不应降级为禁复合 ReAct")
|
||||
}
|
||||
if !nextState.CompositeToolSuccess["MinContextSwitch"] {
|
||||
t.Fatalf("期望 MinContextSwitch 成功状态被记录")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package schedulerefine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -96,6 +97,67 @@ func TestRefineToolMinContextSwitchGroupsContext(t *testing.T) {
|
||||
if switches > 1 {
|
||||
t.Fatalf("期望最少上下文切换(<=1),实际 switches=%d, tasks=%+v", switches, selected)
|
||||
}
|
||||
if selected[0].TaskItemID != 11 || selected[1].TaskItemID != 13 || selected[2].TaskItemID != 12 {
|
||||
t.Fatalf("期望在原坑位集合内重排为 11,13,12,实际=%+v", selected)
|
||||
}
|
||||
for _, task := range selected {
|
||||
if task.Week != 16 || task.DayOfWeek != 1 {
|
||||
t.Fatalf("MinContextSwitch 不应跳出原坑位集合,实际 task=%+v", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefineToolMinContextSwitchKeepsCurrentSlotSet(t *testing.T) {
|
||||
entries := []model.HybridScheduleEntry{
|
||||
{TaskItemID: 21, Name: "随机事件与概率基础概念复习", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "General"},
|
||||
{TaskItemID: 22, Name: "数制、码制与逻辑代数基础", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, ContextTag: "General"},
|
||||
{TaskItemID: 23, Name: "第二章 条件概率与全概率公式", Type: "task", Status: "suggested", Week: 14, DayOfWeek: 3, SectionFrom: 3, SectionTo: 4, ContextTag: "General"},
|
||||
}
|
||||
params := map[string]any{
|
||||
"task_item_ids": []any{21.0, 22.0, 23.0},
|
||||
"week": 14,
|
||||
"limit": 48,
|
||||
"allow_embed": true,
|
||||
}
|
||||
policy := refineToolPolicy{OriginOrderMap: map[int]int{21: 1, 22: 2, 23: 3}}
|
||||
|
||||
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
|
||||
if !result.Success {
|
||||
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
|
||||
}
|
||||
|
||||
selected := make([]model.HybridScheduleEntry, 0, 3)
|
||||
for _, id := range []int{21, 22, 23} {
|
||||
idx := findSuggestedByID(nextEntries, id)
|
||||
if idx < 0 {
|
||||
t.Fatalf("未找到任务 id=%d", id)
|
||||
}
|
||||
selected = append(selected, nextEntries[idx])
|
||||
}
|
||||
sort.SliceStable(selected, func(i, j int) bool {
|
||||
if selected[i].Week != selected[j].Week {
|
||||
return selected[i].Week < selected[j].Week
|
||||
}
|
||||
if selected[i].DayOfWeek != selected[j].DayOfWeek {
|
||||
return selected[i].DayOfWeek < selected[j].DayOfWeek
|
||||
}
|
||||
return selected[i].SectionFrom < selected[j].SectionFrom
|
||||
})
|
||||
|
||||
if selected[0].TaskItemID != 21 || selected[1].TaskItemID != 23 || selected[2].TaskItemID != 22 {
|
||||
t.Fatalf("期望按原坑位集合重排为概率, 概率, 数电,实际=%+v", selected)
|
||||
}
|
||||
expectedSlots := map[int]string{
|
||||
21: "14-1-1-2",
|
||||
23: "14-1-11-12",
|
||||
22: "14-3-3-4",
|
||||
}
|
||||
for _, task := range selected {
|
||||
got := fmt.Sprintf("%d-%d-%d-%d", task.Week, task.DayOfWeek, task.SectionFrom, task.SectionTo)
|
||||
if got != expectedSlots[task.TaskItemID] {
|
||||
t.Fatalf("任务 id=%d 应仅在原坑位集合内换位,期望=%s 实际=%s", task.TaskItemID, expectedSlots[task.TaskItemID], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTaskIDsFromToolCallComposite(t *testing.T) {
|
||||
|
||||
@@ -300,7 +300,17 @@ func runCompositeRouteNode(
|
||||
continue
|
||||
}
|
||||
|
||||
lastReason = "未启用确定性目标,无法在复合路由直接收口"
|
||||
// 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective;
|
||||
// 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct;
|
||||
// 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。
|
||||
if reason, ok := allowCompositeRouteExitByToolSuccess(st, result); ok {
|
||||
st.CompositeRouteSucceeded = true
|
||||
emitStage("schedule_refine.route.handoff", truncate(reason, 180))
|
||||
st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站"
|
||||
}
|
||||
|
||||
// 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。
|
||||
@@ -343,6 +353,30 @@ func buildCompositeRouteTaskIDs(st *ScheduleRefineState) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
// allowCompositeRouteExitByToolSuccess 判断“复合工具成功后,是否允许跳过 ReAct 直接进入终审”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 仅在当前没有 deterministic objective 时启用,避免覆盖原有“确定性验收优先”策略;
|
||||
// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行;
|
||||
// 3. 放行后并不代表最终成功,后续仍由 hard_check 做统一裁决。
|
||||
func allowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result reactToolResult) (string, bool) {
|
||||
if st == nil || !result.Success {
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" {
|
||||
return "", false
|
||||
}
|
||||
required := normalizeCompositeToolName(st.RequiredCompositeTool)
|
||||
toolName := normalizeCompositeToolName(result.Tool)
|
||||
if required == "" || toolName == "" || required != toolName {
|
||||
return "", false
|
||||
}
|
||||
if !isRequiredCompositeSatisfied(st) {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("复合工具 %s 已成功执行;当前目标暂不支持确定性收口,跳过 ReAct,交由终审裁决。", required), true
|
||||
}
|
||||
|
||||
func buildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) reactToolCall {
|
||||
limit := len(taskIDs) * 6
|
||||
if limit < 12 {
|
||||
@@ -729,7 +763,9 @@ func runHardCheckNode(
|
||||
// 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。
|
||||
intentPassLocked, intentReasonLocked, intentUnmetLocked := evaluateIntentForJudgement(ctx, chatModel, st, emitStage)
|
||||
emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120)))
|
||||
if changed := normalizeMovableTaskOrderByOrigin(st); changed {
|
||||
if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped {
|
||||
emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。")
|
||||
} else if changed {
|
||||
emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。")
|
||||
}
|
||||
report := evaluateHardChecks(ctx, chatModel, st, emitStage)
|
||||
@@ -754,7 +790,9 @@ func runHardCheckNode(
|
||||
}
|
||||
intentPassLocked, intentReasonLocked, intentUnmetLocked = evaluateIntentForJudgement(ctx, chatModel, st, emitStage)
|
||||
emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120)))
|
||||
if changed := normalizeMovableTaskOrderByOrigin(st); changed {
|
||||
if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped {
|
||||
emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位:MinContextSwitch 结果需要保留重排后的任务顺序。")
|
||||
} else if changed {
|
||||
emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。")
|
||||
}
|
||||
report = evaluateHardChecks(ctx, chatModel, st, emitStage)
|
||||
@@ -795,7 +833,7 @@ func runSummaryNode(
|
||||
emitModelRawDebug(emitStage, "summary", raw)
|
||||
}
|
||||
if err != nil || summary == "" {
|
||||
if st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed {
|
||||
if FinalHardCheckPassed(st) {
|
||||
summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed)
|
||||
} else {
|
||||
summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标"))
|
||||
@@ -803,7 +841,10 @@ func runSummaryNode(
|
||||
}
|
||||
summary = alignSummaryWithHardCheck(st, summary)
|
||||
st.FinalSummary = summary
|
||||
st.Completed = true
|
||||
// 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功;
|
||||
// 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”;
|
||||
// 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。
|
||||
st.Completed = FinalHardCheckPassed(st)
|
||||
emitStage("schedule_refine.summary.done", "微调总结已生成。")
|
||||
return st, nil
|
||||
}
|
||||
@@ -813,8 +854,9 @@ func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *Sched
|
||||
report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems))
|
||||
report.PhysicsPassed = len(report.PhysicsIssues) == 0
|
||||
// 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。
|
||||
// 2. 当 origin_order_map 为空时降级跳过,避免无基线时误报。
|
||||
needOrderCheck := len(st.OriginOrderMap) > 0
|
||||
// 2. 但 MinContextSwitch 成功后,重排后的顺序本身就是业务目标,不能再拿 origin_rank 反向判错。
|
||||
// 3. 当 origin_order_map 为空时同样降级跳过,避免无基线时误报。
|
||||
needOrderCheck := len(st.OriginOrderMap) > 0 && !shouldSkipOrderConstraintCheck(st)
|
||||
report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{
|
||||
KeepRelativeOrder: needOrderCheck,
|
||||
OrderScope: st.Contract.OrderScope,
|
||||
@@ -1305,6 +1347,34 @@ func normalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 默认仍保持旧行为,继续在终审前做展示侧顺序归位;
|
||||
// 2. 但当 MinContextSwitch 已成功执行时,重排后的顺序本身就是业务目标的一部分;
|
||||
// 3. 此时若再按 origin_rank 归位,会把复合工具效果直接抹掉,因此必须跳过。
|
||||
func tryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) {
|
||||
if shouldSkipOriginOrderNormalization(st) {
|
||||
return false, true
|
||||
}
|
||||
return normalizeMovableTaskOrderByOrigin(st), false
|
||||
}
|
||||
|
||||
func shouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool {
|
||||
if st == nil {
|
||||
return false
|
||||
}
|
||||
ensureCompositeStateMaps(st)
|
||||
if st.CompositeToolSuccess["MinContextSwitch"] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool {
|
||||
return shouldSkipOriginOrderNormalization(st)
|
||||
}
|
||||
|
||||
func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("nil state")
|
||||
@@ -2047,7 +2117,7 @@ func alignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string {
|
||||
if st == nil {
|
||||
return clean
|
||||
}
|
||||
passed := st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
|
||||
passed := FinalHardCheckPassed(st)
|
||||
if passed {
|
||||
if st.RoundUsed == 0 {
|
||||
return "本轮未执行调度动作(0轮),当前排程已满足终审条件。"
|
||||
|
||||
@@ -507,6 +507,70 @@ func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryNormalizeMovableTaskOrderByOriginSkipsAfterMinContextSwitch(t *testing.T) {
|
||||
st := &ScheduleRefineState{
|
||||
OriginOrderMap: map[int]int{
|
||||
101: 1,
|
||||
202: 2,
|
||||
},
|
||||
CompositeToolSuccess: map[string]bool{
|
||||
"SpreadEven": false,
|
||||
"MinContextSwitch": true,
|
||||
},
|
||||
HybridEntries: []model.HybridScheduleEntry{
|
||||
{TaskItemID: 202, Name: "task-202", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{TaskItemID: 101, Name: "task-101", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
|
||||
},
|
||||
}
|
||||
changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st)
|
||||
if !skipped {
|
||||
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序归位")
|
||||
}
|
||||
if changed {
|
||||
t.Fatalf("跳过顺序归位时不应报告 changed=true")
|
||||
}
|
||||
if st.HybridEntries[0].TaskItemID != 202 || st.HybridEntries[1].TaskItemID != 101 {
|
||||
t.Fatalf("跳过顺序归位后不应改写任务顺序: %+v", st.HybridEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateHardChecksSkipsOrderConstraintAfterMinContextSwitch(t *testing.T) {
|
||||
st := &ScheduleRefineState{
|
||||
UserMessage: "减少第15周科目切换",
|
||||
OriginOrderMap: map[int]int{
|
||||
101: 1,
|
||||
202: 2,
|
||||
},
|
||||
CompositeToolSuccess: map[string]bool{
|
||||
"SpreadEven": false,
|
||||
"MinContextSwitch": true,
|
||||
},
|
||||
InitialHybridEntries: []model.HybridScheduleEntry{
|
||||
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
|
||||
},
|
||||
HybridEntries: []model.HybridScheduleEntry{
|
||||
{TaskItemID: 202, Name: "数电任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
|
||||
{TaskItemID: 101, Name: "概率任务", Type: "task", Status: "suggested", Week: 15, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
|
||||
},
|
||||
Objective: RefineObjective{
|
||||
Mode: "move_all",
|
||||
SourceWeeks: []int{15},
|
||||
TargetWeeks: []int{15},
|
||||
BaselineSourceTaskCount: 2,
|
||||
RequiredMoveMin: 2,
|
||||
RequiredMoveMax: 2,
|
||||
},
|
||||
SlicePlan: RefineSlicePlan{
|
||||
WeekFilter: []int{15},
|
||||
},
|
||||
}
|
||||
report := evaluateHardChecks(nil, nil, st, nil)
|
||||
if !report.OrderPassed {
|
||||
t.Fatalf("期望 MinContextSwitch 成功后跳过顺序终审,实际 issues=%v", report.OrderIssues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecheckToolCallPolicyRejectsRedundantSlotQuery(t *testing.T) {
|
||||
st := &ScheduleRefineState{
|
||||
SeenSlotQueries: make(map[string]struct{}),
|
||||
|
||||
@@ -167,7 +167,12 @@ type ScheduleRefineState struct {
|
||||
DisableCompositeTools bool
|
||||
// CompositeRouteTried 标记是否尝试过“复合批处理路由”。
|
||||
CompositeRouteTried bool
|
||||
// CompositeRouteSucceeded 标记复合批处理路由是否成功收口。
|
||||
// CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check;
|
||||
// 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准;
|
||||
// 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。
|
||||
CompositeRouteSucceeded bool
|
||||
TaskActionUsed map[int]int
|
||||
EntriesVersion int
|
||||
@@ -357,3 +362,16 @@ func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
|
||||
}
|
||||
return orderMap
|
||||
}
|
||||
|
||||
// FinalHardCheckPassed 判断“最终终审”是否整体通过。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用;
|
||||
// 2. 不负责触发终审,也不负责推导修复动作;
|
||||
// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。
|
||||
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
|
||||
if st == nil {
|
||||
return false
|
||||
}
|
||||
return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
|
||||
}
|
||||
|
||||
@@ -422,11 +422,38 @@ func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string
|
||||
// refineToolMinContextSwitch 执行“最少上下文切换”复合动作。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责参数解析、候选收集、调用确定性规划器;
|
||||
// 2. 不直接改写 entries,统一通过 BatchMove 原子落地;
|
||||
// 3. 规划算法实现位于 logic 包,工具层只负责编排。
|
||||
// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移;
|
||||
// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射;
|
||||
// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。
|
||||
func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
return refineToolCompositeMove(entries, params, window, policy, "MinContextSwitch", logic.PlanMinContextSwitchMoves)
|
||||
taskIDs := collectCompositeTaskIDs(params)
|
||||
if len(taskIDs) == 0 {
|
||||
return entries, reactToolResult{
|
||||
Tool: "MinContextSwitch",
|
||||
Success: false,
|
||||
ErrorCode: "PARAM_MISSING",
|
||||
Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id",
|
||||
}
|
||||
}
|
||||
tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch")
|
||||
if !ok {
|
||||
return entries, taskResult
|
||||
}
|
||||
|
||||
// 1. MinContextSwitch 的产品语义是“尽量少切换,同时尽量少折腾坑位”;
|
||||
// 2. 因此这里不再查询整周新坑位,而是直接复用当前任务已占据的坑位集合;
|
||||
// 3. 这样最终只会发生“任务之间互换位置”,不会跳到用户意料之外的远处时段。
|
||||
currentSlots := buildCompositeCurrentTaskSlots(tasks)
|
||||
plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{})
|
||||
if planErr != nil {
|
||||
return entries, reactToolResult{
|
||||
Tool: "MinContextSwitch",
|
||||
Success: false,
|
||||
ErrorCode: "PLAN_FAILED",
|
||||
Result: planErr.Error(),
|
||||
}
|
||||
}
|
||||
return applyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves)
|
||||
}
|
||||
|
||||
// refineToolCompositeMove 是复合动作工具的统一执行框架。
|
||||
@@ -453,56 +480,12 @@ func refineToolCompositeMove(
|
||||
Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id",
|
||||
}
|
||||
}
|
||||
tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName)
|
||||
if !ok {
|
||||
return entries, taskResult
|
||||
}
|
||||
idSet := intSliceToIDSet(taskIDs)
|
||||
|
||||
// 1. 先筛选任务候选,并校验 task_item_id 是否全部可定位。
|
||||
// 2. 只允许可移动 suggested 任务参与,避免误改 existing/course 条目。
|
||||
tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs))
|
||||
found := make(map[int]struct{}, len(taskIDs))
|
||||
spanNeed := make(map[int]int)
|
||||
for _, entry := range entries {
|
||||
if !isMovableSuggestedTask(entry) {
|
||||
continue
|
||||
}
|
||||
if _, ok := idSet[entry.TaskItemID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, duplicated := found[entry.TaskItemID]; duplicated {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_ID_AMBIGUOUS",
|
||||
Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID),
|
||||
}
|
||||
}
|
||||
found[entry.TaskItemID] = struct{}{}
|
||||
task := logic.RefineTaskCandidate{
|
||||
TaskItemID: entry.TaskItemID,
|
||||
Week: entry.Week,
|
||||
DayOfWeek: entry.DayOfWeek,
|
||||
SectionFrom: entry.SectionFrom,
|
||||
SectionTo: entry.SectionTo,
|
||||
Name: strings.TrimSpace(entry.Name),
|
||||
ContextTag: strings.TrimSpace(entry.ContextTag),
|
||||
OriginRank: policy.OriginOrderMap[entry.TaskItemID],
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
spanNeed[entry.SectionTo-entry.SectionFrom+1]++
|
||||
}
|
||||
if len(tasks) != len(taskIDs) {
|
||||
missing := make([]int, 0, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
if _, ok := found[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_NOT_FOUND",
|
||||
Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing),
|
||||
}
|
||||
}
|
||||
spanNeed := buildCompositeSpanNeed(tasks)
|
||||
|
||||
slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed)
|
||||
if slotErr != nil {
|
||||
@@ -525,6 +508,92 @@ func refineToolCompositeMove(
|
||||
Result: planErr.Error(),
|
||||
}
|
||||
}
|
||||
return applyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves)
|
||||
}
|
||||
|
||||
// collectCompositeTasks 收集复合动作参与的可移动任务,并做唯一性校验。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 只收 suggested 且可移动的 task,避免误改 existing/course;
|
||||
// 2. task_item_id 必须一一命中,命中多条或缺失都直接失败;
|
||||
// 3. 输出顺序保持 entries 原始遍历顺序,后续再由规划器做稳定排序。
|
||||
func collectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy refineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, reactToolResult, bool) {
|
||||
idSet := intSliceToIDSet(taskIDs)
|
||||
tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs))
|
||||
found := make(map[int]struct{}, len(taskIDs))
|
||||
for _, entry := range entries {
|
||||
if !isMovableSuggestedTask(entry) {
|
||||
continue
|
||||
}
|
||||
if _, ok := idSet[entry.TaskItemID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, duplicated := found[entry.TaskItemID]; duplicated {
|
||||
return nil, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_ID_AMBIGUOUS",
|
||||
Result: fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务,无法唯一定位", entry.TaskItemID),
|
||||
}, false
|
||||
}
|
||||
found[entry.TaskItemID] = struct{}{}
|
||||
tasks = append(tasks, logic.RefineTaskCandidate{
|
||||
TaskItemID: entry.TaskItemID,
|
||||
Week: entry.Week,
|
||||
DayOfWeek: entry.DayOfWeek,
|
||||
SectionFrom: entry.SectionFrom,
|
||||
SectionTo: entry.SectionTo,
|
||||
Name: strings.TrimSpace(entry.Name),
|
||||
ContextTag: strings.TrimSpace(entry.ContextTag),
|
||||
OriginRank: policy.OriginOrderMap[entry.TaskItemID],
|
||||
})
|
||||
}
|
||||
if len(tasks) != len(taskIDs) {
|
||||
missing := make([]int, 0, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
if _, ok := found[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
return nil, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_NOT_FOUND",
|
||||
Result: fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务:%v", missing),
|
||||
}, false
|
||||
}
|
||||
return tasks, reactToolResult{}, true
|
||||
}
|
||||
|
||||
func buildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int {
|
||||
spanNeed := make(map[int]int, len(tasks))
|
||||
for _, task := range tasks {
|
||||
spanNeed[task.SectionTo-task.SectionFrom+1]++
|
||||
}
|
||||
return spanNeed
|
||||
}
|
||||
|
||||
func buildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate {
|
||||
slots := make([]logic.RefineSlotCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
slots = append(slots, logic.RefineSlotCandidate{
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func applyCompositePlannedMoves(
|
||||
entries []model.HybridScheduleEntry,
|
||||
params map[string]any,
|
||||
window planningWindow,
|
||||
policy refineToolPolicy,
|
||||
toolName string,
|
||||
plannedMoves []logic.RefineMovePlanItem,
|
||||
) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
if len(plannedMoves) == 0 {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
@@ -564,6 +633,89 @@ func refineToolCompositeMove(
|
||||
}
|
||||
}
|
||||
|
||||
// applyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 该函数专门服务“坑位集合固定”的复合工具,避免 BatchMove 顺序执行时出现互相占位冲突;
|
||||
// 2. 先在副本上一次性改写所有目标任务的坐标,再统一排序与校验;
|
||||
// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足,则整批失败并回滚。
|
||||
func applyFixedSlotCompositeMoves(
|
||||
entries []model.HybridScheduleEntry,
|
||||
policy refineToolPolicy,
|
||||
toolName string,
|
||||
plannedMoves []logic.RefineMovePlanItem,
|
||||
) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
if len(plannedMoves) == 0 {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "PLAN_EMPTY",
|
||||
Result: "规划结果为空:未生成任何可执行移动",
|
||||
}
|
||||
}
|
||||
|
||||
working := cloneHybridEntries(entries)
|
||||
indexByTaskID := make(map[int]int, len(working))
|
||||
for idx, entry := range working {
|
||||
if !isMovableSuggestedTask(entry) {
|
||||
continue
|
||||
}
|
||||
if _, exists := indexByTaskID[entry.TaskItemID]; exists {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_ID_AMBIGUOUS",
|
||||
Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID),
|
||||
}
|
||||
}
|
||||
indexByTaskID[entry.TaskItemID] = idx
|
||||
}
|
||||
|
||||
targetSeen := make(map[string]int, len(plannedMoves))
|
||||
for _, move := range plannedMoves {
|
||||
if _, ok := indexByTaskID[move.TaskItemID]; !ok {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "TASK_NOT_FOUND",
|
||||
Result: fmt.Sprintf("%s 执行失败:task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID),
|
||||
}
|
||||
}
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo)
|
||||
if prevID, exists := targetSeen[key]; exists {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "PLAN_CONFLICT",
|
||||
Result: fmt.Sprintf("%s 执行失败:任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID),
|
||||
}
|
||||
}
|
||||
targetSeen[key] = move.TaskItemID
|
||||
}
|
||||
|
||||
for _, move := range plannedMoves {
|
||||
idx := indexByTaskID[move.TaskItemID]
|
||||
working[idx].Week = move.ToWeek
|
||||
working[idx].DayOfWeek = move.ToDay
|
||||
working[idx].SectionFrom = move.ToSectionFrom
|
||||
working[idx].SectionTo = move.ToSectionTo
|
||||
}
|
||||
sortHybridEntries(working)
|
||||
if issues := validateRelativeOrder(working, policy); len(issues) > 0 {
|
||||
return entries, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: false,
|
||||
ErrorCode: "ORDER_CONSTRAINT_VIOLATED",
|
||||
Result: "顺序约束不满足:" + strings.Join(issues, ";"),
|
||||
}
|
||||
}
|
||||
return working, reactToolResult{
|
||||
Tool: toolName,
|
||||
Success: true,
|
||||
Result: fmt.Sprintf("%s 执行成功:已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)),
|
||||
}
|
||||
}
|
||||
|
||||
func collectCompositeTaskIDs(params map[string]any) []int {
|
||||
ids := readIntSlice(params, "task_item_ids", "task_ids")
|
||||
if id, ok := paramIntAny(params, "task_item_id", "task_id"); ok {
|
||||
@@ -645,9 +797,12 @@ func buildCompositeSlotQueryParams(params map[string]any, span int, required int
|
||||
}
|
||||
}
|
||||
|
||||
copyIntSliceParam(params, query, "week_filter", "weeks")
|
||||
copyIntSliceParam(params, query, "day_of_week", "days", "day_filter")
|
||||
copyIntSliceParam(params, query, "exclude_sections", "exclude_section")
|
||||
// 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections;
|
||||
// 2. 这里必须优先透传这些“规范键”,再兼容历史别名;
|
||||
// 3. 否则会出现复合工具已被调用,但内部查坑位时丢失目标范围,导致规划结果漂移。
|
||||
copyIntSliceParam(params, query, "week_filter", "week_filter", "weeks")
|
||||
copyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter")
|
||||
copyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section")
|
||||
|
||||
// 兼容 Move 风格别名,降低模型参数名漂移导致的失败。
|
||||
if week, ok := paramIntAny(params, "to_week", "target_week", "new_week"); ok {
|
||||
|
||||
Reference in New Issue
Block a user