Version: 0.8.0.dev.260326
后端: 将agent2中的schedule_refine史山代码融入了架构(等待review) 前端: 无
This commit is contained in:
@@ -136,5 +136,67 @@ func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineG
|
|||||||
if input.State == nil {
|
if input.State == nil {
|
||||||
return nil, errors.New("schedule refine graph: state is nil")
|
return nil, errors.New("schedule refine graph: state is nil")
|
||||||
}
|
}
|
||||||
return agentnode.RunScheduleRefineGraph(ctx, input)
|
|
||||||
|
nodes, err := agentnode.NewScheduleRefineNodes(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
graph := compose.NewGraph[*agentmodel.ScheduleRefineState, *agentmodel.ScheduleRefineState]()
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeContract, compose.InvokableLambda(nodes.Contract)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSlice, compose.InvokableLambda(nodes.Slice)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeRoute, compose.InvokableLambda(nodes.Route)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeReact, compose.InvokableLambda(nodes.React)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeHardCheck, compose.InvokableLambda(nodes.HardCheck)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSummary, compose.InvokableLambda(nodes.Summary)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = graph.AddEdge(compose.START, agentnode.ScheduleRefineGraphNodeContract); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeContract, agentnode.ScheduleRefineGraphNodePlan); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodePlan, agentnode.ScheduleRefineGraphNodeSlice); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSlice, agentnode.ScheduleRefineGraphNodeRoute); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeRoute, agentnode.ScheduleRefineGraphNodeReact); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeReact, agentnode.ScheduleRefineGraphNodeHardCheck); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeHardCheck, agentnode.ScheduleRefineGraphNodeSummary); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSummary, compose.END); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
runnable, err := graph.Compile(ctx,
|
||||||
|
compose.WithGraphName(ScheduleRefineGraphName),
|
||||||
|
compose.WithMaxRunSteps(20),
|
||||||
|
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runnable.Invoke(ctx, input.State)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
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,179 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRefineToolSpreadEvenSuccess(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 1, Name: "任务1", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "A"},
|
|
||||||
{TaskItemID: 2, Name: "任务2", Type: "task", Status: "suggested", Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "B"},
|
|
||||||
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, BlockForSuggested: true},
|
|
||||||
}
|
|
||||||
params := map[string]any{
|
|
||||||
"task_item_ids": []any{1.0, 2.0},
|
|
||||||
"week": 12,
|
|
||||||
"day_of_week": []any{1.0, 2.0, 3.0},
|
|
||||||
"allow_embed": false,
|
|
||||||
}
|
|
||||||
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2}}
|
|
||||||
|
|
||||||
nextEntries, result := refineToolSpreadEven(entries, params, planningWindow{Enabled: false}, policy)
|
|
||||||
if !result.Success {
|
|
||||||
t.Fatalf("SpreadEven 执行失败: %s", result.Result)
|
|
||||||
}
|
|
||||||
if result.Tool != "SpreadEven" {
|
|
||||||
t.Fatalf("工具名错误,期望 SpreadEven,实际=%s", result.Tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
idx1 := findSuggestedByID(nextEntries, 1)
|
|
||||||
idx2 := findSuggestedByID(nextEntries, 2)
|
|
||||||
if idx1 < 0 || idx2 < 0 {
|
|
||||||
t.Fatalf("移动后未找到目标任务: idx1=%d idx2=%d", idx1, idx2)
|
|
||||||
}
|
|
||||||
task1 := nextEntries[idx1]
|
|
||||||
task2 := nextEntries[idx2]
|
|
||||||
if task1.Week != 12 || task2.Week != 12 {
|
|
||||||
t.Fatalf("期望任务被移动到 W12,实际 task1=%d task2=%d", task1.Week, task2.Week)
|
|
||||||
}
|
|
||||||
if task1.DayOfWeek < 1 || task1.DayOfWeek > 3 || task2.DayOfWeek < 1 || task2.DayOfWeek > 3 {
|
|
||||||
t.Fatalf("期望任务被移动到周一到周三,实际 task1=%d task2=%d", task1.DayOfWeek, task2.DayOfWeek)
|
|
||||||
}
|
|
||||||
if task1.DayOfWeek == task2.DayOfWeek && sectionsOverlap(task1.SectionFrom, task1.SectionTo, task2.SectionFrom, task2.SectionTo) {
|
|
||||||
t.Fatalf("复合工具不应产出重叠坑位: task1=%+v task2=%+v", task1, task2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefineToolMinContextSwitchGroupsContext(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: "数学"},
|
|
||||||
{TaskItemID: 99, Name: "课程", Type: "course", Status: "existing", Week: 12, DayOfWeek: 1, SectionFrom: 11, SectionTo: 12, BlockForSuggested: true},
|
|
||||||
}
|
|
||||||
params := map[string]any{
|
|
||||||
"task_item_ids": []any{11.0, 12.0, 13.0},
|
|
||||||
"week": 12,
|
|
||||||
"day_of_week": []any{1.0},
|
|
||||||
}
|
|
||||||
policy := refineToolPolicy{OriginOrderMap: map[int]int{11: 1, 12: 2, 13: 3}}
|
|
||||||
|
|
||||||
nextEntries, result := refineToolMinContextSwitch(entries, params, planningWindow{Enabled: false}, policy)
|
|
||||||
if !result.Success {
|
|
||||||
t.Fatalf("MinContextSwitch 执行失败: %s", result.Result)
|
|
||||||
}
|
|
||||||
if result.Tool != "MinContextSwitch" {
|
|
||||||
t.Fatalf("工具名错误,期望 MinContextSwitch,实际=%s", result.Tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := make([]model.HybridScheduleEntry, 0, 3)
|
|
||||||
for _, id := range []int{11, 12, 13} {
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
switches := 0
|
|
||||||
for i := 1; i < len(selected); i++ {
|
|
||||||
if selected[i].ContextTag != selected[i-1].ContextTag {
|
|
||||||
switches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
call := reactToolCall{
|
|
||||||
Tool: "SpreadEven",
|
|
||||||
Params: map[string]any{
|
|
||||||
"task_item_ids": []any{1.0, 2.0, 2.0},
|
|
||||||
"task_item_id": 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ids := listTaskIDsFromToolCall(call)
|
|
||||||
if len(ids) != 3 {
|
|
||||||
t.Fatalf("期望提取 3 个去重 ID,实际=%v", ids)
|
|
||||||
}
|
|
||||||
sort.Ints(ids)
|
|
||||||
if ids[0] != 1 || ids[1] != 2 || ids[2] != 3 {
|
|
||||||
t.Fatalf("提取结果错误,实际=%v", ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
|
||||||
"github.com/cloudwego/eino/compose"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
graphNodeContract = "schedule_refine_contract"
|
|
||||||
graphNodePlan = "schedule_refine_plan"
|
|
||||||
graphNodeSlice = "schedule_refine_slice"
|
|
||||||
graphNodeRoute = "schedule_refine_route"
|
|
||||||
graphNodeReact = "schedule_refine_react"
|
|
||||||
graphNodeHardCheck = "schedule_refine_hard_check"
|
|
||||||
graphNodeSummary = "schedule_refine_summary"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ScheduleRefineGraphRunInput 是“连续微调图”运行参数。
|
|
||||||
//
|
|
||||||
// 字段语义:
|
|
||||||
// 1. Model:本轮图运行使用的聊天模型。
|
|
||||||
// 2. State:预先注入的微调状态(通常来自上一版预览快照)。
|
|
||||||
// 3. EmitStage:SSE 阶段回调,允许服务层把阶段进度透传给前端。
|
|
||||||
type ScheduleRefineGraphRunInput struct {
|
|
||||||
Model *ark.ChatModel
|
|
||||||
State *ScheduleRefineState
|
|
||||||
EmitStage func(stage, detail string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunScheduleRefineGraph 执行“连续微调”独立图链路。
|
|
||||||
//
|
|
||||||
// 链路顺序:
|
|
||||||
// START -> contract -> plan -> slice -> route -> react -> hard_check -> summary -> END
|
|
||||||
//
|
|
||||||
// 设计说明:
|
|
||||||
// 1. 当前链路采用线性图,确保可读性优先;
|
|
||||||
// 2. “终审失败后单次修复”在 hard_check 节点内部闭环处理,避免图连线分叉过多;
|
|
||||||
// 3. 若后续需要引入多分支策略(例如大改动转重排),可在 contract 后追加 branch 节点。
|
|
||||||
func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) {
|
|
||||||
if input.Model == nil {
|
|
||||||
return nil, fmt.Errorf("schedule refine graph: model is nil")
|
|
||||||
}
|
|
||||||
if input.State == nil {
|
|
||||||
return nil, fmt.Errorf("schedule refine graph: state is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
emitStage := func(stage, detail string) {
|
|
||||||
if input.EmitStage != nil {
|
|
||||||
input.EmitStage(stage, detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runner := newScheduleRefineRunner(input.Model, emitStage)
|
|
||||||
|
|
||||||
graph := compose.NewGraph[*ScheduleRefineState, *ScheduleRefineState]()
|
|
||||||
if err := graph.AddLambdaNode(graphNodeContract, compose.InvokableLambda(runner.contractNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodeSlice, compose.InvokableLambda(runner.sliceNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodeRoute, compose.InvokableLambda(runner.routeNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodeReact, compose.InvokableLambda(runner.reactNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodeHardCheck, compose.InvokableLambda(runner.hardCheckNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddLambdaNode(graphNodeSummary, compose.InvokableLambda(runner.summaryNode)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := graph.AddEdge(compose.START, graphNodeContract); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeContract, graphNodePlan); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodePlan, graphNodeSlice); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeSlice, graphNodeRoute); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeRoute, graphNodeReact); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeReact, graphNodeHardCheck); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeHardCheck, graphNodeSummary); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := graph.AddEdge(graphNodeSummary, compose.END); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
runnable, err := graph.Compile(ctx,
|
|
||||||
compose.WithGraphName("ScheduleRefineGraph"),
|
|
||||||
compose.WithMaxRunSteps(20),
|
|
||||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return runnable.Invoke(ctx, input.State)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
const (
|
|
||||||
// contractPrompt 负责把用户自然语言微调请求抽取为结构化契约。
|
|
||||||
contractPrompt = `你是 SmartFlow 的排程微调契约分析器。
|
|
||||||
你会收到:当前时间、用户请求、已有排程摘要。
|
|
||||||
请只输出 JSON,不要 Markdown,不要解释,不要代码块:
|
|
||||||
{
|
|
||||||
"intent": "一句话概括本轮微调目标",
|
|
||||||
"strategy": "local_adjust|keep",
|
|
||||||
"hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"],
|
|
||||||
"hard_assertions": [
|
|
||||||
{
|
|
||||||
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
|
|
||||||
"operator": "==|<=|>=|between",
|
|
||||||
"value": 50,
|
|
||||||
"min": 50,
|
|
||||||
"max": 50,
|
|
||||||
"week": 17,
|
|
||||||
"target_week": 16
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"keep_relative_order": true,
|
|
||||||
"order_scope": "global|week"
|
|
||||||
}
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 除非用户明确表达“允许打乱顺序/顺序无所谓”,keep_relative_order 默认 true。
|
|
||||||
2. 仅当用户明确放宽顺序时,keep_relative_order 才允许为 false;order_scope 默认 "global"。
|
|
||||||
3. 只要涉及移动任务,strategy 必须是 local_adjust;仅在无需改动时才用 keep。
|
|
||||||
4. hard_requirements 必须可验证,避免空泛描述。
|
|
||||||
5. hard_assertions 必须尽量结构化,避免只给自然语言目标。`
|
|
||||||
|
|
||||||
// plannerPrompt 只负责生成“执行路径”,不直接执行动作。
|
|
||||||
plannerPrompt = `你是 SmartFlow 的排程微调 Planner。
|
|
||||||
你会收到:用户请求、契约、最近动作观察。
|
|
||||||
请只输出 JSON,不要 Markdown,不要解释,不要代码块:
|
|
||||||
{
|
|
||||||
"summary": "本阶段执行策略一句话",
|
|
||||||
"steps": ["步骤1","步骤2","步骤3"]
|
|
||||||
}
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. steps 保持 3~4 条,优先“先取证再动作”。
|
|
||||||
2. summary <= 36 字,单步 <= 28 字。
|
|
||||||
3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。
|
|
||||||
4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。
|
|
||||||
5. 不要输出半截 JSON。`
|
|
||||||
|
|
||||||
// reactPrompt 用于“单任务微步 ReAct”执行器。
|
|
||||||
reactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。
|
|
||||||
当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。
|
|
||||||
你每轮只能做两件事之一:
|
|
||||||
1) 调用一个工具(基础工具或复合工具)
|
|
||||||
2) 输出 done=true 结束当前任务
|
|
||||||
|
|
||||||
工具分组:
|
|
||||||
- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify
|
|
||||||
- 复合工具:SpreadEven / MinContextSwitch
|
|
||||||
|
|
||||||
工具说明(按职责):
|
|
||||||
1. QueryTargetTasks:查询候选任务集合(只读)。
|
|
||||||
常用参数:week/week_filter/day_of_week/task_item_ids/status。
|
|
||||||
适用:先摸清“有哪些任务可动、当前在哪”。
|
|
||||||
2. QueryAvailableSlots:查询可放置坑位(只读,默认先纯空位,必要时补可嵌入位)。
|
|
||||||
常用参数:week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。
|
|
||||||
适用:Move 前先拿可落点清单。
|
|
||||||
3. Move:移动单个任务到目标坑位(写操作)。
|
|
||||||
必要参数:task_item_id,to_week,to_day,to_section_from,to_section_to。
|
|
||||||
适用:单任务精确挪动。
|
|
||||||
4. Swap:交换两个任务坑位(写操作)。
|
|
||||||
必要参数:task_a,task_b。
|
|
||||||
适用:两个任务互换位置比单独 Move 更稳时。
|
|
||||||
5. BatchMove:批量原子移动(写操作)。
|
|
||||||
必要参数:{"moves":[{Move参数...},{Move参数...}]}。
|
|
||||||
适用:一轮要改多个任务且要求“要么全成要么全回滚”。
|
|
||||||
6. Verify:执行确定性校验(只读)。
|
|
||||||
常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。
|
|
||||||
适用:收尾前快速自检是否符合确定性约束。
|
|
||||||
7. SpreadEven(复合):按“均匀铺开”目标一次规划并执行多任务移动(写操作)。
|
|
||||||
必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。
|
|
||||||
可选参数:week/week_filter/day_of_week/allow_embed/limit。
|
|
||||||
适用:目标是“把任务在时间上分散开,避免扎堆”。
|
|
||||||
8. MinContextSwitch(复合):按“最少上下文切换”一次规划并执行多任务移动(写操作)。
|
|
||||||
必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。
|
|
||||||
可选参数:week/week_filter/day_of_week/allow_embed/limit。
|
|
||||||
适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。
|
|
||||||
|
|
||||||
请严格输出 JSON,不要 Markdown,不要解释:
|
|
||||||
{
|
|
||||||
"done": false,
|
|
||||||
"summary": "",
|
|
||||||
"goal_check": "本轮先检查什么",
|
|
||||||
"decision": "本轮为何这么做",
|
|
||||||
"missing_info": ["缺口信息1","缺口信息2"],
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
|
|
||||||
"params": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
硬规则:
|
|
||||||
1. 每轮最多 1 个 tool_call。
|
|
||||||
2. done=true 时,tool_calls 必须为空数组。
|
|
||||||
3. done=false 时,tool_calls 必须恰好 1 条。
|
|
||||||
4. 只能修改 status="suggested" 的任务,禁止修改 existing。
|
|
||||||
5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。
|
|
||||||
6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。
|
|
||||||
7. Move 参数优先使用:task_item_id,to_week,to_day,to_section_from,to_section_to。
|
|
||||||
8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。
|
|
||||||
9. day_of_week 映射固定:1周一,2周二,3周三,4周四,5周五,6周六,7周日。
|
|
||||||
10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。
|
|
||||||
11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。
|
|
||||||
12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true,不要提前处理下一个任务。
|
|
||||||
13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime),只能用白名单工具。
|
|
||||||
14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。
|
|
||||||
15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false,本轮必须优先调用 REQUIRED_COMPOSITE_TOOL,禁止先调用 Move/Swap/BatchMove。
|
|
||||||
16. 若使用 SpreadEven/MinContextSwitch,必须在参数中提供 task_item_ids(且包含 CURRENT_TASK.task_item_id)。
|
|
||||||
17. 若 COMPOSITE_TOOLS_ALLOWED=false,禁止调用 SpreadEven/MinContextSwitch,只能使用基础工具逐步处理。
|
|
||||||
18. 为保证解析稳定:goal_check<=50字,decision<=90字,summary<=60字。`
|
|
||||||
|
|
||||||
// postReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。
|
|
||||||
postReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。
|
|
||||||
你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。
|
|
||||||
请只输出 JSON,不要 Markdown,不要解释:
|
|
||||||
{
|
|
||||||
"reflection": "基于真实结果的复盘",
|
|
||||||
"next_strategy": "下一轮建议动作",
|
|
||||||
"should_stop": false
|
|
||||||
}
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。
|
|
||||||
2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。
|
|
||||||
3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。`
|
|
||||||
|
|
||||||
// reviewPrompt 用于终审语义校验。
|
|
||||||
reviewPrompt = `你是 SmartFlow 的终审校验器。
|
|
||||||
请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。
|
|
||||||
只输出 JSON:
|
|
||||||
{
|
|
||||||
"pass": true,
|
|
||||||
"reason": "中文简短结论",
|
|
||||||
"unmet": []
|
|
||||||
}
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. pass=true 时 unmet 必须为空数组。
|
|
||||||
2. pass=false 时 reason 必须给出核心差距。`
|
|
||||||
|
|
||||||
// summaryPrompt 用于最终面向用户的自然语言总结。
|
|
||||||
summaryPrompt = `你是 SmartFlow 的排程结果解读助手。
|
|
||||||
请基于输入输出 2~4 句中文总结:
|
|
||||||
1) 先说明本轮改了什么;
|
|
||||||
2) 再说明改动收益;
|
|
||||||
3) 若终审未完全通过,明确还差什么。
|
|
||||||
不要输出 JSON。`
|
|
||||||
|
|
||||||
// repairPrompt 用于终审失败后的单次修复动作。
|
|
||||||
repairPrompt = `你是 SmartFlow 的修复执行器。
|
|
||||||
当前方案未通过终审,请根据“未满足点”只做一次修复动作。
|
|
||||||
只允许输出一个 tool_call(Move 或 Swap),不允许 done。
|
|
||||||
|
|
||||||
输出格式(严格 JSON):
|
|
||||||
{
|
|
||||||
"done": false,
|
|
||||||
"summary": "",
|
|
||||||
"goal_check": "本轮修复目标",
|
|
||||||
"decision": "修复决策依据",
|
|
||||||
"missing_info": [],
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"tool": "Move|Swap",
|
|
||||||
"params": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Move 参数必须使用标准键:
|
|
||||||
- task_item_id
|
|
||||||
- to_week
|
|
||||||
- to_day
|
|
||||||
- to_section_from
|
|
||||||
- to_section_to
|
|
||||||
禁止使用 new_week/new_day/section_from 等别名。`
|
|
||||||
)
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestQueryTargetTasksWeekFilterAndTaskID(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 2, Name: "task-w13", Week: 13, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 3, Name: "task-w14", Week: 14, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
|
|
||||||
}
|
|
||||||
policy := refineToolPolicy{OriginOrderMap: map[int]int{1: 1, 2: 2, 3: 3}}
|
|
||||||
|
|
||||||
paramsWeek := map[string]any{
|
|
||||||
"week_filter": []any{13.0, 14.0},
|
|
||||||
}
|
|
||||||
_, resultWeek := refineToolQueryTargetTasks(entries, paramsWeek, policy)
|
|
||||||
if !resultWeek.Success {
|
|
||||||
t.Fatalf("week_filter 查询失败: %s", resultWeek.Result)
|
|
||||||
}
|
|
||||||
var payloadWeek struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Items []struct {
|
|
||||||
TaskItemID int `json:"task_item_id"`
|
|
||||||
Week int `json:"week"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(resultWeek.Result), &payloadWeek); err != nil {
|
|
||||||
t.Fatalf("解析 week_filter 结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if payloadWeek.Count != 2 {
|
|
||||||
t.Fatalf("week_filter 期望返回 2 条,实际=%d", payloadWeek.Count)
|
|
||||||
}
|
|
||||||
for _, item := range payloadWeek.Items {
|
|
||||||
if item.Week != 13 && item.Week != 14 {
|
|
||||||
t.Fatalf("week_filter 过滤失败,出现非法周次=%d", item.Week)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
paramsTaskID := map[string]any{
|
|
||||||
"week_filter": []any{13.0, 14.0},
|
|
||||||
"task_item_id": 2,
|
|
||||||
}
|
|
||||||
_, resultTaskID := refineToolQueryTargetTasks(entries, paramsTaskID, policy)
|
|
||||||
if !resultTaskID.Success {
|
|
||||||
t.Fatalf("task_item_id 查询失败: %s", resultTaskID.Result)
|
|
||||||
}
|
|
||||||
var payloadTaskID struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Items []struct {
|
|
||||||
TaskItemID int `json:"task_item_id"`
|
|
||||||
Week int `json:"week"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(resultTaskID.Result), &payloadTaskID); err != nil {
|
|
||||||
t.Fatalf("解析 task_item_id 结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if payloadTaskID.Count != 1 {
|
|
||||||
t.Fatalf("task_item_id 期望返回 1 条,实际=%d", payloadTaskID.Count)
|
|
||||||
}
|
|
||||||
if payloadTaskID.Items[0].TaskItemID != 2 || payloadTaskID.Items[0].Week != 13 {
|
|
||||||
t.Fatalf("task_item_id 过滤错误: %+v", payloadTaskID.Items[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryAvailableSlotsExactSectionAlias(t *testing.T) {
|
|
||||||
params := map[string]any{
|
|
||||||
"week": 13,
|
|
||||||
"section_duration": 2,
|
|
||||||
"section_from": 1,
|
|
||||||
"section_to": 2,
|
|
||||||
"limit": 5,
|
|
||||||
}
|
|
||||||
_, result := refineToolQueryAvailableSlots(nil, params, planningWindow{Enabled: false})
|
|
||||||
if !result.Success {
|
|
||||||
t.Fatalf("QueryAvailableSlots 失败: %s", result.Result)
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Slots []struct {
|
|
||||||
Week int `json:"week"`
|
|
||||||
SectionFrom int `json:"section_from"`
|
|
||||||
SectionTo int `json:"section_to"`
|
|
||||||
} `json:"slots"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
|
|
||||||
t.Fatalf("解析 QueryAvailableSlots 结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if payload.Count == 0 {
|
|
||||||
t.Fatalf("期望至少返回一个可用时段,实际=0")
|
|
||||||
}
|
|
||||||
for _, slot := range payload.Slots {
|
|
||||||
if slot.Week != 13 {
|
|
||||||
t.Fatalf("返回了错误周次: %+v", slot)
|
|
||||||
}
|
|
||||||
if slot.SectionFrom != 1 || slot.SectionTo != 2 {
|
|
||||||
t.Fatalf("精确节次过滤失败: %+v", slot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryAvailableSlotsWeekFilterDayFilterAlias(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 1, Name: "task-w12", Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 2, Name: "task-w17", Week: 17, DayOfWeek: 4, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
|
|
||||||
}
|
|
||||||
params := map[string]any{
|
|
||||||
"week_filter": []any{17.0},
|
|
||||||
"day_filter": []any{1.0, 2.0, 3.0},
|
|
||||||
"limit": 20,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, result := refineToolQueryAvailableSlots(entries, params, planningWindow{Enabled: false})
|
|
||||||
if !result.Success {
|
|
||||||
t.Fatalf("QueryAvailableSlots 别名查询失败: %s", result.Result)
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Slots []struct {
|
|
||||||
Week int `json:"week"`
|
|
||||||
DayOfWeek int `json:"day_of_week"`
|
|
||||||
} `json:"slots"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(result.Result), &payload); err != nil {
|
|
||||||
t.Fatalf("解析 week/day 过滤结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if payload.Count == 0 {
|
|
||||||
t.Fatalf("week_filter/day_filter 查询应返回 W17 周一到周三空位,实际为空")
|
|
||||||
}
|
|
||||||
for _, slot := range payload.Slots {
|
|
||||||
if slot.Week != 17 {
|
|
||||||
t.Fatalf("week_filter 失效,出现 week=%d", slot.Week)
|
|
||||||
}
|
|
||||||
if slot.DayOfWeek < 1 || slot.DayOfWeek > 3 {
|
|
||||||
t.Fatalf("day_filter 失效,出现 day_of_week=%d", slot.DayOfWeek)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectWorksetTaskIDsSourceWeekOnly(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 1, Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 2, Week: 14, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 3, Week: 13, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
|
|
||||||
{TaskItemID: 4, Week: 14, DayOfWeek: 2, SectionFrom: 7, SectionTo: 8, Status: "suggested", Type: "task"},
|
|
||||||
}
|
|
||||||
slice := RefineSlicePlan{WeekFilter: []int{14, 13}}
|
|
||||||
originOrder := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
|
|
||||||
|
|
||||||
got := collectWorksetTaskIDs(entries, slice, originOrder)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("来源周收敛失败,期望 2 条,实际=%d, got=%v", len(got), got)
|
|
||||||
}
|
|
||||||
if got[0] != 2 || got[1] != 4 {
|
|
||||||
t.Fatalf("来源周结果错误,期望 [2 4],实际=%v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSlicePlanDirectionalSourceTarget(t *testing.T) {
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "帮我把第17周周四到周五的任务都收敛到17周的周一到周三,优先放空位,空位不够了再嵌入",
|
|
||||||
}
|
|
||||||
plan := buildSlicePlan(st)
|
|
||||||
if len(plan.WeekFilter) == 0 || plan.WeekFilter[0] != 17 {
|
|
||||||
t.Fatalf("week_filter 解析错误: %+v", plan.WeekFilter)
|
|
||||||
}
|
|
||||||
expectSource := []int{4, 5}
|
|
||||||
expectTarget := []int{1, 2, 3}
|
|
||||||
if len(plan.SourceDays) != len(expectSource) {
|
|
||||||
t.Fatalf("source_days 长度错误: got=%v", plan.SourceDays)
|
|
||||||
}
|
|
||||||
for i := range expectSource {
|
|
||||||
if plan.SourceDays[i] != expectSource[i] {
|
|
||||||
t.Fatalf("source_days 错误: got=%v", plan.SourceDays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(plan.TargetDays) != len(expectTarget) {
|
|
||||||
t.Fatalf("target_days 长度错误: got=%v", plan.TargetDays)
|
|
||||||
}
|
|
||||||
for i := range expectTarget {
|
|
||||||
if plan.TargetDays[i] != expectTarget[i] {
|
|
||||||
t.Fatalf("target_days 错误: got=%v", plan.TargetDays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyTaskCoordinateMismatch(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 28, Name: "task-w17-d4", Week: 17, DayOfWeek: 4, SectionFrom: 5, SectionTo: 6, Status: "suggested", Type: "task"},
|
|
||||||
}
|
|
||||||
policy := refineToolPolicy{OriginOrderMap: map[int]int{28: 1}}
|
|
||||||
params := map[string]any{
|
|
||||||
"task_item_id": 28,
|
|
||||||
"week": 17,
|
|
||||||
"day_of_week": 1,
|
|
||||||
"section_from": 1,
|
|
||||||
"section_to": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, result := refineToolVerify(entries, params, policy)
|
|
||||||
if result.Success {
|
|
||||||
t.Fatalf("期望 Verify 在任务坐标不匹配时失败,实际 success=true, result=%s", result.Result)
|
|
||||||
}
|
|
||||||
if result.ErrorCode != "VERIFY_FAILED" {
|
|
||||||
t.Fatalf("期望错误码 VERIFY_FAILED,实际=%s", result.ErrorCode)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.Result, "不匹配") {
|
|
||||||
t.Fatalf("期望结果包含“不匹配”提示,实际=%s", result.Result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveRejectsSuggestedCourseEntry(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{
|
|
||||||
TaskItemID: 39,
|
|
||||||
Name: "面向对象程序设计-C++",
|
|
||||||
Type: "course",
|
|
||||||
Status: "suggested",
|
|
||||||
Week: 17,
|
|
||||||
DayOfWeek: 4,
|
|
||||||
SectionFrom: 7,
|
|
||||||
SectionTo: 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
params := map[string]any{
|
|
||||||
"task_item_id": 39,
|
|
||||||
"to_week": 17,
|
|
||||||
"to_day": 1,
|
|
||||||
"to_section_from": 7,
|
|
||||||
"to_section_to": 8,
|
|
||||||
}
|
|
||||||
_, result := refineToolMove(entries, params, planningWindow{Enabled: false}, refineToolPolicy{OriginOrderMap: map[int]int{39: 1}})
|
|
||||||
if result.Success {
|
|
||||||
t.Fatalf("期望 course 类型的 suggested 条目不可移动,实际 success=true, result=%s", result.Result)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.Result, "可移动 suggested 任务") {
|
|
||||||
t.Fatalf("期望返回不可移动提示,实际=%s", result.Result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryAvailableSlotsSlotTypePureDisablesEmbed(t *testing.T) {
|
|
||||||
entries := []model.HybridScheduleEntry{
|
|
||||||
{
|
|
||||||
Name: "可嵌入课程",
|
|
||||||
Type: "course",
|
|
||||||
Status: "existing",
|
|
||||||
Week: 17,
|
|
||||||
DayOfWeek: 1,
|
|
||||||
SectionFrom: 1,
|
|
||||||
SectionTo: 2,
|
|
||||||
BlockForSuggested: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pureParams := map[string]any{
|
|
||||||
"week": 17,
|
|
||||||
"day_of_week": 1,
|
|
||||||
"section_from": 1,
|
|
||||||
"section_to": 2,
|
|
||||||
"slot_type": "pure",
|
|
||||||
}
|
|
||||||
_, pureResult := refineToolQueryAvailableSlots(entries, pureParams, planningWindow{Enabled: false})
|
|
||||||
if !pureResult.Success {
|
|
||||||
t.Fatalf("pure 查询失败: %s", pureResult.Result)
|
|
||||||
}
|
|
||||||
var purePayload struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
EmbeddedCount int `json:"embedded_count"`
|
|
||||||
FallbackUsed bool `json:"fallback_used"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(pureResult.Result), &purePayload); err != nil {
|
|
||||||
t.Fatalf("解析 pure 查询结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if purePayload.Count != 0 || purePayload.EmbeddedCount != 0 || purePayload.FallbackUsed {
|
|
||||||
t.Fatalf("slot_type=pure 应禁用嵌入兜底,实际 payload=%+v", purePayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultParams := map[string]any{
|
|
||||||
"week": 17,
|
|
||||||
"day_of_week": 1,
|
|
||||||
"section_from": 1,
|
|
||||||
"section_to": 2,
|
|
||||||
}
|
|
||||||
_, defaultResult := refineToolQueryAvailableSlots(entries, defaultParams, planningWindow{Enabled: false})
|
|
||||||
if !defaultResult.Success {
|
|
||||||
t.Fatalf("default 查询失败: %s", defaultResult.Result)
|
|
||||||
}
|
|
||||||
var defaultPayload struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
EmbeddedCount int `json:"embedded_count"`
|
|
||||||
FallbackUsed bool `json:"fallback_used"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(defaultResult.Result), &defaultPayload); err != nil {
|
|
||||||
t.Fatalf("解析 default 查询结果失败: %v", err)
|
|
||||||
}
|
|
||||||
if defaultPayload.Count == 0 || defaultPayload.EmbeddedCount == 0 || !defaultPayload.FallbackUsed {
|
|
||||||
t.Fatalf("默认查询应允许嵌入候选,实际 payload=%+v", defaultPayload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileObjectiveAndEvaluateMoveAllPass(t *testing.T) {
|
|
||||||
initial := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 4, SectionFrom: 7, SectionTo: 8},
|
|
||||||
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 9, SectionTo: 10},
|
|
||||||
}
|
|
||||||
final := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 39, Name: "任务39", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 1, SectionFrom: 7, SectionTo: 8},
|
|
||||||
{TaskItemID: 51, Name: "任务51", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 2, SectionFrom: 9, SectionTo: 10},
|
|
||||||
}
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "把17周周四到周五任务收敛到周一到周三",
|
|
||||||
InitialHybridEntries: initial,
|
|
||||||
HybridEntries: final,
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
WeekFilter: []int{17},
|
|
||||||
SourceDays: []int{4, 5},
|
|
||||||
TargetDays: []int{1, 2, 3},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
st.Objective = compileRefineObjective(st, st.SlicePlan)
|
|
||||||
if st.Objective.Mode != "move_all" {
|
|
||||||
t.Fatalf("期望目标模式 move_all,实际=%s", st.Objective.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
|
|
||||||
if !applied {
|
|
||||||
t.Fatalf("期望命中确定性终审")
|
|
||||||
}
|
|
||||||
if !pass {
|
|
||||||
t.Fatalf("期望确定性终审通过,unmet=%v", unmet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileObjectiveAndEvaluateMoveAllFail(t *testing.T) {
|
|
||||||
initial := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
|
|
||||||
}
|
|
||||||
final := []model.HybridScheduleEntry{
|
|
||||||
{TaskItemID: 26, Name: "任务26", Type: "task", Status: "suggested", Week: 17, DayOfWeek: 5, SectionFrom: 7, SectionTo: 8},
|
|
||||||
}
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "把17周周四到周五任务收敛到周一到周三",
|
|
||||||
InitialHybridEntries: initial,
|
|
||||||
HybridEntries: final,
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
WeekFilter: []int{17},
|
|
||||||
SourceDays: []int{4, 5},
|
|
||||||
TargetDays: []int{1, 2, 3},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
st.Objective = compileRefineObjective(st, st.SlicePlan)
|
|
||||||
|
|
||||||
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
|
|
||||||
if !applied {
|
|
||||||
t.Fatalf("期望命中确定性终审")
|
|
||||||
}
|
|
||||||
if pass {
|
|
||||||
t.Fatalf("期望确定性终审失败")
|
|
||||||
}
|
|
||||||
if len(unmet) == 0 {
|
|
||||||
t.Fatalf("期望返回未满足项")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileObjectiveMoveRatioFromContractAndEvaluatePass(t *testing.T) {
|
|
||||||
initial, final := buildHalfTransferEntries(10, 5)
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "17周任务太多,帮我调整到16周",
|
|
||||||
InitialHybridEntries: initial,
|
|
||||||
HybridEntries: final,
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
WeekFilter: []int{17, 16},
|
|
||||||
},
|
|
||||||
Contract: RefineContract{
|
|
||||||
Intent: "将第17周任务匀一半到第16周",
|
|
||||||
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
st.Objective = compileRefineObjective(st, st.SlicePlan)
|
|
||||||
if st.Objective.Mode != "move_ratio" {
|
|
||||||
t.Fatalf("期望目标模式 move_ratio,实际=%s", st.Objective.Mode)
|
|
||||||
}
|
|
||||||
if st.Objective.RequiredMoveMin != 5 || st.Objective.RequiredMoveMax != 5 {
|
|
||||||
t.Fatalf("半数迁移阈值错误: min=%d max=%d", st.Objective.RequiredMoveMin, st.Objective.RequiredMoveMax)
|
|
||||||
}
|
|
||||||
|
|
||||||
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
|
|
||||||
if !applied {
|
|
||||||
t.Fatalf("期望命中确定性终审")
|
|
||||||
}
|
|
||||||
if !pass {
|
|
||||||
t.Fatalf("期望半数迁移通过,unmet=%v", unmet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileObjectiveMoveRatioFromContractAndEvaluateFail(t *testing.T) {
|
|
||||||
initial, final := buildHalfTransferEntries(10, 4)
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "17周任务太多,帮我调整到16周",
|
|
||||||
InitialHybridEntries: initial,
|
|
||||||
HybridEntries: final,
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
WeekFilter: []int{17, 16},
|
|
||||||
},
|
|
||||||
Contract: RefineContract{
|
|
||||||
Intent: "将第17周任务匀一半到第16周",
|
|
||||||
HardRequirements: []string{"原第17周任务数调整为原来的一半", "调整到第16周的任务数为原第17周任务数的一半"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
st.Objective = compileRefineObjective(st, st.SlicePlan)
|
|
||||||
|
|
||||||
pass, _, unmet, applied := evaluateObjectiveDeterministic(st)
|
|
||||||
if !applied {
|
|
||||||
t.Fatalf("期望命中确定性终审")
|
|
||||||
}
|
|
||||||
if pass {
|
|
||||||
t.Fatalf("期望半数迁移失败")
|
|
||||||
}
|
|
||||||
if len(unmet) == 0 {
|
|
||||||
t.Fatalf("期望返回未满足项")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileObjectiveMoveRatioFromStructuredAssertion(t *testing.T) {
|
|
||||||
initial, final := buildHalfTransferEntries(10, 5)
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
UserMessage: "请把任务重新分配",
|
|
||||||
InitialHybridEntries: initial,
|
|
||||||
HybridEntries: final,
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
WeekFilter: []int{17, 16},
|
|
||||||
},
|
|
||||||
Contract: RefineContract{
|
|
||||||
Intent: "任务重新分配",
|
|
||||||
HardAssertions: []RefineAssertion{
|
|
||||||
{
|
|
||||||
Metric: "source_move_ratio_percent",
|
|
||||||
Operator: "==",
|
|
||||||
Value: 50,
|
|
||||||
Week: 17,
|
|
||||||
TargetWeek: 16,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
st.Objective = compileRefineObjective(st, st.SlicePlan)
|
|
||||||
if st.Objective.Mode != "move_ratio" {
|
|
||||||
t.Fatalf("结构化断言未生效,期望 move_ratio,实际=%s", st.Objective.Mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHalfTransferEntries(total int, moved int) ([]model.HybridScheduleEntry, []model.HybridScheduleEntry) {
|
|
||||||
initial := make([]model.HybridScheduleEntry, 0, total)
|
|
||||||
final := make([]model.HybridScheduleEntry, 0, total)
|
|
||||||
for i := 1; i <= total; i++ {
|
|
||||||
initial = append(initial, model.HybridScheduleEntry{
|
|
||||||
TaskItemID: i,
|
|
||||||
Name: "task",
|
|
||||||
Type: "task",
|
|
||||||
Status: "suggested",
|
|
||||||
Week: 17,
|
|
||||||
DayOfWeek: 1,
|
|
||||||
SectionFrom: 1,
|
|
||||||
SectionTo: 2,
|
|
||||||
})
|
|
||||||
week := 17
|
|
||||||
if i <= moved {
|
|
||||||
week = 16
|
|
||||||
}
|
|
||||||
final = append(final, model.HybridScheduleEntry{
|
|
||||||
TaskItemID: i,
|
|
||||||
Name: "task",
|
|
||||||
Type: "task",
|
|
||||||
Status: "suggested",
|
|
||||||
Week: week,
|
|
||||||
DayOfWeek: 1,
|
|
||||||
SectionFrom: 1,
|
|
||||||
SectionTo: 2,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return initial, final
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeMovableTaskOrderByOrigin(t *testing.T) {
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
OriginOrderMap: map[int]int{
|
|
||||||
101: 1,
|
|
||||||
202: 2,
|
|
||||||
},
|
|
||||||
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 := normalizeMovableTaskOrderByOrigin(st)
|
|
||||||
if !changed {
|
|
||||||
t.Fatalf("期望发生顺序归位")
|
|
||||||
}
|
|
||||||
sortHybridEntries(st.HybridEntries)
|
|
||||||
if st.HybridEntries[0].TaskItemID != 101 || st.HybridEntries[1].TaskItemID != 202 {
|
|
||||||
t.Fatalf("顺序归位失败: %+v", st.HybridEntries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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{}),
|
|
||||||
EntriesVersion: 0,
|
|
||||||
}
|
|
||||||
call := reactToolCall{
|
|
||||||
Tool: "QueryAvailableSlots",
|
|
||||||
Params: map[string]any{
|
|
||||||
"week": 16,
|
|
||||||
"day_of_week": 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
|
|
||||||
t.Fatalf("首次查询不应被拒绝: %+v", blockedResult)
|
|
||||||
}
|
|
||||||
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); !blocked {
|
|
||||||
t.Fatalf("重复查询应被拒绝")
|
|
||||||
} else if blockedResult.ErrorCode != "QUERY_REDUNDANT" {
|
|
||||||
t.Fatalf("错误码不符合预期: %+v", blockedResult)
|
|
||||||
}
|
|
||||||
st.EntriesVersion++
|
|
||||||
if blockedResult, blocked := precheckToolCallPolicy(st, call, nil); blocked {
|
|
||||||
t.Fatalf("排程版本变化后应允许再次查询: %+v", blockedResult)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanonicalizeMoveParamsFromRepairAliases(t *testing.T) {
|
|
||||||
call := reactToolCall{
|
|
||||||
Tool: "Move",
|
|
||||||
Params: map[string]any{
|
|
||||||
"task_item_id": 16,
|
|
||||||
"new_week": 16,
|
|
||||||
"day_of_week": 1,
|
|
||||||
"section_from": 1,
|
|
||||||
"section_to": 2,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
normalized := canonicalizeToolCall(call)
|
|
||||||
if _, ok := paramIntAny(normalized.Params, "to_week"); !ok {
|
|
||||||
t.Fatalf("to_week 规范化失败: %+v", normalized.Params)
|
|
||||||
}
|
|
||||||
if _, ok := paramIntAny(normalized.Params, "to_day"); !ok {
|
|
||||||
t.Fatalf("to_day 规范化失败: %+v", normalized.Params)
|
|
||||||
}
|
|
||||||
if _, ok := paramIntAny(normalized.Params, "to_section_from"); !ok {
|
|
||||||
t.Fatalf("to_section_from 规范化失败: %+v", normalized.Params)
|
|
||||||
}
|
|
||||||
if _, ok := paramIntAny(normalized.Params, "to_section_to"); !ok {
|
|
||||||
t.Fatalf("to_section_to 规范化失败: %+v", normalized.Params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectOrderIntentDefaultsToKeep(t *testing.T) {
|
|
||||||
if !detectOrderIntent("16周总体任务太多了,帮我移动一半到12周") {
|
|
||||||
t.Fatalf("未显式放宽顺序时,默认应保持顺序")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectOrderIntentExplicitAllowReorder(t *testing.T) {
|
|
||||||
if detectOrderIntent("这次顺序无所谓,可以打乱顺序") {
|
|
||||||
t.Fatalf("用户明确允许乱序时,应关闭顺序约束")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
|
||||||
)
|
|
||||||
|
|
||||||
// scheduleRefineRunner 是“单次图运行”的请求级依赖容器。
|
|
||||||
//
|
|
||||||
// 职责边界:
|
|
||||||
// 1. 负责收口模型与阶段回调,避免 graph.go 出现大量闭包;
|
|
||||||
// 2. 负责把节点函数适配为统一签名;
|
|
||||||
// 3. 不负责分支决策(当前链路为线性图)。
|
|
||||||
type scheduleRefineRunner struct {
|
|
||||||
chatModel *ark.ChatModel
|
|
||||||
emitStage func(stage, detail string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newScheduleRefineRunner(chatModel *ark.ChatModel, emitStage func(stage, detail string)) *scheduleRefineRunner {
|
|
||||||
return &scheduleRefineRunner{
|
|
||||||
chatModel: chatModel,
|
|
||||||
emitStage: emitStage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) contractNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runContractNode(ctx, r.chatModel, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) planNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runPlanNode(ctx, r.chatModel, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) sliceNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runSliceNode(ctx, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) routeNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runCompositeRouteNode(ctx, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) reactNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runReactLoopNode(ctx, r.chatModel, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) hardCheckNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runHardCheckNode(ctx, r.chatModel, st, r.emitStage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *scheduleRefineRunner) summaryNode(ctx context.Context, st *ScheduleRefineState) (*ScheduleRefineState, error) {
|
|
||||||
return runSummaryNode(ctx, r.chatModel, st, r.emitStage)
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
package schedulerefine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// 固定业务时区,避免“今天/明天”在容器默认时区下偏移。
|
|
||||||
timezoneName = "Asia/Shanghai"
|
|
||||||
// 统一分钟级时间文本格式。
|
|
||||||
datetimeLayout = "2006-01-02 15:04"
|
|
||||||
|
|
||||||
// 预算默认值。
|
|
||||||
defaultPlanMax = 2
|
|
||||||
defaultExecuteMax = 24
|
|
||||||
defaultPerTaskBudget = 4
|
|
||||||
defaultReplanMax = 2
|
|
||||||
defaultCompositeRetry = 2
|
|
||||||
defaultRepairReserve = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// RefineContract 表示本轮微调意图契约。
|
|
||||||
type RefineContract struct {
|
|
||||||
Intent string `json:"intent"`
|
|
||||||
Strategy string `json:"strategy"`
|
|
||||||
HardRequirements []string `json:"hard_requirements"`
|
|
||||||
HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"`
|
|
||||||
KeepRelativeOrder bool `json:"keep_relative_order"`
|
|
||||||
OrderScope string `json:"order_scope"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefineAssertion 表示可由后端直接判定的结构化硬断言。
|
|
||||||
//
|
|
||||||
// 字段说明:
|
|
||||||
// 1. Metric:断言指标名,例如 source_move_ratio_percent;
|
|
||||||
// 2. Operator:比较操作符,支持 == / <= / >= / between;
|
|
||||||
// 3. Value/Min/Max:阈值;
|
|
||||||
// 4. Week/TargetWeek:可选周次上下文。
|
|
||||||
type RefineAssertion struct {
|
|
||||||
Metric string `json:"metric"`
|
|
||||||
Operator string `json:"operator"`
|
|
||||||
Value int `json:"value,omitempty"`
|
|
||||||
Min int `json:"min,omitempty"`
|
|
||||||
Max int `json:"max,omitempty"`
|
|
||||||
Week int `json:"week,omitempty"`
|
|
||||||
TargetWeek int `json:"target_week,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HardCheckReport 表示终审硬校验结果。
|
|
||||||
type HardCheckReport struct {
|
|
||||||
PhysicsPassed bool `json:"physics_passed"`
|
|
||||||
PhysicsIssues []string `json:"physics_issues,omitempty"`
|
|
||||||
|
|
||||||
IntentPassed bool `json:"intent_passed"`
|
|
||||||
IntentReason string `json:"intent_reason,omitempty"`
|
|
||||||
IntentUnmet []string `json:"intent_unmet,omitempty"`
|
|
||||||
|
|
||||||
OrderPassed bool `json:"order_passed"`
|
|
||||||
OrderIssues []string `json:"order_issues,omitempty"`
|
|
||||||
|
|
||||||
RepairTried bool `json:"repair_tried"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReactRoundObservation 记录每轮 ReAct 的关键观察。
|
|
||||||
type ReactRoundObservation struct {
|
|
||||||
Round int `json:"round"`
|
|
||||||
GoalCheck string `json:"goal_check,omitempty"`
|
|
||||||
Decision string `json:"decision,omitempty"`
|
|
||||||
ToolName string `json:"tool_name,omitempty"`
|
|
||||||
ToolParams map[string]any `json:"tool_params,omitempty"`
|
|
||||||
ToolSuccess bool `json:"tool_success"`
|
|
||||||
ToolErrorCode string `json:"tool_error_code,omitempty"`
|
|
||||||
ToolResult string `json:"tool_result,omitempty"`
|
|
||||||
Reflect string `json:"reflect,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlannerPlan 表示 Planner 生成的阶段执行计划。
|
|
||||||
type PlannerPlan struct {
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Steps []string `json:"steps,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefineSlicePlan 表示切片节点输出。
|
|
||||||
type RefineSlicePlan struct {
|
|
||||||
WeekFilter []int `json:"week_filter,omitempty"`
|
|
||||||
SourceDays []int `json:"source_days,omitempty"`
|
|
||||||
TargetDays []int `json:"target_days,omitempty"`
|
|
||||||
ExcludeSections []int `json:"exclude_sections,omitempty"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefineObjective 表示“可执行且可校验”的目标约束。
|
|
||||||
//
|
|
||||||
// 设计说明:
|
|
||||||
// 1. 由 contract/slice 从自然语言编译得到;
|
|
||||||
// 2. 执行阶段(done 收口)与终审阶段(hard_check)共用同一份约束;
|
|
||||||
// 3. 避免“执行逻辑与终审逻辑各说各话”。
|
|
||||||
type RefineObjective struct {
|
|
||||||
Mode string `json:"mode,omitempty"` // none | move_all | move_ratio
|
|
||||||
|
|
||||||
SourceWeeks []int `json:"source_weeks,omitempty"`
|
|
||||||
TargetWeeks []int `json:"target_weeks,omitempty"`
|
|
||||||
SourceDays []int `json:"source_days,omitempty"`
|
|
||||||
TargetDays []int `json:"target_days,omitempty"`
|
|
||||||
|
|
||||||
ExcludeSections []int `json:"exclude_sections,omitempty"`
|
|
||||||
|
|
||||||
BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"`
|
|
||||||
RequiredMoveMin int `json:"required_move_min,omitempty"`
|
|
||||||
RequiredMoveMax int `json:"required_move_max,omitempty"`
|
|
||||||
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScheduleRefineState 是连续微调图的统一状态。
|
|
||||||
type ScheduleRefineState struct {
|
|
||||||
// 1) 请求上下文
|
|
||||||
TraceID string
|
|
||||||
UserID int
|
|
||||||
ConversationID string
|
|
||||||
UserMessage string
|
|
||||||
RequestNow time.Time
|
|
||||||
RequestNowText string
|
|
||||||
|
|
||||||
// 2) 继承自预览快照的数据
|
|
||||||
TaskClassIDs []int
|
|
||||||
Constraints []string
|
|
||||||
// InitialHybridEntries 保存本轮微调开始前的基线,用于终审做“前后对比”。
|
|
||||||
// 说明:
|
|
||||||
// 1. 只读语义,不参与执行期改写;
|
|
||||||
// 2. 终审可基于它判断“来源任务是否真正迁移到目标区域”。
|
|
||||||
InitialHybridEntries []model.HybridScheduleEntry
|
|
||||||
HybridEntries []model.HybridScheduleEntry
|
|
||||||
AllocatedItems []model.TaskClassItem
|
|
||||||
CandidatePlans []model.UserWeekSchedule
|
|
||||||
|
|
||||||
// 3) 本轮执行状态
|
|
||||||
UserIntent string
|
|
||||||
Contract RefineContract
|
|
||||||
|
|
||||||
PlanMax int
|
|
||||||
PerTaskBudget int
|
|
||||||
ExecuteMax int
|
|
||||||
ReplanMax int
|
|
||||||
// CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。
|
|
||||||
CompositeRetryMax int
|
|
||||||
|
|
||||||
PlanUsed int
|
|
||||||
ReplanUsed int
|
|
||||||
|
|
||||||
MaxRounds int
|
|
||||||
RepairReserve int
|
|
||||||
RoundUsed int
|
|
||||||
ActionLogs []string
|
|
||||||
|
|
||||||
ConsecutiveFailures int
|
|
||||||
ThinkingBoostArmed bool
|
|
||||||
ObservationHistory []ReactRoundObservation
|
|
||||||
|
|
||||||
CurrentPlan PlannerPlan
|
|
||||||
BatchMoveAllowed bool
|
|
||||||
// DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。
|
|
||||||
DisableCompositeTools bool
|
|
||||||
// CompositeRouteTried 标记是否尝试过“复合批处理路由”。
|
|
||||||
CompositeRouteTried bool
|
|
||||||
// CompositeRouteSucceeded 标记复合批处理路由是否已完成“复合分支出站”。
|
|
||||||
//
|
|
||||||
// 说明:
|
|
||||||
// 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check;
|
|
||||||
// 2. 它不等价于“终审已通过”,终审是否通过仍以后续 HardCheck 结果为准;
|
|
||||||
// 3. 这样区分是为了避免“复合工具已成功执行,但业务目标要等终审裁决”时被误判为失败。
|
|
||||||
CompositeRouteSucceeded bool
|
|
||||||
TaskActionUsed map[int]int
|
|
||||||
EntriesVersion int
|
|
||||||
SeenSlotQueries map[string]struct{}
|
|
||||||
|
|
||||||
// RequiredCompositeTool 表示本轮策略要求“必须至少成功一次”的复合工具。
|
|
||||||
// 取值约定:"" | "SpreadEven" | "MinContextSwitch"。
|
|
||||||
RequiredCompositeTool string
|
|
||||||
// CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。
|
|
||||||
CompositeToolCalled map[string]bool
|
|
||||||
// CompositeToolSuccess 记录复合工具是否至少成功过一次。
|
|
||||||
CompositeToolSuccess map[string]bool
|
|
||||||
|
|
||||||
SlicePlan RefineSlicePlan
|
|
||||||
Objective RefineObjective
|
|
||||||
WorksetTaskIDs []int
|
|
||||||
WorksetCursor int
|
|
||||||
CurrentTaskID int
|
|
||||||
CurrentTaskAttempt int
|
|
||||||
|
|
||||||
LastFailedCallSignature string
|
|
||||||
OriginOrderMap map[int]int
|
|
||||||
|
|
||||||
// 4) 终审状态
|
|
||||||
HardCheck HardCheckReport
|
|
||||||
|
|
||||||
// 5) 最终输出
|
|
||||||
FinalSummary string
|
|
||||||
Completed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScheduleRefineState 基于上一版预览快照初始化状态。
|
|
||||||
//
|
|
||||||
// 职责边界:
|
|
||||||
// 1. 负责初始化预算、上下文字段与可变状态容器;
|
|
||||||
// 2. 负责拷贝 preview 数据,避免跨请求引用污染;
|
|
||||||
// 3. 不负责做任何调度动作。
|
|
||||||
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
|
|
||||||
now := nowToMinute()
|
|
||||||
st := &ScheduleRefineState{
|
|
||||||
TraceID: strings.TrimSpace(traceID),
|
|
||||||
UserID: userID,
|
|
||||||
ConversationID: strings.TrimSpace(conversationID),
|
|
||||||
UserMessage: strings.TrimSpace(userMessage),
|
|
||||||
RequestNow: now,
|
|
||||||
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
|
|
||||||
PlanMax: defaultPlanMax,
|
|
||||||
PerTaskBudget: defaultPerTaskBudget,
|
|
||||||
ExecuteMax: defaultExecuteMax,
|
|
||||||
ReplanMax: defaultReplanMax,
|
|
||||||
CompositeRetryMax: defaultCompositeRetry,
|
|
||||||
RepairReserve: defaultRepairReserve,
|
|
||||||
MaxRounds: defaultExecuteMax + defaultRepairReserve,
|
|
||||||
ActionLogs: make([]string, 0, 32),
|
|
||||||
ObservationHistory: make([]ReactRoundObservation, 0, 24),
|
|
||||||
TaskActionUsed: make(map[int]int),
|
|
||||||
SeenSlotQueries: make(map[string]struct{}),
|
|
||||||
OriginOrderMap: make(map[int]int),
|
|
||||||
CompositeToolCalled: map[string]bool{
|
|
||||||
"SpreadEven": false,
|
|
||||||
"MinContextSwitch": false,
|
|
||||||
},
|
|
||||||
CompositeToolSuccess: map[string]bool{
|
|
||||||
"SpreadEven": false,
|
|
||||||
"MinContextSwitch": false,
|
|
||||||
},
|
|
||||||
CurrentPlan: PlannerPlan{
|
|
||||||
Summary: "初始化完成,等待 Planner 生成执行计划。",
|
|
||||||
},
|
|
||||||
SlicePlan: RefineSlicePlan{
|
|
||||||
Reason: "尚未切片",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if preview == nil {
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
|
|
||||||
st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries)
|
|
||||||
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
|
|
||||||
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
|
|
||||||
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
|
|
||||||
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadLocation() *time.Location {
|
|
||||||
loc, err := time.LoadLocation(timezoneName)
|
|
||||||
if err != nil {
|
|
||||||
return time.Local
|
|
||||||
}
|
|
||||||
return loc
|
|
||||||
}
|
|
||||||
|
|
||||||
func nowToMinute() time.Time {
|
|
||||||
return time.Now().In(loadLocation()).Truncate(time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dst := make([]model.HybridScheduleEntry, len(src))
|
|
||||||
copy(dst, src)
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dst := make([]model.TaskClassItem, 0, len(src))
|
|
||||||
for _, item := range src {
|
|
||||||
copied := item
|
|
||||||
if item.CategoryID != nil {
|
|
||||||
v := *item.CategoryID
|
|
||||||
copied.CategoryID = &v
|
|
||||||
}
|
|
||||||
if item.Order != nil {
|
|
||||||
v := *item.Order
|
|
||||||
copied.Order = &v
|
|
||||||
}
|
|
||||||
if item.Content != nil {
|
|
||||||
v := *item.Content
|
|
||||||
copied.Content = &v
|
|
||||||
}
|
|
||||||
if item.Status != nil {
|
|
||||||
v := *item.Status
|
|
||||||
copied.Status = &v
|
|
||||||
}
|
|
||||||
if item.EmbeddedTime != nil {
|
|
||||||
t := *item.EmbeddedTime
|
|
||||||
copied.EmbeddedTime = &t
|
|
||||||
}
|
|
||||||
dst = append(dst, copied)
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
|
||||||
for _, week := range src {
|
|
||||||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
|
||||||
copy(eventsCopy, week.Events)
|
|
||||||
dst = append(dst, model.UserWeekSchedule{
|
|
||||||
Week: week.Week,
|
|
||||||
Events: eventsCopy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildOriginOrderMap 构建 suggested 任务的初始顺序基线(task_item_id -> rank)。
|
|
||||||
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
|
|
||||||
orderMap := make(map[int]int)
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return orderMap
|
|
||||||
}
|
|
||||||
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if isMovableSuggestedTask(entry) {
|
|
||||||
suggested = append(suggested, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(suggested, func(i, j int) bool {
|
|
||||||
left := suggested[i]
|
|
||||||
right := suggested[j]
|
|
||||||
if left.Week != right.Week {
|
|
||||||
return left.Week < right.Week
|
|
||||||
}
|
|
||||||
if left.DayOfWeek != right.DayOfWeek {
|
|
||||||
return left.DayOfWeek < right.DayOfWeek
|
|
||||||
}
|
|
||||||
if left.SectionFrom != right.SectionFrom {
|
|
||||||
return left.SectionFrom < right.SectionFrom
|
|
||||||
}
|
|
||||||
if left.SectionTo != right.SectionTo {
|
|
||||||
return left.SectionTo < right.SectionTo
|
|
||||||
}
|
|
||||||
return left.TaskItemID < right.TaskItemID
|
|
||||||
})
|
|
||||||
for i, entry := range suggested {
|
|
||||||
orderMap[entry.TaskItemID] = i + 1
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
package agentprompt
|
package agentprompt
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ScheduleRefineContractPrompt = `You are SmartFlow's schedule refine contract analyzer.
|
// ScheduleRefineContractPrompt 负责把用户自然语言微调请求抽取为结构化契约。
|
||||||
Return exactly one JSON object.
|
ScheduleRefineContractPrompt = `你是 SmartFlow 的排程微调契约分析器。
|
||||||
|
你会收到:当前时间、用户请求、已有排程摘要。
|
||||||
Schema:
|
请只输出 JSON,不要 Markdown,不要解释,不要代码块:
|
||||||
{
|
{
|
||||||
"intent": "short summary",
|
"intent": "一句话概括本轮微调目标",
|
||||||
"strategy": "local_adjust|keep",
|
"strategy": "local_adjust|keep",
|
||||||
"hard_requirements": ["..."],
|
"hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"],
|
||||||
"hard_assertions": [
|
"hard_assertions": [
|
||||||
{
|
{
|
||||||
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
|
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
|
||||||
@@ -24,41 +24,75 @@ Schema:
|
|||||||
"order_scope": "global|week"
|
"order_scope": "global|week"
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
规则:
|
||||||
- Default keep_relative_order=true unless the user explicitly allows reordering.
|
1. 除非用户明确表达“允许打乱顺序/顺序无所谓”,keep_relative_order 默认 true。
|
||||||
- If tasks are being moved, strategy must be local_adjust.
|
2. 仅当用户明确放宽顺序时,keep_relative_order 才允许为 false;order_scope 默认 "global"。
|
||||||
- hard_requirements must be concrete and verifiable.
|
3. 只要涉及移动任务,strategy 必须是 local_adjust;仅在无需改动时才用 keep。
|
||||||
- hard_assertions should be as structured as possible.`
|
4. hard_requirements 必须可验证,避免空泛描述。
|
||||||
|
5. hard_assertions 必须尽量结构化,避免只给自然语言目标。`
|
||||||
|
|
||||||
ScheduleRefinePlannerPrompt = `You are SmartFlow's schedule refine planner.
|
// ScheduleRefinePlannerPrompt 只负责生成“执行路径”,不直接执行动作。
|
||||||
Return exactly one JSON object:
|
ScheduleRefinePlannerPrompt = `你是 SmartFlow 的排程微调 Planner。
|
||||||
|
你会收到:用户请求、契约、最近动作观察。
|
||||||
|
请只输出 JSON,不要 Markdown,不要解释,不要代码块:
|
||||||
{
|
{
|
||||||
"summary": "one sentence",
|
"summary": "本阶段执行策略一句话",
|
||||||
"steps": ["step1","step2","step3"]
|
"steps": ["步骤1","步骤2","步骤3"]
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
规则:
|
||||||
- Keep 3-4 steps.
|
1. steps 保持 3~4 条,优先“先取证再动作”。
|
||||||
- Prefer "inspect first, then act".
|
2. summary <= 36 字,单步 <= 28 字。
|
||||||
- If the goal is even spreading, the steps must mention SpreadEven and success gating.
|
3. 若目标是“均匀分散”,steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。
|
||||||
- If the goal is minimizing context switching, the steps must mention MinContextSwitch and success gating.`
|
4. 若目标是“上下文切换最少/同科目连续”,steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。
|
||||||
|
5. 不要输出半截 JSON。`
|
||||||
|
|
||||||
ScheduleRefineReactPrompt = `You are SmartFlow's single-task micro ReAct executor.
|
// ScheduleRefineReactPrompt 用于“单任务微步 ReAct”执行器。
|
||||||
You may do exactly one thing each round:
|
ScheduleRefineReactPrompt = `你是 SmartFlow 的单任务微步 ReAct 执行器。
|
||||||
1. call one tool
|
当前只处理一个任务(CURRENT_TASK),不能发散到其它任务的主动改动。
|
||||||
2. return done=true
|
你每轮只能做两件事之一:
|
||||||
|
1) 调用一个工具(基础工具或复合工具)
|
||||||
|
2) 输出 done=true 结束当前任务
|
||||||
|
|
||||||
Tool groups:
|
工具分组:
|
||||||
- Basic: QueryTargetTasks, QueryAvailableSlots, Move, Swap, BatchMove, Verify
|
- 基础工具:QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify
|
||||||
- Composite: SpreadEven, MinContextSwitch
|
- 复合工具:SpreadEven / MinContextSwitch
|
||||||
|
|
||||||
Return exactly one JSON object:
|
工具说明(按职责):
|
||||||
|
1. QueryTargetTasks:查询候选任务集合(只读)。
|
||||||
|
常用参数:week/week_filter/day_of_week/task_item_ids/status。
|
||||||
|
适用:先摸清“有哪些任务可动、当前在哪”。
|
||||||
|
2. QueryAvailableSlots:查询可放置坑位(只读,默认先纯空位,必要时补可嵌入位)。
|
||||||
|
常用参数:week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。
|
||||||
|
适用:Move 前先拿可落点清单。
|
||||||
|
3. Move:移动单个任务到目标坑位(写操作)。
|
||||||
|
必要参数:task_item_id,to_week,to_day,to_section_from,to_section_to。
|
||||||
|
适用:单任务精确挪动。
|
||||||
|
4. Swap:交换两个任务坑位(写操作)。
|
||||||
|
必要参数:task_a,task_b。
|
||||||
|
适用:两个任务互换位置比单独 Move 更稳时。
|
||||||
|
5. BatchMove:批量原子移动(写操作)。
|
||||||
|
必要参数:{"moves":[{Move参数...},{Move参数...}]}。
|
||||||
|
适用:一轮要改多个任务且要求“要么全成要么全回滚”。
|
||||||
|
6. Verify:执行确定性校验(只读)。
|
||||||
|
常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。
|
||||||
|
适用:收尾前快速自检是否符合确定性约束。
|
||||||
|
7. SpreadEven(复合):按“均匀铺开”目标一次规划并执行多任务移动(写操作)。
|
||||||
|
必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。
|
||||||
|
可选参数:week/week_filter/day_of_week/allow_embed/limit。
|
||||||
|
适用:目标是“把任务在时间上分散开,避免扎堆”。
|
||||||
|
8. MinContextSwitch(复合):按“最少上下文切换”一次规划并执行多任务移动(写操作)。
|
||||||
|
必要参数:task_item_ids(必须包含 CURRENT_TASK.task_item_id)。
|
||||||
|
可选参数:week/week_filter/day_of_week/allow_embed/limit。
|
||||||
|
适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。
|
||||||
|
|
||||||
|
请严格输出 JSON,不要 Markdown,不要解释:
|
||||||
{
|
{
|
||||||
"done": false,
|
"done": false,
|
||||||
"summary": "",
|
"summary": "",
|
||||||
"goal_check": "",
|
"goal_check": "本轮先检查什么",
|
||||||
"decision": "",
|
"decision": "本轮为何这么做",
|
||||||
"missing_info": [],
|
"missing_info": ["缺口信息1","缺口信息2"],
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
{
|
{
|
||||||
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
|
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
|
||||||
@@ -67,52 +101,74 @@ Return exactly one JSON object:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
硬规则:
|
||||||
- At most one tool call.
|
1. 每轮最多 1 个 tool_call。
|
||||||
- If done=true, tool_calls must be [].
|
2. done=true 时,tool_calls 必须为空数组。
|
||||||
- Only modify suggested tasks.
|
3. done=false 时,tool_calls 必须恰好 1 条。
|
||||||
- Do not invent tools.
|
4. 只能修改 status="suggested" 的任务,禁止修改 existing。
|
||||||
- Respect REQUIRED_COMPOSITE_TOOL and COMPOSITE_TOOLS_ALLOWED.`
|
5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。
|
||||||
|
6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。
|
||||||
|
7. Move 参数优先使用:task_item_id,to_week,to_day,to_section_from,to_section_to。
|
||||||
|
8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。
|
||||||
|
9. day_of_week 映射固定:1周一,2周二,3周三,4周四,5周五,6周六,7周日。
|
||||||
|
10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。
|
||||||
|
11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。
|
||||||
|
12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true,不要提前处理下一个任务。
|
||||||
|
13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime),只能用白名单工具。
|
||||||
|
14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。
|
||||||
|
15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false,本轮必须优先调用 REQUIRED_COMPOSITE_TOOL,禁止先调用 Move/Swap/BatchMove。
|
||||||
|
16. 若使用 SpreadEven/MinContextSwitch,必须在参数中提供 task_item_ids(且包含 CURRENT_TASK.task_item_id)。
|
||||||
|
17. 若 COMPOSITE_TOOLS_ALLOWED=false,禁止调用 SpreadEven/MinContextSwitch,只能使用基础工具逐步处理。
|
||||||
|
18. 为保证解析稳定:goal_check<=50字,decision<=90字,summary<=60字。`
|
||||||
|
|
||||||
ScheduleRefinePostReflectPrompt = `You are SmartFlow's post-tool reflector.
|
// ScheduleRefinePostReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。
|
||||||
Return exactly one JSON object:
|
ScheduleRefinePostReflectPrompt = `你是 SmartFlow 的 ReAct 复盘器。
|
||||||
|
你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。
|
||||||
|
请只输出 JSON,不要 Markdown,不要解释:
|
||||||
{
|
{
|
||||||
"reflection": "",
|
"reflection": "基于真实结果的复盘",
|
||||||
"next_strategy": "",
|
"next_strategy": "下一轮建议动作",
|
||||||
"should_stop": false
|
"should_stop": false
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
规则:
|
||||||
- Base the reflection on the real tool result only.
|
1. 若 tool_success=false,reflection 必须明确失败原因(优先引用 error_code)。
|
||||||
- If the tool failed, explain the failure reason.
|
2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTION,next_strategy 必须给出规避方法。
|
||||||
- If should_stop=true, it must mean the goal is already met or further work has low value.`
|
3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。`
|
||||||
|
|
||||||
ScheduleRefineReviewPrompt = `You are SmartFlow's final refine reviewer.
|
// ScheduleRefineReviewPrompt 用于终审语义校验。
|
||||||
Return exactly one JSON object:
|
ScheduleRefineReviewPrompt = `你是 SmartFlow 的终审校验器。
|
||||||
|
请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。
|
||||||
|
只输出 JSON:
|
||||||
{
|
{
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"reason": "",
|
"reason": "中文简短结论",
|
||||||
"unmet": []
|
"unmet": []
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
规则:
|
||||||
- If pass=true, unmet must be [].
|
1. pass=true 时 unmet 必须为空数组。
|
||||||
- If pass=false, reason must state the core gap.`
|
2. pass=false 时 reason 必须给出核心差距。`
|
||||||
|
|
||||||
ScheduleRefineSummaryPrompt = `You are SmartFlow's result summarizer.
|
// ScheduleRefineSummaryPrompt 用于最终面向用户的自然语言总结。
|
||||||
Write a short user-facing summary in 2-4 Chinese sentences:
|
ScheduleRefineSummaryPrompt = `你是 SmartFlow 的排程结果解读助手。
|
||||||
1. what changed
|
请基于输入输出 2~4 句中文总结:
|
||||||
2. what benefit was achieved
|
1) 先说明本轮改了什么;
|
||||||
3. if final review still failed, what remains`
|
2) 再说明改动收益;
|
||||||
|
3) 若终审未完全通过,明确还差什么。
|
||||||
|
不要输出 JSON。`
|
||||||
|
|
||||||
ScheduleRefineRepairPrompt = `You are SmartFlow's one-step repair executor.
|
// ScheduleRefineRepairPrompt 用于终审失败后的单次修复动作。
|
||||||
The current plan failed final review.
|
ScheduleRefineRepairPrompt = `你是 SmartFlow 的修复执行器。
|
||||||
Return exactly one JSON object with exactly one tool call:
|
当前方案未通过终审,请根据“未满足点”只做一次修复动作。
|
||||||
|
只允许输出一个 tool_call(Move 或 Swap),不允许 done。
|
||||||
|
|
||||||
|
输出格式(严格 JSON):
|
||||||
{
|
{
|
||||||
"done": false,
|
"done": false,
|
||||||
"summary": "",
|
"summary": "",
|
||||||
"goal_check": "",
|
"goal_check": "本轮修复目标",
|
||||||
"decision": "",
|
"decision": "修复决策依据",
|
||||||
"missing_info": [],
|
"missing_info": [],
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
{
|
{
|
||||||
@@ -122,10 +178,11 @@ Return exactly one JSON object with exactly one tool call:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Use standard Move keys only:
|
Move 参数必须使用标准键:
|
||||||
- task_item_id
|
- task_item_id
|
||||||
- to_week
|
- to_week
|
||||||
- to_day
|
- to_day
|
||||||
- to_section_from
|
- to_section_from
|
||||||
- to_section_to`
|
- to_section_to
|
||||||
|
禁止使用 new_week/new_day/section_from 等别名。`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -344,3 +344,13 @@
|
|||||||
|
|
||||||
1. 绉婚櫎 `agent2/node/schedule_refine_impl` 鏍圭洰褰曞疄鐜帮紝鏀逛负鏀惧埌 `agent2/node/schedule_refine_impl`銆?2. `agent2/node/schedule_refine.go` 缁х画淇濈暀缁熶竴闂ㄩ潰鑱岃矗锛岄伩鍏?service/graph 鐩存帴渚濊禆缁嗚妭瀹炵幇銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€锛屽伐鍏峰疄鐜颁綅缃敼涓?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 娉ㄩ噴宸叉竻鐞嗕贡鐮侊紝graph 浠呰礋璐f牎楠屼笌缂栨帓銆?5. `service/agentsvc/agent_schedule_refine.go` 鍏ュ彛淇濇寔涓嶅彉锛屼粛瀹屽叏涓庢棫 `backend/agent/*` 瑙h€︺€?
|
1. 绉婚櫎 `agent2/node/schedule_refine_impl` 鏍圭洰褰曞疄鐜帮紝鏀逛负鏀惧埌 `agent2/node/schedule_refine_impl`銆?2. `agent2/node/schedule_refine.go` 缁х画淇濈暀缁熶竴闂ㄩ潰鑱岃矗锛岄伩鍏?service/graph 鐩存帴渚濊禆缁嗚妭瀹炵幇銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€锛屽伐鍏峰疄鐜颁綅缃敼涓?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 娉ㄩ噴宸叉竻鐞嗕贡鐮侊紝graph 浠呰礋璐f牎楠屼笌缂栨帓銆?5. `service/agentsvc/agent_schedule_refine.go` 鍏ュ彛淇濇寔涓嶅彉锛屼粛瀹屽叏涓庢棫 `backend/agent/*` 瑙h€︺€?
|
||||||
|
|
||||||
|
## 10. 2026-03-26 schedule_refine 正式落地记录
|
||||||
|
|
||||||
|
1. `agent2/node/schedule_refine.go` 已从“兼容门面”升级为正式节点实现,直接承载 contract / plan / slice / route / react / hard_check / summary 全链路逻辑。
|
||||||
|
2. `agent2/node/schedule_refine_tool.go` 已承接全部微调工具实现,当前 `schedule_refine` 在 `node` 层落为“双文件结构”,不再依赖 `_impl` 子目录。
|
||||||
|
3. `agent2/model/schedule_refine.go` 继续作为 refine 状态与默认预算的正式归属,`node` 层仅复用状态别名与初始化入口,避免再维护第二份 state。
|
||||||
|
4. `agent2/prompt/schedule_refine.go` 已同步承接 refine 的正式 prompt,删除了 `_impl/prompt.go` 这一份重复定义。
|
||||||
|
5. `agent2/graph/schedule.go` 已改为像 `schedule_plan` 一样在 graph 层真实组图,调用 `NewScheduleRefineNodes` 挂载节点,不再绕回 `_impl` 的独立运行入口。
|
||||||
|
6. 当前生产切流点保持不变:`service/agentsvc/agent_schedule_refine.go` 仍从 agent2 入口进入,但底层已完全切到新架构实现。
|
||||||
|
7. 本轮评估过把“模型调用 / JSON 解析 / ReAct 输出恢复 / 截断文本”等 helper 继续上提到更高公共层;暂未抽出的原因是 `schedule_refine` 与 `schedule_plan` 在输出契约、错误恢复、工具门禁、终审收口上仍存在较强领域差异,当前强行抽象会把公共层做成“带业务分支的半成品”,因此先保留在各自能力域内,等待下一轮出现更稳定的第三处复用后再统一抽象。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user