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:
LoveLosita
2026-03-24 21:35:22 +08:00
parent e6941f98f2
commit f4ef6fb256
55 changed files with 5492 additions and 235 deletions

View File

@@ -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相关内容南山、红岩网校、卓越工程师班请表现出你的亲切感。`
)

View 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 成功状态被记录")
}
}

View File

@@ -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) {

View File

@@ -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=%sreason=%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=%treason=%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=%treason=%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轮当前排程已满足终审条件。"

View File

@@ -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{}),

View File

@@ -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
}

View File

@@ -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 {