diff --git a/backend/agent2/graph/schedule.go b/backend/agent2/graph/schedule.go index d34e997..c47f23d 100644 --- a/backend/agent2/graph/schedule.go +++ b/backend/agent2/graph/schedule.go @@ -10,29 +10,11 @@ import ( ) const ( - // SchedulePlanGraphName 是首次排程 graph 的稳定标识。 - SchedulePlanGraphName = "schedule_plan" - // ScheduleRefineGraphName 先保留给 refine 链路使用。 + SchedulePlanGraphName = "schedule_plan" ScheduleRefineGraphName = "schedule_refine" ) -// RunSchedulePlanGraph 执行“智能排程”图编排。 -// -// 当前链路: -// START -// -> plan -// -> roughBuild -// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine) -// -> finalCheck -// -> returnPreview -// -> END -// -// 说明: -// 1. exit 分支可从 plan/roughBuild 直接提前终止; -// 2. 本文件只负责“连线与分支”,节点内业务都在 node 层实现; -// 3. 这轮已经去掉旧 runner 适配层,graph 直接挂 node 方法,减少一跳阅读成本。 func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) { - // 1. 启动前硬校验。 if input.Model == nil { return nil, errors.New("schedule plan graph: model is nil") } @@ -43,7 +25,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph return nil, err } - // 2. 注入运行时配置(可选覆盖)。 if input.DailyRefineConcurrency > 0 { input.State.DailyRefineConcurrency = input.DailyRefineConcurrency } @@ -57,8 +38,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph } graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]() - - // 3. 注册节点。 if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil { return nil, err } @@ -90,12 +69,9 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph return nil, err } - // 4. 固定入口:START -> plan。 if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil { return nil, err } - - // 5. plan 分支:roughBuild | exit。 if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch( nodes.NextAfterPlan, map[string]bool{ @@ -105,8 +81,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph )); err != nil { return nil, err } - - // 6. roughBuild 分支:dailySplit | quickRefine | weeklyRefine | exit。 if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch( nodes.NextAfterRoughBuild, map[string]bool{ @@ -119,7 +93,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph return nil, err } - // 7. 固定边:quickRefine -> weeklyRefine;dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END。 if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil { return nil, err } @@ -145,8 +118,6 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph return nil, err } - // 8. 编译并执行。 - // 路径最多约 8~9 个节点,保守预留 20 步避免误判。 runnable, err := graph.Compile(ctx, compose.WithGraphName(SchedulePlanGraphName), compose.WithMaxRunSteps(20), @@ -158,12 +129,12 @@ func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraph return runnable.Invoke(ctx, input.State) } -// ScheduleRefineGraph 先保留骨架,避免本轮“只迁 schedule_plan”时误动 refine 主链路。 -type ScheduleRefineGraph struct { - Nodes *agentnode.ScheduleRefineNodes -} - -// NewScheduleRefineGraph 创建连续微调图骨架。 -func NewScheduleRefineGraph(nodes *agentnode.ScheduleRefineNodes) *ScheduleRefineGraph { - return &ScheduleRefineGraph{Nodes: nodes} +func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineGraphRunInput) (*agentnode.ScheduleRefineState, error) { + if input.Model == nil { + return nil, errors.New("schedule refine graph: model is nil") + } + if input.State == nil { + return nil, errors.New("schedule refine graph: state is nil") + } + return agentnode.RunScheduleRefineGraph(ctx, input) } diff --git a/backend/agent2/llm/schedule_refine.go b/backend/agent2/llm/schedule_refine.go new file mode 100644 index 0000000..6b420b2 --- /dev/null +++ b/backend/agent2/llm/schedule_refine.go @@ -0,0 +1,132 @@ +package agentllm + +import ( + "context" + "time" + + "github.com/cloudwego/eino-ext/components/model/ark" +) + +const scheduleRefineNodeTimeout = 120 * time.Second + +type ScheduleRefineContractOutput struct { + Intent string `json:"intent"` + Strategy string `json:"strategy"` + HardRequirements []string `json:"hard_requirements"` + HardAssertions []ScheduleRefineAssertionLite `json:"hard_assertions"` + KeepRelativeOrder bool `json:"keep_relative_order"` + OrderScope string `json:"order_scope"` +} + +type ScheduleRefineAssertionLite struct { + Metric string `json:"metric"` + Operator string `json:"operator"` + Value int `json:"value"` + Min int `json:"min"` + Max int `json:"max"` + Week int `json:"week"` + TargetWeek int `json:"target_week"` +} + +type ScheduleRefinePlannerOutput struct { + Summary string `json:"summary"` + Steps []string `json:"steps"` +} + +type ScheduleRefineToolCall struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` +} + +type ScheduleRefineReactOutput struct { + Done bool `json:"done"` + Summary string `json:"summary"` + GoalCheck string `json:"goal_check"` + Decision string `json:"decision"` + MissingInfo []string `json:"missing_info,omitempty"` + ToolCalls []ScheduleRefineToolCall `json:"tool_calls"` +} + +type ScheduleRefinePostReflectOutput struct { + Reflection string `json:"reflection"` + NextStrategy string `json:"next_strategy"` + ShouldStop bool `json:"should_stop"` +} + +type ScheduleRefineReviewOutput struct { + Pass bool `json:"pass"` + Reason string `json:"reason"` + Unmet []string `json:"unmet"` +} + +func GenerateScheduleRefineContract(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineContractOutput, string, error) { + return callScheduleRefineJSON[ScheduleRefineContractOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 260, + Thinking: ThinkingModeDisabled, + }) +} + +func GenerateScheduleRefinePlanner(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (*ScheduleRefinePlannerOutput, string, error) { + return callScheduleRefineJSON[ScheduleRefinePlannerOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: maxTokens, + Thinking: ThinkingModeDisabled, + }) +} + +func GenerateScheduleRefineReact(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, useThinking bool, maxTokens int) (string, error) { + thinking := ThinkingModeDisabled + if useThinking { + thinking = ThinkingModeEnabled + } + return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: maxTokens, + Thinking: thinking, + }) +} + +func GenerateScheduleRefinePostReflect(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefinePostReflectOutput, string, error) { + return callScheduleRefineJSON[ScheduleRefinePostReflectOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 220, + Thinking: ThinkingModeDisabled, + }) +} + +func GenerateScheduleRefineReview(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineReviewOutput, string, error) { + return callScheduleRefineJSON[ScheduleRefineReviewOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0, + MaxTokens: 240, + Thinking: ThinkingModeDisabled, + }) +} + +func GenerateScheduleRefineSummary(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) { + return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0.35, + MaxTokens: 280, + Thinking: ThinkingModeDisabled, + }) +} + +func GenerateScheduleRefineRepair(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) { + return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{ + Temperature: 0.15, + MaxTokens: 240, + Thinking: ThinkingModeDisabled, + }) +} + +func callScheduleRefineText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) { + nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout) + defer cancel() + return CallArkText(nodeCtx, chatModel, systemPrompt, userPrompt, options) +} + +func callScheduleRefineJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) { + nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout) + defer cancel() + return CallArkJSON[T](nodeCtx, chatModel, systemPrompt, userPrompt, options) +} diff --git a/backend/agent2/model/schedule.go b/backend/agent2/model/schedule.go index 8bce757..deb55e3 100644 --- a/backend/agent2/model/schedule.go +++ b/backend/agent2/model/schedule.go @@ -198,13 +198,3 @@ func schedulePlanNowToMinute() time.Time { func normalizeAdjustmentScope(raw string) string { return NormalizeSchedulePlanAdjustmentScope(raw) } - -// ScheduleRefineState 先保留现有骨架,避免本轮“只迁 schedule_plan”时误动 refine。 -type ScheduleRefineState struct { - TraceID string - UserID int - ConversationID string - UserInput string - Completed bool - FinalSummary string -} diff --git a/backend/agent2/model/schedule_refine.go b/backend/agent2/model/schedule_refine.go new file mode 100644 index 0000000..356876b --- /dev/null +++ b/backend/agent2/model/schedule_refine.go @@ -0,0 +1,344 @@ +package agentmodel + +import ( + "sort" + "strings" + "time" + + agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared" + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + datetimeLayout = agentshared.MinuteLayout + + ScheduleRefineDefaultPlanMax = 2 + ScheduleRefineDefaultExecuteMax = 24 + ScheduleRefineDefaultPerTaskBudget = 4 + ScheduleRefineDefaultReplanMax = 2 + ScheduleRefineDefaultCompositeRetry = 2 + ScheduleRefineDefaultRepairReserve = 1 +) + +const ( + defaultPlanMax = ScheduleRefineDefaultPlanMax + defaultExecuteMax = ScheduleRefineDefaultExecuteMax + defaultPerTaskBudget = ScheduleRefineDefaultPerTaskBudget + defaultReplanMax = ScheduleRefineDefaultReplanMax + defaultCompositeRetry = ScheduleRefineDefaultCompositeRetry + defaultRepairReserve = ScheduleRefineDefaultRepairReserve +) + +// 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. 鎵ц闃舵锛坉one 鏀跺彛锛変笌缁堝闃舵锛坔ard_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. 缁堝鍙熀浜庡畠鍒ゆ柇鈥滄潵婧愪换鍔℃槸鍚︾湡姝h縼绉诲埌鐩爣鍖哄煙鈥濄€? + 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. 涓嶈礋璐e仛浠讳綍璋冨害鍔ㄤ綔銆? +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: "initialized, waiting for planner output", + }, + 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 { + return agentshared.ShanghaiLocation() +} + +func nowToMinute() time.Time { + return agentshared.NowToMinute() +} + +func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { + return agentshared.CloneHybridEntries(src) +} + +func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { + return agentshared.CloneTaskClassItems(src) +} + +func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { + return agentshared.CloneWeekSchedules(src) +} + +// 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. 涓嶈礋璐hЕ鍙戠粓瀹★紝涔熶笉璐熻矗鎺ㄥ淇鍔ㄤ綔锛? +// 3. nil state 瑙嗕负鏈€氳繃锛岄伩鍏嶄笂灞傛妸缂哄け缁撴灉璇垽涓烘垚鍔熴€? +func FinalHardCheckPassed(st *ScheduleRefineState) bool { + if st == nil { + return false + } + return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed +} + +func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool { + if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { + return false + } + if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { + return false + } + return true +} diff --git a/backend/agent2/node/quicknote.go b/backend/agent2/node/quicknote.go index 7b79aa7..cce3d27 100644 --- a/backend/agent2/node/quicknote.go +++ b/backend/agent2/node/quicknote.go @@ -2,8 +2,13 @@ package agentnode import ( "context" + "encoding/json" "errors" + "fmt" + "strings" + "time" + agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" "github.com/cloudwego/eino-ext/components/model/ark" "github.com/cloudwego/eino/components/tool" @@ -116,3 +121,384 @@ func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.Qu } return compose.END, nil } + +// Intent 负责“意图识别 + 聚合规划 + 时间校验”。 +// +// 职责边界: +// 1. 负责判断本次请求是否属于随口记; +// 2. 负责把模型规划结果回填到 state; +// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL; +// 4. 不负责真正写库。 +func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in intent node") + } + + // 1. 若上游路由已经高置信命中 quick_note,则直接进入单次聚合规划。 + // 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内; + // 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。 + if n.input.SkipIntentVerification { + n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") + st.IsQuickNoteIntent = true + st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" + st.PlannedBySingleCall = true + + n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") + plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput) + if planErr != nil { + st.IntentJudgeReason += ";聚合规划失败,回退本地兜底" + } else { + if strings.TrimSpace(plan.Title) != "" { + st.ExtractedTitle = strings.TrimSpace(plan.Title) + } + if plan.Deadline != nil { + st.ExtractedDeadline = plan.Deadline + } + st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText) + if plan.UrgencyThreshold != nil { + st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline) + } + if agentmodel.IsValidTaskPriority(plan.PriorityGroup) { + st.ExtractedPriority = plan.PriorityGroup + st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) + } + st.ExtractedBanter = strings.TrimSpace(plan.Banter) + } + + // 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。 + if strings.TrimSpace(st.ExtractedTitle) == "" { + st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) + } + + // 1.4 最后一定要做一轮本地时间硬校验。 + // 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间; + // 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。 + n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") + userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) + if userHasTimeHint && userDeadlineErr != nil { + st.DeadlineValidationError = userDeadlineErr.Error() + st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" + n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") + return st, nil + } + if userDeadline != nil { + st.ExtractedDeadline = userDeadline + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + return st, nil + } + + // 2. 常规路径:先做一次意图识别,再做本地时间硬校验。 + n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") + parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput) + if callErr != nil { + // 2.1 这里不直接返回 error,而是把它视为“本次未能确认是 quick note”,交给上层回退普通聊天。 + st.IsQuickNoteIntent = false + st.IntentJudgeReason = "意图识别失败,回退普通聊天" + return st, nil + } + + st.IsQuickNoteIntent = parsed.IsQuickNote + st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) + if !st.IsQuickNoteIntent { + return st, nil + } + + title := strings.TrimSpace(parsed.Title) + if title == "" { + title = strings.TrimSpace(st.UserInput) + } + st.ExtractedTitle = title + + n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") + + // 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。 + st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) + if st.ExtractedDeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { + st.ExtractedDeadline = deadline + } + } + + // 2.3 再强制对用户原句做一次时间线索校验。 + userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) + if userHasTimeHint && userDeadlineErr != nil { + st.DeadlineValidationError = userDeadlineErr.Error() + st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" + n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") + return st, nil + } + + // 2.4 若模型没提到 deadline,但用户原句能解析出来,则以用户原句为准补齐。 + if st.ExtractedDeadline == nil && userDeadline != nil { + st.ExtractedDeadline = userDeadline + if st.ExtractedDeadlineText == "" { + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + } + return st, nil +} + +// Priority 负责“优先级评估”。 +// +// 职责边界: +// 1. 负责在 intent 节点之后补齐 priority_group; +// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型; +// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走; +// 4. 不负责写库。 +func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in priority node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + return st, nil + } + + // 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。 + if agentmodel.IsValidTaskPriority(st.ExtractedPriority) { + if strings.TrimSpace(st.ExtractedPriorityReason) == "" { + st.ExtractedPriorityReason = "复用聚合规划优先级" + } + n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") + return st, nil + } + + // 2. 单请求聚合路径若没有给出合法 priority,则直接走本地兜底,优先保证低时延。 + if n.input.SkipIntentVerification || st.PlannedBySingleCall { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" + n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") + return st, nil + } + + n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") + deadlineText := "无" + if st.ExtractedDeadline != nil { + deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) + } + deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) + if deadlineClue == "" { + deadlineClue = "无" + } + + parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText) + if callErr != nil { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" + return st, nil + } + if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" + return st, nil + } + + st.ExtractedPriority = parsed.PriorityGroup + st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) + if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" { + urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow) + if thresholdErr == nil { + st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline) + } + } + return st, nil +} + +// Persist 负责“调工具写库 + 有限次重试状态回填”。 +// +// 职责边界: +// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参; +// 2. 负责调用 createTaskTool 执行真正写库; +// 3. 负责把成功/失败结果回填到 state,供后续分支与回复使用; +// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。 +func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in persist node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + return st, nil + } + + n.emitStage("quick_note.persisting", "正在写入任务数据。") + priority := st.ExtractedPriority + if !agentmodel.IsValidTaskPriority(priority) { + priority = fallbackPriority(st) + st.ExtractedPriority = priority + } + + deadlineText := "" + if st.ExtractedDeadline != nil { + deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339) + } + urgencyThresholdText := "" + if st.ExtractedUrgencyThreshold != nil { + urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339) + } + + toolInput := QuickNoteCreateTaskToolInput{ + Title: st.ExtractedTitle, + PriorityGroup: priority, + DeadlineAt: deadlineText, + UrgencyThresholdAt: urgencyThresholdText, + } + rawInput, marshalErr := json.Marshal(toolInput) + if marshalErr != nil { + st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" + n.emitStage("quick_note.failed", "参数构造失败,未完成写入。") + } + return st, nil + } + + rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput)) + if invokeErr != nil { + st.RecordToolError(invokeErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" + n.emitStage("quick_note.failed", "多次重试后仍未完成写入。") + } + return st, nil + } + + toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput) + if parseErr != nil { + st.RecordToolError("解析工具返回失败: " + parseErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" + n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") + } + return st, nil + } + if toolOutput.TaskID <= 0 { + st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" + n.emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") + } + return st, nil + } + + // 1. 只有拿到有效 task_id,才视为真正写入成功; + // 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。 + st.RecordToolSuccess(toolOutput.TaskID) + if strings.TrimSpace(toolOutput.Title) != "" { + st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) + } + if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) { + st.ExtractedPriority = toolOutput.PriorityGroup + } + + reply := strings.TrimSpace(toolOutput.Message) + if reply == "" { + reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority)) + } + st.AssistantReply = reply + n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") + return st, nil +} + +type quickNotePlannedResult struct { + Title string + Deadline *time.Time + DeadlineText string + UrgencyThreshold *time.Time + UrgencyThresholdText string + PriorityGroup int + PriorityReason string + Banter string +} + +// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。 +func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) { + parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput) + if err != nil { + return nil, err + } + + result := &quickNotePlannedResult{ + Title: strings.TrimSpace(parsed.Title), + DeadlineText: strings.TrimSpace(parsed.DeadlineAt), + UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt), + PriorityGroup: parsed.PriorityGroup, + PriorityReason: strings.TrimSpace(parsed.PriorityReason), + Banter: strings.TrimSpace(parsed.Banter), + } + if result.Banter != "" { + if idx := strings.Index(result.Banter, "\n"); idx >= 0 { + result.Banter = strings.TrimSpace(result.Banter[:idx]) + } + } + if result.DeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil { + result.Deadline = deadline + } + } + if result.UrgencyThresholdText != "" { + if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil { + result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline) + } + } + return result, nil +} + +func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time { + if threshold == nil { + return nil + } + if deadline == nil { + return threshold + } + if threshold.After(*deadline) { + normalized := *deadline + return &normalized + } + return threshold +} + +func fallbackPriority(st *agentmodel.QuickNoteState) int { + if st == nil { + return agentmodel.QuickNotePrioritySimpleNotImportant + } + if st.ExtractedDeadline != nil { + if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { + return agentmodel.QuickNotePriorityImportantUrgent + } + return agentmodel.QuickNotePriorityImportantNotUrgent + } + return agentmodel.QuickNotePrioritySimpleNotImportant +} + +// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 +func deriveQuickNoteTitleFromInput(userInput string) string { + text := strings.TrimSpace(userInput) + if text == "" { + return "这条任务" + } + + prefixes := []string{ + "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个", + } + for _, prefix := range prefixes { + if strings.HasPrefix(text, prefix) { + text = strings.TrimSpace(strings.TrimPrefix(text, prefix)) + break + } + } + + suffixSeparators := []string{ + ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得", + } + for _, sep := range suffixSeparators { + if idx := strings.Index(text, sep); idx > 0 { + text = strings.TrimSpace(text[:idx]) + break + } + } + + text = strings.Trim(text, ",。?!!? ") + if text == "" { + return strings.TrimSpace(userInput) + } + return text +} diff --git a/backend/agent2/node/quicknote_flow.go b/backend/agent2/node/quicknote_flow.go deleted file mode 100644 index 9b1d857..0000000 --- a/backend/agent2/node/quicknote_flow.go +++ /dev/null @@ -1,395 +0,0 @@ -package agentnode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" - agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" - "github.com/cloudwego/eino-ext/components/model/ark" -) - -// Intent 负责“意图识别 + 聚合规划 + 时间校验”。 -// -// 职责边界: -// 1. 负责判断本次请求是否属于随口记; -// 2. 负责把模型规划结果回填到 state; -// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL; -// 4. 不负责真正写库。 -func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in intent node") - } - - // 1. 若上游路由已经高置信命中 quick_note,则直接进入单次聚合规划。 - // 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内; - // 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。 - if n.input.SkipIntentVerification { - n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") - st.IsQuickNoteIntent = true - st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" - st.PlannedBySingleCall = true - - n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") - plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput) - if planErr != nil { - st.IntentJudgeReason += ";聚合规划失败,回退本地兜底" - } else { - if strings.TrimSpace(plan.Title) != "" { - st.ExtractedTitle = strings.TrimSpace(plan.Title) - } - if plan.Deadline != nil { - st.ExtractedDeadline = plan.Deadline - } - st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText) - if plan.UrgencyThreshold != nil { - st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline) - } - if agentmodel.IsValidTaskPriority(plan.PriorityGroup) { - st.ExtractedPriority = plan.PriorityGroup - st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) - } - st.ExtractedBanter = strings.TrimSpace(plan.Banter) - } - - // 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。 - if strings.TrimSpace(st.ExtractedTitle) == "" { - st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) - } - - // 1.4 最后一定要做一轮本地时间硬校验。 - // 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间; - // 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。 - n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") - userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) - if userHasTimeHint && userDeadlineErr != nil { - st.DeadlineValidationError = userDeadlineErr.Error() - st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" - n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") - return st, nil - } - if userDeadline != nil { - st.ExtractedDeadline = userDeadline - st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) - } - return st, nil - } - - // 2. 常规路径:先做一次意图识别,再做本地时间硬校验。 - n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") - parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput) - if callErr != nil { - // 2.1 这里不直接返回 error,而是把它视为“本次未能确认是 quick note”,交给上层回退普通聊天。 - st.IsQuickNoteIntent = false - st.IntentJudgeReason = "意图识别失败,回退普通聊天" - return st, nil - } - - st.IsQuickNoteIntent = parsed.IsQuickNote - st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) - if !st.IsQuickNoteIntent { - return st, nil - } - - title := strings.TrimSpace(parsed.Title) - if title == "" { - title = strings.TrimSpace(st.UserInput) - } - st.ExtractedTitle = title - - n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") - - // 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。 - st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) - if st.ExtractedDeadlineText != "" { - if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { - st.ExtractedDeadline = deadline - } - } - - // 2.3 再强制对用户原句做一次时间线索校验。 - userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) - if userHasTimeHint && userDeadlineErr != nil { - st.DeadlineValidationError = userDeadlineErr.Error() - st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" - n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") - return st, nil - } - - // 2.4 若模型没提到 deadline,但用户原句能解析出来,则以用户原句为准补齐。 - if st.ExtractedDeadline == nil && userDeadline != nil { - st.ExtractedDeadline = userDeadline - if st.ExtractedDeadlineText == "" { - st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) - } - } - return st, nil -} - -// Priority 负责“优先级评估”。 -// -// 职责边界: -// 1. 负责在 intent 节点之后补齐 priority_group; -// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型; -// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走; -// 4. 不负责写库。 -func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in priority node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - return st, nil - } - - // 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。 - if agentmodel.IsValidTaskPriority(st.ExtractedPriority) { - if strings.TrimSpace(st.ExtractedPriorityReason) == "" { - st.ExtractedPriorityReason = "复用聚合规划优先级" - } - n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") - return st, nil - } - - // 2. 单请求聚合路径若没有给出合法 priority,则直接走本地兜底,优先保证低时延。 - if n.input.SkipIntentVerification || st.PlannedBySingleCall { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" - n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") - return st, nil - } - - n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") - deadlineText := "无" - if st.ExtractedDeadline != nil { - deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) - } - deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) - if deadlineClue == "" { - deadlineClue = "无" - } - - parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText) - if callErr != nil { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" - return st, nil - } - if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) { - st.ExtractedPriority = fallbackPriority(st) - st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" - return st, nil - } - - st.ExtractedPriority = parsed.PriorityGroup - st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) - if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" { - urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow) - if thresholdErr == nil { - st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline) - } - } - return st, nil -} - -// Persist 负责“调工具写库 + 有限次重试状态回填”。 -// -// 职责边界: -// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参; -// 2. 负责调用 createTaskTool 执行真正写库; -// 3. 负责把成功/失败结果回填到 state,供后续分支与回复使用; -// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。 -func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in persist node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - return st, nil - } - - n.emitStage("quick_note.persisting", "正在写入任务数据。") - priority := st.ExtractedPriority - if !agentmodel.IsValidTaskPriority(priority) { - priority = fallbackPriority(st) - st.ExtractedPriority = priority - } - - deadlineText := "" - if st.ExtractedDeadline != nil { - deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339) - } - urgencyThresholdText := "" - if st.ExtractedUrgencyThreshold != nil { - urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339) - } - - toolInput := QuickNoteCreateTaskToolInput{ - Title: st.ExtractedTitle, - PriorityGroup: priority, - DeadlineAt: deadlineText, - UrgencyThresholdAt: urgencyThresholdText, - } - rawInput, marshalErr := json.Marshal(toolInput) - if marshalErr != nil { - st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" - n.emitStage("quick_note.failed", "参数构造失败,未完成写入。") - } - return st, nil - } - - rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput)) - if invokeErr != nil { - st.RecordToolError(invokeErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" - n.emitStage("quick_note.failed", "多次重试后仍未完成写入。") - } - return st, nil - } - - toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput) - if parseErr != nil { - st.RecordToolError("解析工具返回失败: " + parseErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" - n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") - } - return st, nil - } - if toolOutput.TaskID <= 0 { - st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" - n.emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") - } - return st, nil - } - - // 1. 只有拿到有效 task_id,才视为真正写入成功; - // 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。 - st.RecordToolSuccess(toolOutput.TaskID) - if strings.TrimSpace(toolOutput.Title) != "" { - st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) - } - if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) { - st.ExtractedPriority = toolOutput.PriorityGroup - } - - reply := strings.TrimSpace(toolOutput.Message) - if reply == "" { - reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority)) - } - st.AssistantReply = reply - n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") - return st, nil -} - -type quickNotePlannedResult struct { - Title string - Deadline *time.Time - DeadlineText string - UrgencyThreshold *time.Time - UrgencyThresholdText string - PriorityGroup int - PriorityReason string - Banter string -} - -// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。 -func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) { - parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput) - if err != nil { - return nil, err - } - - result := &quickNotePlannedResult{ - Title: strings.TrimSpace(parsed.Title), - DeadlineText: strings.TrimSpace(parsed.DeadlineAt), - UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt), - PriorityGroup: parsed.PriorityGroup, - PriorityReason: strings.TrimSpace(parsed.PriorityReason), - Banter: strings.TrimSpace(parsed.Banter), - } - if result.Banter != "" { - if idx := strings.Index(result.Banter, "\n"); idx >= 0 { - result.Banter = strings.TrimSpace(result.Banter[:idx]) - } - } - if result.DeadlineText != "" { - if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil { - result.Deadline = deadline - } - } - if result.UrgencyThresholdText != "" { - if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil { - result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline) - } - } - return result, nil -} - -func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time { - if threshold == nil { - return nil - } - if deadline == nil { - return threshold - } - if threshold.After(*deadline) { - normalized := *deadline - return &normalized - } - return threshold -} - -func fallbackPriority(st *agentmodel.QuickNoteState) int { - if st == nil { - return agentmodel.QuickNotePrioritySimpleNotImportant - } - if st.ExtractedDeadline != nil { - if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { - return agentmodel.QuickNotePriorityImportantUrgent - } - return agentmodel.QuickNotePriorityImportantNotUrgent - } - return agentmodel.QuickNotePrioritySimpleNotImportant -} - -// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 -func deriveQuickNoteTitleFromInput(userInput string) string { - text := strings.TrimSpace(userInput) - if text == "" { - return "这条任务" - } - - prefixes := []string{ - "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个", - } - for _, prefix := range prefixes { - if strings.HasPrefix(text, prefix) { - text = strings.TrimSpace(strings.TrimPrefix(text, prefix)) - break - } - } - - suffixSeparators := []string{ - ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得", - } - for _, sep := range suffixSeparators { - if idx := strings.Index(text, sep); idx > 0 { - text = strings.TrimSpace(text[:idx]) - break - } - } - - text = strings.Trim(text, ",。?!!? ") - if text == "" { - return strings.TrimSpace(userInput) - } - return text -} diff --git a/backend/agent2/node/schedule_refine.go b/backend/agent2/node/schedule_refine.go index f0c3a00..9706d87 100644 --- a/backend/agent2/node/schedule_refine.go +++ b/backend/agent2/node/schedule_refine.go @@ -1,25 +1,69 @@ package agentnode import ( - agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm" - agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream" + "context" + + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentrefine "github.com/LoveLosita/smartflow/backend/agent2/node/schedule_refine_impl" + "github.com/LoveLosita/smartflow/backend/model" ) -// ScheduleRefineNodeDeps 描述“连续微调排程”节点层公共依赖。 -type ScheduleRefineNodeDeps struct { - LLM *agentllm.Client - StageEmitter agentstream.StageEmitter +// ScheduleRefineState is the node-layer alias for refine state. +type ScheduleRefineState = agentrefine.ScheduleRefineState + +// ScheduleRefineGraphRunInput is the node-layer alias for refine graph input. +type ScheduleRefineGraphRunInput = agentrefine.ScheduleRefineGraphRunInput + +// NewScheduleRefineState creates refine state from the previous preview snapshot. +func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState { + return agentrefine.NewScheduleRefineState(traceID, userID, conversationID, userMessage, preview) } -// ScheduleRefineNodes 是“连续微调排程”节点逻辑容器。 +// FinalHardCheckPassed reports whether the final refine hard check passed. +func FinalHardCheckPassed(st *ScheduleRefineState) bool { + return agentrefine.FinalHardCheckPassed(st) +} + +// ScheduleRefineNodes is a temporary compatibility facade. +// The real refine implementation still lives in schedule_refine_impl until the next split round lands. type ScheduleRefineNodes struct { - deps ScheduleRefineNodeDeps + input ScheduleRefineGraphRunInput } -// NewScheduleRefineNodes 创建连续微调节点容器。 -func NewScheduleRefineNodes(deps ScheduleRefineNodeDeps) *ScheduleRefineNodes { - if deps.StageEmitter == nil { - deps.StageEmitter = agentstream.NoopStageEmitter() - } - return &ScheduleRefineNodes{deps: deps} +// NewScheduleRefineNodes stores the refine graph input. +func NewScheduleRefineNodes(input ScheduleRefineGraphRunInput) (*ScheduleRefineNodes, error) { + return &ScheduleRefineNodes{input: input}, nil +} + +func (n *ScheduleRefineNodes) Contract(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) Plan(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) Slice(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) Route(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) React(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) HardCheck(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +func (n *ScheduleRefineNodes) Summary(ctx context.Context, st *agentmodel.ScheduleRefineState) (*agentmodel.ScheduleRefineState, error) { + return st, nil +} + +// RunScheduleRefineGraph is kept as the single executable entry for refine. +func RunScheduleRefineGraph(ctx context.Context, input ScheduleRefineGraphRunInput) (*ScheduleRefineState, error) { + return agentrefine.RunScheduleRefineGraph(ctx, input) } diff --git a/backend/agent2/node/schedule_refine_impl/composite_route_test.go b/backend/agent2/node/schedule_refine_impl/composite_route_test.go new file mode 100644 index 0000000..baafbe3 --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/composite_route_test.go @@ -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 成功状态被记录") + } +} diff --git a/backend/agent2/node/schedule_refine_impl/composite_tools_test.go b/backend/agent2/node/schedule_refine_impl/composite_tools_test.go new file mode 100644 index 0000000..28e99a9 --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/composite_tools_test.go @@ -0,0 +1,179 @@ +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) + } +} diff --git a/backend/agent2/node/schedule_refine_impl/graph.go b/backend/agent2/node/schedule_refine_impl/graph.go new file mode 100644 index 0000000..98d071e --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/graph.go @@ -0,0 +1,114 @@ +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) +} diff --git a/backend/agent2/node/schedule_refine_impl/nodes.go b/backend/agent2/node/schedule_refine_impl/nodes.go new file mode 100644 index 0000000..b8ad94a --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/nodes.go @@ -0,0 +1,3380 @@ +package schedulerefine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/cloudwego/eino-ext/components/model/ark" + einoModel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +const ( + nodeTimeout = 120 * time.Second + plannerMaxTokens = 420 + reactMaxTokens = 360 +) + +const ( + jsonContractForContract = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: intent,strategy,hard_requirements,hard_assertions,keep_relative_order,order_scope。" + jsonContractForPlanner = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: summary,steps。" + jsonContractForReact = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: done,summary,goal_check,decision,missing_info,tool_calls。" + jsonContractForReview = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: pass,reason,unmet。" + jsonContractForPostReflect = "只输出单个 JSON 对象,不要 Markdown/代码块/解释。必须包含: reflection,next_strategy,should_stop。" +) + +type contractOutput struct { + Intent string `json:"intent"` + Strategy string `json:"strategy"` + HardRequirements []string `json:"hard_requirements"` + HardAssertions []hardAssertionOutput `json:"hard_assertions"` + KeepRelativeOrder bool `json:"keep_relative_order"` + OrderScope string `json:"order_scope"` +} + +type hardAssertionOutput struct { + Metric string `json:"metric"` + Operator string `json:"operator"` + Value int `json:"value"` + Min int `json:"min"` + Max int `json:"max"` + Week int `json:"week"` + TargetWeek int `json:"target_week"` +} + +type postReflectOutput struct { + Reflection string `json:"reflection"` + NextStrategy string `json:"next_strategy"` + ShouldStop bool `json:"should_stop"` +} + +type plannerOutput struct { + Summary string `json:"summary"` + Steps []string `json:"steps"` +} + +func runContractNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in contract node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in contract node") + } + emitStage("schedule_refine.contract.analyzing", "正在抽取本轮微调目标与硬性约束。") + + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s", + st.RequestNowText, + strings.TrimSpace(st.UserMessage), + len(st.HybridEntries), + countSuggested(st.HybridEntries), + strings.Join(st.Constraints, ";"), + condenseSummary(st.CandidatePlans), + ), + jsonContractForContract, + ) + raw, err := callModelText(ctx, chatModel, contractPrompt, userPrompt, false, 260, 0) + if err != nil { + st.Contract = buildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", "契约抽取失败,已按兜底策略继续微调。") + return st, nil + } + emitModelRawDebug(emitStage, "contract", raw) + parsed, parseErr := parseJSON[contractOutput](raw) + if parseErr != nil { + st.Contract = buildFallbackContract(st) + st.UserIntent = st.Contract.Intent + emitStage("schedule_refine.contract.fallback", fmt.Sprintf("契约解析失败,已按兜底策略继续微调:%s", truncate(parseErr.Error(), 180))) + return st, nil + } + + intent := strings.TrimSpace(parsed.Intent) + if intent == "" { + intent = strings.TrimSpace(st.UserMessage) + } + // 1. 顺序策略以用户表达为准:默认保持顺序,明确授权乱序才放开。 + // 2. 不再让模型自行放宽顺序,避免契约漂移导致“默认乱序”。 + keepOrder := detectOrderIntent(st.UserMessage) + reqs := append([]string(nil), parsed.HardRequirements...) + if keepOrder { + reqs = append(reqs, "保持任务原始相对顺序不变") + } + assertions := normalizeHardAssertions(parsed.HardAssertions) + if len(assertions) == 0 { + // 1. 当模型未给出结构化断言时,后端基于请求做兜底推断。 + // 2. 目标是保证终审一定可落到“可编程判断”的参数层,而不是停留在自然语言。 + assertions = inferHardAssertionsFromRequest(st.UserMessage, reqs) + } + st.UserIntent = intent + st.Contract = RefineContract{ + Intent: intent, + Strategy: normalizeStrategy(parsed.Strategy), + HardRequirements: uniqueNonEmpty(reqs), + HardAssertions: assertions, + KeepRelativeOrder: keepOrder, + OrderScope: normalizeOrderScope(parsed.OrderScope), + } + emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成:strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder)) + return st, nil +} + +func runPlanNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in plan node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in plan node") + } + if err := runPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil { + return st, err + } + return st, nil +} + +func runSliceNode( + ctx context.Context, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in slice node") + } + emitStage("schedule_refine.slice.building", "正在构建本轮微调任务切片。") + slice := buildSlicePlan(st) + workset := collectWorksetTaskIDs(st.HybridEntries, slice, st.OriginOrderMap) + if len(workset) == 0 { + relaxed := slice + relaxed.SourceDays = nil + workset = collectWorksetTaskIDs(st.HybridEntries, relaxed, st.OriginOrderMap) + if len(workset) > 0 { + slice = relaxed + emitStage("schedule_refine.slice.relaxed", "切片首次为空,已放宽来源日过滤。") + } + } + if len(workset) == 0 { + workset = collectWorksetTaskIDs(st.HybridEntries, RefineSlicePlan{}, st.OriginOrderMap) + emitStage("schedule_refine.slice.fallback", "切片仍为空,已回退到全量 suggested 任务。") + } + st.SlicePlan = slice + st.Objective = compileRefineObjective(st, slice) + st.WorksetTaskIDs = workset + st.WorksetCursor = 0 + st.CurrentTaskID = 0 + st.CurrentTaskAttempt = 0 + emitStage("schedule_refine.slice.done", fmt.Sprintf("切片完成:workset=%d,week_filter=%v,source_days=%v,target_days=%v,exclude_sections=%v。", len(workset), slice.WeekFilter, slice.SourceDays, slice.TargetDays, slice.ExcludeSections)) + if raw, err := json.Marshal(st.Objective); err == nil { + emitStage("schedule_refine.objective.done", fmt.Sprintf("目标编译完成:%s", string(raw))) + } else { + emitStage("schedule_refine.objective.done", "目标编译完成。") + } + return st, nil +} + +// runCompositeRouteNode 在 ReAct 之前做一次“全局复合动作直达”分流。 +// +// 职责边界: +// 1. 负责识别是否命中全局复合目标(SpreadEven/MinContextSwitch); +// 2. 负责直接调用一次复合工具并按配置重试,争取在进入 ReAct 前完成收口; +// 3. 不负责语义推理与逐任务细调,失败后仅负责切换到“禁复合”的 ReAct 兜底链路。 +func runCompositeRouteNode( + ctx context.Context, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + _ = ctx + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in route node") + } + ensureCompositeStateMaps(st) + if st.CompositeRetryMax < 0 { + st.CompositeRetryMax = defaultCompositeRetry + } + // 1. 先由后端判定本轮是否需要复合路由,避免把分流复杂度继续交给主 ReAct。 + // 2. 若已被上游标记为“禁复合兜底”,直接跳过该路由。 + if st.DisableCompositeTools { + emitStage("schedule_refine.route.skip", "当前已处于禁复合兜底模式,跳过复合路由。") + return st, nil + } + if strings.TrimSpace(st.RequiredCompositeTool) == "" { + st.RequiredCompositeTool = detectRequiredCompositeTool(st) + } + required := normalizeCompositeToolName(st.RequiredCompositeTool) + if required == "" { + emitStage("schedule_refine.route.skip", "未命中全局复合目标,直接进入 ReAct 兜底链路。") + return st, nil + } + + taskIDs := buildCompositeRouteTaskIDs(st) + if len(taskIDs) == 0 { + // 1. 没有任务可用于复合规划时,复合路由无法落地。 + // 2. 直接降级到 ReAct,并明确禁用复合工具,避免循环重试同一失败路径。 + st.CompositeRouteTried = true + st.DisableCompositeTools = true + st.RequiredCompositeTool = "" + st.CurrentPlan = buildFallbackPlan(st) + st.BatchMoveAllowed = false + emitStage("schedule_refine.route.fallback", "复合路由未获取到可执行任务,已切换到禁复合 ReAct 兜底。") + return st, nil + } + + totalAttempts := 1 + st.CompositeRetryMax + emitStage("schedule_refine.route.start", fmt.Sprintf("命中复合路由:tool=%s,task_count=%d,首次1次+重试%d次。", required, len(taskIDs), st.CompositeRetryMax)) + st.CompositeRouteTried = true + + policy := refineToolPolicy{ + // 1. 路由阶段只解决“坑位分布”。 + // 2. 顺序归位统一放在终审阶段,避免复合路由被顺序约束提前卡死。 + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + window := buildPlanningWindowFromEntries(st.HybridEntries) + lastReason := "" + + for attempt := 1; attempt <= totalAttempts; attempt++ { + if st.RoundUsed >= st.ExecuteMax { + lastReason = "动作预算已耗尽,无法继续复合路由重试" + break + } + call := buildCompositeRouteCall(st, required, taskIDs) + callJSON, _ := json.Marshal(call.Params) + emitStage("schedule_refine.route.attempt", fmt.Sprintf("复合路由第 %d/%d 次尝试:调用=%s 参数=%s。", attempt, totalAttempts, required, string(callJSON))) + + nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), call, window, policy) + result := normalizeToolResult(rawResult) + st.RoundUsed++ + markCompositeToolOutcome(st, result.Tool, result.Success) + emitStage("schedule_refine.route.result", fmt.Sprintf("复合路由第 %d 次结果:success=%t,error_code=%s,detail=%s", attempt, result.Success, fallbackText(result.ErrorCode, "NONE"), truncate(result.Result, 160))) + + if !result.Success { + lastReason = fallbackText(result.Result, fallbackText(result.ErrorCode, "复合工具执行失败")) + st.LastFailedCallSignature = buildToolCallSignature(call) + st.ConsecutiveFailures++ + continue + } + + st.HybridEntries = nextEntries + st.EntriesVersion++ + st.LastFailedCallSignature = "" + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + window = buildPlanningWindowFromEntries(st.HybridEntries) + + // 1. 复合动作成功后必须立刻做后端确定性校验,避免“调用成功但目标未达成”被误收口。 + // 2. 仅当业务目标与(若存在)复合门禁同时通过时,才允许跳过 ReAct。 + if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) + if pass { + st.CompositeRouteSucceeded = true + emitStage("schedule_refine.route.pass", fmt.Sprintf("复合路由收口成功:%s", truncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由成功收口:tool=%s,reason=%s", required, reason)) + return st, nil + } + lastReason = fallbackText(strings.TrimSpace(reason), "确定性目标未达成") + if len(unmet) > 0 { + emitStage("schedule_refine.route.unmet", fmt.Sprintf("复合路由第 %d 次后仍未达成:%s", attempt, truncate(strings.Join(unmet, ";"), 180))) + } + continue + } + + // 1. “均匀分散/最少上下文切换”这类复合目标,未必能编译成 deterministic objective; + // 2. 只要本轮要求的复合工具已经成功执行,就允许独立复合分支直接出站并跳过 ReAct; + // 3. 最终是否真正达标,继续交给 hard_check 统一裁决,避免“工具成功却被路由误判失败”。 + if reason, ok := allowCompositeRouteExitByToolSuccess(st, result); ok { + st.CompositeRouteSucceeded = true + emitStage("schedule_refine.route.handoff", truncate(reason, 180)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站:tool=%s,reason=%s", required, reason)) + return st, nil + } + + lastReason = "未启用确定性目标,且复合工具门禁未满足,无法在复合路由直接出站" + } + + // 1. 复合路由重试后仍失败,切入 ReAct 兜底并强制禁用复合工具。 + // 2. 禁用后仅允许基础工具逐任务搬运,避免再次回到复合失败路径造成震荡。 + st.DisableCompositeTools = true + st.RequiredCompositeTool = "" + st.CurrentPlan = buildFallbackPlan(st) + st.BatchMoveAllowed = false + emitStage("schedule_refine.route.fallback", fmt.Sprintf("复合路由未收口,切换禁复合 ReAct 兜底:%s", truncate(fallbackText(lastReason, "复合路由达到重试上限"), 180))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由失败后降级:%s", fallbackText(lastReason, "无具体失败原因"))) + return st, nil +} + +func buildCompositeRouteTaskIDs(st *ScheduleRefineState) []int { + if st == nil { + return nil + } + ids := uniquePositiveInts(append([]int(nil), st.WorksetTaskIDs...)) + if len(ids) > 0 { + return ids + } + ids = collectSourceTaskIDsForObjective(st.InitialHybridEntries, st.Objective, st.SlicePlan.WeekFilter) + if len(ids) > 0 { + return ids + } + // 兜底:从当前 suggested 中提取一份稳定任务集,避免因切片异常导致路由空跑。 + seen := make(map[int]struct{}, len(st.HybridEntries)) + out := make([]int, 0, len(st.HybridEntries)) + for _, entry := range st.HybridEntries { + if !isMovableSuggestedTask(entry) { + continue + } + if _, ok := seen[entry.TaskItemID]; ok { + continue + } + seen[entry.TaskItemID] = struct{}{} + out = append(out, entry.TaskItemID) + } + sort.Ints(out) + 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 { + limit = 12 + } + params := map[string]any{ + "task_item_ids": append([]int(nil), taskIDs...), + "allow_embed": true, + "limit": limit, + } + targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) + if len(targetWeeks) == 0 { + targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan)) + } + if len(targetWeeks) == 0 { + targetWeeks = append([]int(nil), st.Objective.SourceWeeks...) + } + if len(targetWeeks) == 1 { + params["week"] = targetWeeks[0] + } else if len(targetWeeks) > 1 { + params["week_filter"] = targetWeeks + } + + targetDays := append([]int(nil), st.Objective.TargetDays...) + if len(targetDays) == 0 { + targetDays = append([]int(nil), st.SlicePlan.TargetDays...) + } + if len(targetDays) > 0 { + params["day_of_week"] = targetDays + } + if len(st.SlicePlan.ExcludeSections) > 0 { + params["exclude_sections"] = append([]int(nil), st.SlicePlan.ExcludeSections...) + } + return reactToolCall{ + Tool: tool, + Params: params, + } +} + +func runReactLoopNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in react loop node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in react loop node") + } + if st.CompositeRouteSucceeded { + emitStage("schedule_refine.react.skip", "复合路由已收口成功,跳过 ReAct 兜底循环。") + return st, nil + } + if len(st.HybridEntries) == 0 { + st.ActionLogs = append(st.ActionLogs, "无可微调条目,跳过动作循环。") + return st, nil + } + if len(st.WorksetTaskIDs) == 0 { + st.ActionLogs = append(st.ActionLogs, "workset 为空,跳过动作循环。") + return st, nil + } + if st.PerTaskBudget <= 0 { + st.PerTaskBudget = defaultPerTaskBudget + } + if st.ExecuteMax <= 0 { + st.ExecuteMax = defaultExecuteMax + } + if st.ReplanMax < 0 { + st.ReplanMax = defaultReplanMax + } + if st.RepairReserve < 0 { + st.RepairReserve = 0 + } + st.MaxRounds = st.ExecuteMax + st.RepairReserve + if st.TaskActionUsed == nil { + st.TaskActionUsed = make(map[int]int) + } + if st.SeenSlotQueries == nil { + st.SeenSlotQueries = make(map[string]struct{}) + } + ensureCompositeStateMaps(st) + if st.DisableCompositeTools { + st.RequiredCompositeTool = "" + emitStage("schedule_refine.react.fallback_mode", "当前为禁复合兜底模式:仅允许基础工具逐任务调整。") + } else if strings.TrimSpace(st.RequiredCompositeTool) == "" { + st.RequiredCompositeTool = detectRequiredCompositeTool(st) + } + if strings.TrimSpace(st.CurrentPlan.Summary) == "" { + st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) + st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) + } + + window := buildPlanningWindowFromEntries(st.HybridEntries) + sourceWeekSet := inferSourceWeekSet(st.SlicePlan) + policy := refineToolPolicy{ + // 1. 执行期不再用顺序约束卡住 Move/Swap; + // 2. LLM 只负责把坑位排好,顺序由后端在收口阶段统一归位。 + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + } + emitStage( + "schedule_refine.react.start", + fmt.Sprintf( + "开始执行单任务微步 ReAct,workset=%d,per_task_budget=%d,execute_max=%d,replan_max=%d,required_composite=%s,required_success=%t。", + len(st.WorksetTaskIDs), + st.PerTaskBudget, + st.ExecuteMax, + st.ReplanMax, + fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"), + isRequiredCompositeSatisfied(st), + ), + ) + +outer: + for st.WorksetCursor < len(st.WorksetTaskIDs) && st.RoundUsed < st.ExecuteMax { + // 1. 每次取下一个任务前先做一次全局目标短路判断。 + // 2. 目标已满足时,直接结束整个 workset 循环,避免“任务6~10 空转”。 + if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { + if isRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("全局目标已满足,提前结束任务循环:%s", truncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标提前达成,触发短路结束:%s", reason)) + break + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) + } + taskID := st.WorksetTaskIDs[st.WorksetCursor] + current, ok := findSuggestedEntryByTaskID(st.HybridEntries, taskID) + if !ok { + st.WorksetCursor++ + continue + } + if len(sourceWeekSet) > 0 { + if _, inSourceWeek := sourceWeekSet[current.Week]; !inSourceWeek { + emitStage("schedule_refine.react.task_skip_scope", fmt.Sprintf("任务 id=%d 当前位于 W%d,不在来源周范围,已跳过。", taskID, current.Week)) + st.WorksetCursor++ + continue + } + } + st.CurrentTaskID = taskID + st.CurrentTaskAttempt = 0 + emitStage("schedule_refine.react.task_start", fmt.Sprintf("开始处理任务 %d/%d:id=%d,%s。", st.WorksetCursor+1, len(st.WorksetTaskIDs), taskID, strings.TrimSpace(current.Name))) + + taskDone := false + for st.CurrentTaskAttempt < st.PerTaskBudget && st.RoundUsed < st.ExecuteMax { + // 1. 每轮开头先刷新“当前任务”的最新位置,避免模型基于旧坐标决策。 + // 2. 若该任务已满足切片目标(例如“已从周末迁出到工作日”),则直接收口当前任务。 + latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID) + if !exists { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已不在 suggested 列表,视为当前任务已完成。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:任务条目已不再可调 suggested。", taskID)) + break + } + current = latest + if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { + // 1. 自动收口前必须通过复合工具门禁。 + // 2. 这样可避免“切片已满足但未执行必需复合工具”直接跳过执行阶段。 + if isRequiredCompositeSatisfied(st) { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已满足切片目标,自动收口并切换下一任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:已满足切片目标。", taskID)) + break + } + emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 虽满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止自动收口:required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"))) + } + + round := st.RoundUsed + 1 + remainingAction := st.ExecuteMax - st.RoundUsed + remainingTotal := st.MaxRounds - st.RoundUsed + useThinking, reason := shouldEnableRecoveryThinking(st) + st.CurrentTaskAttempt++ + emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始(任务id=%d,第 %d/%d 次尝试),动作剩余=%d,总剩余=%d。", round, taskID, st.CurrentTaskAttempt, st.PerTaskBudget, remainingAction, remainingTotal)) + if useThinking { + emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮已启用恢复态 thinking:%s", round, reason)) + } + + userPrompt := buildMicroReactUserPrompt(st, current, remainingAction, remainingTotal) + raw, err := callModelText(ctx, chatModel, reactPrompt, userPrompt, useThinking, reactMaxTokens, 0) + if err != nil { + errDetail := formatRoundModelErrorDetail(round, err, ctx) + st.ActionLogs = append(st.ActionLogs, errDetail) + emitStage("schedule_refine.react.round_error", errDetail) + if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 { + st.WorksetCursor = len(st.WorksetTaskIDs) + break + } + return st, err + } + emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw) + parsed, parseErr := parseReactOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, round, emitStage, st) + if parseErr != nil { + return st, parseErr + } + + observation := ReactRoundObservation{ + Round: round, + GoalCheck: strings.TrimSpace(parsed.GoalCheck), + Decision: strings.TrimSpace(parsed.Decision), + } + emitStage("schedule_refine.react.plan", formatReactPlanStageDetail(round, parsed, remainingAction, useThinking)) + emitStage("schedule_refine.react.need_info", formatReactNeedInfoStageDetail(round, parsed.MissingInfo)) + + if parsed.Done { + allowDone := isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) + if allowDone && !isRequiredCompositeSatisfied(st) { + allowDone = false + } + if !allowDone { + if pass, _, _, applied := evaluateObjectiveDeterministic(st); applied && pass && isRequiredCompositeSatisfied(st) { + allowDone = true + } + } + if !allowDone { + observation.Reflect = fmt.Sprintf("模型返回 done=true,但任务 id=%d 尚未满足切片目标或复合工具门禁未通过,继续执行。", taskID) + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 拒绝提前 done:当前任务未满足目标。", taskID)) + continue + } + reasonText := fallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前任务已满足目标。") + observation.Reflect = reasonText + st.ObservationHistory = append(st.ObservationHistory, observation) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 完成:%s", taskID, reasonText)) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + taskDone = true + break + } + + call, warn := pickSingleToolCall(parsed.ToolCalls) + if warn != "" { + emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警:%s", round, warn)) + } + if call == nil { + observation.Reflect = "本轮未生成可执行工具动作。" + st.ObservationHistory = append(st.ObservationHistory, observation) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + break + } + normalizedCall := canonicalizeToolCall(*call) + call = &normalizedCall + emitStage("schedule_refine.react.tool_call", formatToolCallStageDetail(round, *call, remainingAction)) + + callSignature := buildToolCallSignature(*call) + taskIDs := listTaskIDsFromToolCall(*call) + if blockedResult, blocked := precheckCurrentTaskOwnership(*call, taskIDs, taskID); blocked { + if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + if blockedResult, blocked := precheckToolCallPolicy(st, *call, taskIDs); blocked { + if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + if isRepeatedFailedCall(st, callSignature) { + repeat := reactToolResult{Tool: strings.TrimSpace(call.Tool), Success: false, ErrorCode: "REPEAT_FAILED_ACTION", Result: "重复失败动作:与上一轮失败动作完全相同,请更换目标时段或改用 Swap。"} + if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, repeat, &observation); err != nil { + return st, err + } else if stop { + taskDone = true + break + } + continue + } + + for _, id := range taskIDs { + st.TaskActionUsed[id]++ + } + nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy) + result := normalizeToolResult(rawResult) + st.RoundUsed++ + markCompositeToolOutcome(st, result.Tool, result.Success) + + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = cloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, observation) + + emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + if result.Success { + st.HybridEntries = nextEntries + window = buildPlanningWindowFromEntries(st.HybridEntries) + if isMutatingToolName(result.Tool) { + st.EntriesVersion++ + } + st.LastFailedCallSignature = "" + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + // 1. 动作成功后立即尝试全局短路,避免继续拉着后续任务空转。 + // 2. 只要 deterministic 目标达成,直接收口整个 ReAct 循环。 + if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { + if isRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("动作后全局目标达成,提前结束任务循环:%s", truncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后全局目标达成,触发短路结束:%s", reason)) + taskDone = true + break outer + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后目标达成但复合工具门禁未通过:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) + } + if latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID); exists { + current = latest + if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) { + if isRequiredCompositeSatisfied(st) { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已满足切片目标,自动结束当前任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后已满足切片目标。", taskID)) + break + } + emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 动作后满足切片目标,但复合工具门禁未通过,继续执行。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止动作后自动收口:required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"))) + } + } else { + taskDone = true + emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已不在 suggested 列表,自动结束当前任务。", taskID)) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成:动作后不再可调。", taskID)) + break + } + } else { + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + if shouldTriggerReplan(st, result) { + if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { + return st, err + } else if replanned { + continue + } + } + } + if shouldStop { + // 1. 模型建议 should_stop 只作为“候选中断信号”,必须经后端目标校验确认。 + // 2. 若全局目标未达成,则继续本地循环,避免模型误停。 + if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass { + if isRequiredCompositeSatisfied(st) { + emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("模型建议停止且全局目标达成,提前收口:%s", truncate(reason, 160))) + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止且目标达成,触发短路结束:%s", reason)) + taskDone = true + break outer + } + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止但复合工具门禁未通过:required=%s", normalizeCompositeToolName(st.RequiredCompositeTool))) + } + } + } + + emitStage("schedule_refine.react.task_done", fmt.Sprintf("任务 id=%d 处理完成:status=%s。", taskID, taskProgressLabel(taskDone, st.CurrentTaskAttempt, st.PerTaskBudget))) + st.WorksetCursor++ + st.CurrentTaskID = 0 + st.CurrentTaskAttempt = 0 + } + emitStage("schedule_refine.react.done", fmt.Sprintf("单任务微步 ReAct 结束:已执行轮次=%d,重规划次数=%d,已处理任务=%d/%d。", st.RoundUsed, st.ReplanUsed, st.WorksetCursor, len(st.WorksetTaskIDs))) + return st, nil +} + +func runHardCheckNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in hard check node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in hard check node") + } + emitStage("schedule_refine.hard_check.start", "正在执行终审硬校验。") + // 1. 先锁定“业务目标是否达成”的判定结果(未排序前)。 + // 2. 后续顺序归位仅用于最终展示与顺序一致性,不得反向改变业务目标成败。 + intentPassLocked, intentReasonLocked, intentUnmetLocked := evaluateIntentForJudgement(ctx, chatModel, st, emitStage) + emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) + if changed, 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) + report.IntentPassed = intentPassLocked + report.IntentReason = intentReasonLocked + report.IntentUnmet = append([]string(nil), intentUnmetLocked...) + st.HardCheck = report + if report.PhysicsPassed && report.OrderPassed && report.IntentPassed { + emitStage("schedule_refine.hard_check.pass", "终审通过。") + return st, nil + } + if st.RoundUsed >= st.MaxRounds { + emitStage("schedule_refine.hard_check.fail", "终审未通过,且动作预算已耗尽,无法继续修复。") + return st, nil + } + emitStage("schedule_refine.hard_check.repairing", "终审未通过,正在尝试一次修复动作。") + st.HardCheck.RepairTried = true + if err := runSingleRepairAction(ctx, chatModel, st, emitStage); err != nil { + st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作失败:%v", err)) + emitStage("schedule_refine.hard_check.fail", "修复动作失败,保留当前方案。") + return st, nil + } + intentPassLocked, intentReasonLocked, intentUnmetLocked = evaluateIntentForJudgement(ctx, chatModel, st, emitStage) + emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定:pass=%t,reason=%s", intentPassLocked, truncate(intentReasonLocked, 120))) + if changed, 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) + report.IntentPassed = intentPassLocked + report.IntentReason = intentReasonLocked + report.IntentUnmet = append([]string(nil), intentUnmetLocked...) + report.RepairTried = true + st.HardCheck = report + if report.PhysicsPassed && report.OrderPassed && report.IntentPassed { + emitStage("schedule_refine.hard_check.pass", "修复后终审通过。") + return st, nil + } + emitStage("schedule_refine.hard_check.fail", "修复后仍未完全满足要求,已返回当前最优结果。") + return st, nil +} + +func runSummaryNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (*ScheduleRefineState, error) { + if st == nil { + return nil, fmt.Errorf("schedule refine: nil state in summary node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule refine: model is nil in summary node") + } + emitStage("schedule_refine.summary.generating", "正在生成微调结果总结。") + updateAllocatedItemsFromEntries(st) + st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) + reportJSON, _ := json.Marshal(st.HardCheck) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := fmt.Sprintf("用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), string(reportJSON), summarizeActionLogs(st.ActionLogs, 24)) + raw, err := callModelText(ctx, chatModel, summaryPrompt, userPrompt, false, 280, 0.35) + summary := strings.TrimSpace(raw) + if err == nil { + emitModelRawDebug(emitStage, "summary", raw) + } + if err != nil || summary == "" { + if FinalHardCheckPassed(st) { + summary = fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) + } else { + summary = fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) + } + } + summary = alignSummaryWithHardCheck(st, summary) + st.FinalSummary = summary + // 1. Completed 只代表“最终终审已通过”,不再把“链路执行完毕”误写成成功; + // 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”; + // 3. 若只是返回 best-effort 结果,FinalSummary 仍会保留,但 Completed=false。 + st.Completed = FinalHardCheckPassed(st) + emitStage("schedule_refine.summary.done", "微调总结已生成。") + return st, nil +} + +func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport { + report := HardCheckReport{} + report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems)) + report.PhysicsPassed = len(report.PhysicsIssues) == 0 + // 1. 顺序校验默认开启:即便执行期放开顺序限制,终审也要验证“后端归位”后的顺序正确性。 + // 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, + OriginOrderMap: st.OriginOrderMap, + }) + report.OrderPassed = len(report.OrderIssues) == 0 + + // 1. 优先使用“契约编译后”的确定性终审,执行与终审共用同一份目标约束。 + // 2. 仅当目标约束不可判定时,才回退语义终审兜底。 + if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, reason, unmet) + report.IntentPassed = pass + report.IntentReason = strings.TrimSpace(reason) + report.IntentUnmet = append([]string(nil), unmet...) + return report + } + + review, err := runSemanticReview(ctx, chatModel, st, emitStage) + if err != nil { + report.IntentPassed = false + report.IntentReason = fmt.Sprintf("语义校验失败:%v", err) + report.IntentUnmet = []string{"语义校验阶段异常"} + return report + } + pass, reason, unmet := applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) + report.IntentPassed = pass + report.IntentReason = strings.TrimSpace(reason) + report.IntentUnmet = append([]string(nil), unmet...) + return report +} + +// evaluateIntentForJudgement 在“最终排序前”计算业务目标是否达成。 +// +// 说明: +// 1. 优先走 deterministic objective; +// 2. objective 不可判定时退回语义 review; +// 3. 返回值会在 hard_check 中被锁定,避免后置排序反向干扰业务目标判定。 +func evaluateIntentForJudgement( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (pass bool, reason string, unmet []string) { + if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied { + pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet) + return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) + } + review, err := runSemanticReview(ctx, chatModel, st, emitStage) + if err != nil { + return false, fmt.Sprintf("语义校验失败:%v", err), []string{"语义校验阶段异常"} + } + pass, reason, unmet = applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet) + return pass, strings.TrimSpace(reason), append([]string(nil), unmet...) +} + +// compileRefineObjective 把自然语言契约编译为“可执行且可校验”的目标参数。 +func compileRefineObjective(st *ScheduleRefineState, slice RefineSlicePlan) RefineObjective { + obj := RefineObjective{ + Mode: "none", + SourceWeeks: keysOfIntSet(inferSourceWeekSet(slice)), + TargetWeeks: keysOfIntSet(inferTargetWeekSet(slice)), + SourceDays: uniquePositiveInts(append([]int(nil), slice.SourceDays...)), + TargetDays: uniquePositiveInts(append([]int(nil), slice.TargetDays...)), + ExcludeSections: uniquePositiveInts(append([]int(nil), slice.ExcludeSections...)), + } + // 1. 若契约断言显式给出来源/目标周,优先回填到 objective; + // 2. 避免后续终审只能依赖自然语言猜测。 + for _, assertion := range st.Contract.HardAssertions { + if assertion.Week > 0 && len(obj.SourceWeeks) == 0 { + obj.SourceWeeks = []int{assertion.Week} + } + if assertion.TargetWeek > 0 && len(obj.TargetWeeks) == 0 { + obj.TargetWeeks = []int{assertion.TargetWeek} + } + } + + if len(obj.SourceWeeks) == 0 && len(slice.WeekFilter) == 1 && slice.WeekFilter[0] > 0 { + obj.SourceWeeks = []int{slice.WeekFilter[0]} + } + if len(obj.TargetWeeks) == 0 && len(slice.WeekFilter) == 1 && (len(obj.SourceDays) > 0 || len(obj.TargetDays) > 0) { + obj.TargetWeeks = []int{slice.WeekFilter[0]} + } + + // 来源范围为空时无法构造目标,交给语义终审兜底。 + if len(obj.SourceWeeks) == 0 && len(obj.SourceDays) == 0 { + obj.Reason = "来源范围为空,未启用确定性目标。" + return obj + } + // 目标范围为空时同样不启用确定性目标。 + if len(obj.TargetWeeks) == 0 && len(obj.TargetDays) == 0 { + obj.Reason = "目标范围为空,未启用确定性目标。" + return obj + } + + sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, slice.WeekFilter) + obj.BaselineSourceTaskCount = len(sourceTaskIDs) + + halfIntent := hasHalfTransferIntent(st) + if halfIntent && len(obj.SourceWeeks) > 0 && len(obj.TargetWeeks) > 0 && !isSameWeeks(obj.SourceWeeks, obj.TargetWeeks) { + obj.Mode = "move_ratio" + obj.RequiredMoveMin = obj.BaselineSourceTaskCount / 2 + obj.RequiredMoveMax = (obj.BaselineSourceTaskCount + 1) / 2 + obj.Reason = "检测到“半数迁移”意图,按比例目标执行与终审。" + return obj + } + + obj.Mode = "move_all" + obj.RequiredMoveMin = obj.BaselineSourceTaskCount + obj.RequiredMoveMax = obj.BaselineSourceTaskCount + obj.Reason = "默认按来源范围任务全部进入目标范围执行与终审。" + return obj +} + +// evaluateObjectiveDeterministic 基于编译后的目标做确定性终审。 +func evaluateObjectiveDeterministic(st *ScheduleRefineState) (pass bool, reason string, unmet []string, applied bool) { + if st == nil { + return false, "", nil, false + } + obj := st.Objective + if strings.TrimSpace(obj.Mode) == "" || strings.TrimSpace(obj.Mode) == "none" { + return false, "", nil, false + } + + sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, st.SlicePlan.WeekFilter) + if len(sourceTaskIDs) == 0 { + return true, "确定性校验通过:来源范围无可调任务。", nil, true + } + + byTaskID := buildMovableTaskIndex(st.HybridEntries) + movedCount := 0 + violations := make([]string, 0, 8) + for _, taskID := range sourceTaskIDs { + entries := byTaskID[taskID] + if len(entries) == 0 { + violations = append(violations, fmt.Sprintf("任务id=%d 未在结果中找到可移动条目", taskID)) + continue + } + if len(entries) > 1 { + violations = append(violations, fmt.Sprintf("任务id=%d 命中 %d 条可移动条目,状态不唯一", taskID, len(entries))) + continue + } + entry := entries[0] + moved, why := isTaskMovedIntoObjectiveTarget(entry, obj) + if moved { + movedCount++ + continue + } + if obj.Mode == "move_all" { + violations = append(violations, fmt.Sprintf("任务id=%d 未满足目标范围:%s", taskID, why)) + continue + } + // 比例模式下,允许部分任务不迁移;但若任务落在来源/目标之外,视为异常。 + if !isTaskInObjectiveSource(entry, obj) { + violations = append(violations, fmt.Sprintf("任务id=%d 既不在来源也不在目标范围(W%dD%d)", taskID, entry.Week, entry.DayOfWeek)) + } + } + + if movedCount < obj.RequiredMoveMin || movedCount > obj.RequiredMoveMax { + violations = append(violations, fmt.Sprintf("迁移数量未达标:要求在[%d,%d],实际=%d", obj.RequiredMoveMin, obj.RequiredMoveMax, movedCount)) + } + + if len(violations) == 0 { + return true, fmt.Sprintf("确定性校验通过:迁移数量达标(%d/%d)。", movedCount, len(sourceTaskIDs)), nil, true + } + return false, fmt.Sprintf("确定性校验未通过:仍有 %d 项约束未满足。", len(violations)), violations, true +} + +func collectSourceTaskIDsForObjective(entries []model.HybridScheduleEntry, obj RefineObjective, fallbackWeekFilter []int) []int { + if len(entries) == 0 { + return nil + } + sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks) + sourceDaySet := intSliceToDaySet(obj.SourceDays) + fallbackWeekSet := intSliceToWeekSet(fallbackWeekFilter) + + seen := make(map[int]struct{}, len(entries)) + ids := make([]int, 0, len(entries)) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + continue + } + } else if len(fallbackWeekSet) > 0 { + if _, ok := fallbackWeekSet[entry.Week]; !ok { + continue + } + } + if len(sourceDaySet) > 0 { + if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { + continue + } + } + if _, exists := seen[entry.TaskItemID]; exists { + continue + } + seen[entry.TaskItemID] = struct{}{} + ids = append(ids, entry.TaskItemID) + } + sort.Ints(ids) + return ids +} + +func buildMovableTaskIndex(entries []model.HybridScheduleEntry) map[int][]model.HybridScheduleEntry { + index := make(map[int][]model.HybridScheduleEntry, len(entries)) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + index[entry.TaskItemID] = append(index[entry.TaskItemID], entry) + } + return index +} + +func hasHalfTransferIntent(st *ScheduleRefineState) bool { + if st == nil { + return false + } + if hasHalfTransferAssertion(st.Contract.HardAssertions) { + return true + } + joined := strings.ToLower(strings.Join(append([]string{st.UserMessage, st.Contract.Intent}, st.Contract.HardRequirements...), " ")) + for _, key := range []string{"一半", "半数", "对半", "50%"} { + if strings.Contains(joined, key) { + return true + } + } + return false +} + +func hasHalfTransferAssertion(assertions []RefineAssertion) bool { + for _, item := range assertions { + metric := strings.ToLower(strings.TrimSpace(item.Metric)) + if metric == "" { + continue + } + switch metric { + case "source_move_ratio_percent", "move_ratio_percent", "half_transfer_ratio": + switch strings.TrimSpace(item.Operator) { + case "==", ">=", "<=", "between": + if item.Value == 50 || item.Min == 50 || item.Max == 50 { + return true + } + } + case "source_remaining_count": + // 1. 该断言常用于“迁走一半后来源剩余=一半”。 + // 2. 具体阈值是否满足由 objective + deterministic 校验统一判定。 + return true + } + } + return false +} + +func normalizeHardAssertions(raw []hardAssertionOutput) []RefineAssertion { + if len(raw) == 0 { + return nil + } + out := make([]RefineAssertion, 0, len(raw)) + for _, item := range raw { + metric := strings.TrimSpace(item.Metric) + if metric == "" { + continue + } + operator := strings.TrimSpace(item.Operator) + if operator == "" { + operator = "==" + } + assertion := RefineAssertion{ + Metric: metric, + Operator: operator, + Value: item.Value, + Min: item.Min, + Max: item.Max, + Week: item.Week, + TargetWeek: item.TargetWeek, + } + out = append(out, assertion) + } + if len(out) == 0 { + return nil + } + return out +} + +func inferHardAssertionsFromRequest(message string, requirements []string) []RefineAssertion { + joined := strings.TrimSpace(message + " " + strings.Join(requirements, " ")) + if joined == "" { + return nil + } + weeks := extractWeekFilters(joined) + if !containsAny(strings.ToLower(joined), []string{"一半", "半数", "对半", "50%"}) { + return nil + } + // 1. 兜底断言:要求来源任务迁移比例为 50%。 + // 2. week/target_week 使用文本中前两个周次,便于后续 objective 编译。 + assertion := RefineAssertion{ + Metric: "source_move_ratio_percent", + Operator: "==", + Value: 50, + } + if len(weeks) > 0 { + assertion.Week = weeks[0] + } + if len(weeks) > 1 { + assertion.TargetWeek = weeks[1] + } + return []RefineAssertion{assertion} +} + +func isTaskMovedIntoObjectiveTarget(entry model.HybridScheduleEntry, obj RefineObjective) (bool, string) { + targetWeekSet := intSliceToWeekSet(obj.TargetWeeks) + targetDaySet := intSliceToDaySet(obj.TargetDays) + excludedSections := intSliceToSectionSet(obj.ExcludeSections) + if len(targetWeekSet) > 0 { + if _, ok := targetWeekSet[entry.Week]; !ok { + return false, fmt.Sprintf("week=%d 不在目标周", entry.Week) + } + } + if len(targetDaySet) > 0 { + if _, ok := targetDaySet[entry.DayOfWeek]; !ok { + return false, fmt.Sprintf("day_of_week=%d 不在目标日", entry.DayOfWeek) + } + } + if len(excludedSections) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSections) { + return false, fmt.Sprintf("section=%d-%d 命中排除节次", entry.SectionFrom, entry.SectionTo) + } + return true, "" +} + +func isTaskInObjectiveSource(entry model.HybridScheduleEntry, obj RefineObjective) bool { + sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks) + sourceDaySet := intSliceToDaySet(obj.SourceDays) + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + return false + } + } + if len(sourceDaySet) > 0 { + if _, ok := sourceDaySet[entry.DayOfWeek]; !ok { + return false + } + } + return true +} + +func isSameWeeks(left []int, right []int) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + lset := intSliceToWeekSet(left) + rset := intSliceToWeekSet(right) + if len(lset) != len(rset) { + return false + } + for w := range lset { + if _, ok := rset[w]; !ok { + return false + } + } + return true +} + +// normalizeMovableTaskOrderByOrigin 在“坑位不变”的前提下,按 origin_rank 归位任务顺序。 +// +// 步骤化说明: +// 1. 先提取所有可移动任务的当前坑位(week/day/section); +// 2. 再按任务跨度分组,避免把 2 节任务塞进 3 节坑位; +// 3. 每个跨度组内按坑位时间升序与 origin_rank 升序做一一映射; +// 4. 最终只改“任务身份落到哪个坑位”,不改坑位分布本身。 +func normalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool { + if st == nil || len(st.HybridEntries) <= 1 || len(st.OriginOrderMap) == 0 { + return false + } + entries := cloneHybridEntries(st.HybridEntries) + indices := make([]int, 0, len(entries)) + for idx, entry := range entries { + if isMovableSuggestedTask(entry) { + indices = append(indices, idx) + } + } + if len(indices) <= 1 { + return false + } + + type slot struct { + Week int + DayOfWeek int + SectionFrom int + SectionTo int + } + groupSlots := make(map[int][]slot) // key=span + groupTasks := make(map[int][]model.HybridScheduleEntry) // key=span + for _, idx := range indices { + entry := entries[idx] + span := entry.SectionTo - entry.SectionFrom + 1 + groupSlots[span] = append(groupSlots[span], slot{ + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + }) + groupTasks[span] = append(groupTasks[span], entry) + } + + changed := false + spanKeys := make([]int, 0, len(groupSlots)) + for span := range groupSlots { + spanKeys = append(spanKeys, span) + } + sort.Ints(spanKeys) + + groupCursor := make(map[int]int, len(groupSlots)) + for _, span := range spanKeys { + slots := groupSlots[span] + tasks := groupTasks[span] + if len(slots) != len(tasks) || len(slots) == 0 { + continue + } + sort.SliceStable(slots, func(i, j int) bool { + if slots[i].Week != slots[j].Week { + return slots[i].Week < slots[j].Week + } + if slots[i].DayOfWeek != slots[j].DayOfWeek { + return slots[i].DayOfWeek < slots[j].DayOfWeek + } + if slots[i].SectionFrom != slots[j].SectionFrom { + return slots[i].SectionFrom < slots[j].SectionFrom + } + return slots[i].SectionTo < slots[j].SectionTo + }) + sort.SliceStable(tasks, func(i, j int) bool { + ri := st.OriginOrderMap[tasks[i].TaskItemID] + rj := st.OriginOrderMap[tasks[j].TaskItemID] + if ri <= 0 { + ri = 1 << 30 + } + if rj <= 0 { + rj = 1 << 30 + } + if ri != rj { + return ri < rj + } + if tasks[i].Week != tasks[j].Week { + return tasks[i].Week < tasks[j].Week + } + if tasks[i].DayOfWeek != tasks[j].DayOfWeek { + return tasks[i].DayOfWeek < tasks[j].DayOfWeek + } + if tasks[i].SectionFrom != tasks[j].SectionFrom { + return tasks[i].SectionFrom < tasks[j].SectionFrom + } + return tasks[i].TaskItemID < tasks[j].TaskItemID + }) + for i := range tasks { + tasks[i].Week = slots[i].Week + tasks[i].DayOfWeek = slots[i].DayOfWeek + tasks[i].SectionFrom = slots[i].SectionFrom + tasks[i].SectionTo = slots[i].SectionTo + } + groupTasks[span] = tasks + } + + for _, idx := range indices { + entry := entries[idx] + span := entry.SectionTo - entry.SectionFrom + 1 + cursor := groupCursor[span] + if cursor >= len(groupTasks[span]) { + continue + } + nextEntry := groupTasks[span][cursor] + groupCursor[span] = cursor + 1 + if entry.TaskItemID != nextEntry.TaskItemID || + entry.Week != nextEntry.Week || + entry.DayOfWeek != nextEntry.DayOfWeek || + entry.SectionFrom != nextEntry.SectionFrom || + entry.SectionTo != nextEntry.SectionTo { + changed = true + } + entries[idx] = nextEntry + } + if !changed { + return false + } + sortHybridEntries(entries) + st.HybridEntries = entries + 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") + } + if chatModel == nil { + return fmt.Errorf("nil model") + } + if st.RoundUsed >= st.MaxRounds { + return fmt.Errorf("动作预算已耗尽") + } + entriesJSON, _ := json.Marshal(st.HybridEntries) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n未满足点=%s\n当前混合日程JSON=%s\nMove标准Schema={task_item_id,to_week,to_day,to_section_from,to_section_to}\nSwap标准Schema={task_a,task_b}", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + strings.Join(st.HardCheck.IntentUnmet, ";"), + string(entriesJSON), + ), + jsonContractForReact, + ) + raw, err := callModelText(ctx, chatModel, repairPrompt, userPrompt, false, 240, 0.15) + if err != nil { + return err + } + emitModelRawDebug(emitStage, "repair", raw) + parsed, parseErr := parseReactLLMOutput(raw) + if parseErr != nil { + return parseErr + } + call, warn := pickSingleToolCall(parsed.ToolCalls) + if warn != "" { + st.ActionLogs = append(st.ActionLogs, "修复阶段告警:"+warn) + } + if call == nil { + return fmt.Errorf("修复阶段未给出可执行动作") + } + normalizedCall := canonicalizeToolCall(*call) + call = &normalizedCall + if !isMutatingToolName(strings.TrimSpace(call.Tool)) { + return fmt.Errorf("修复阶段工具不允许:%s(仅允许 Move/Swap/BatchMove)", strings.TrimSpace(call.Tool)) + } + emitStage("schedule_refine.hard_check.repair_call", formatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed)) + nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), refineToolPolicy{ + KeepRelativeOrder: false, + OrderScope: st.Contract.OrderScope, + OriginOrderMap: st.OriginOrderMap, + }) + result = normalizeToolResult(result) + st.RoundUsed++ + emitStage("schedule_refine.hard_check.repair_result", formatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds)) + if !result.Success { + st.LastFailedCallSignature = buildToolCallSignature(*call) + return fmt.Errorf("修复动作执行失败:%s", result.Result) + } + st.LastFailedCallSignature = "" + st.HybridEntries = nextEntries + if isMutatingToolName(result.Tool) { + st.EntriesVersion++ + } + return nil +} + +func runSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*reviewOutput, error) { + entriesJSON, _ := json.Marshal(st.HybridEntries) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\nday_of_week映射=1周一,2周二,3周三,4周四,5周五,6周六,7周日\nsuggested简表=%s\n动作日志=%s\n当前混合日程JSON=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + buildSuggestedDigest(st.HybridEntries, 80), + summarizeActionLogs(st.ActionLogs, 12), + string(entriesJSON), + ), + jsonContractForReview, + ) + raw, err := callModelText(ctx, chatModel, reviewPrompt, userPrompt, false, 240, 0) + if err != nil { + return nil, err + } + emitModelRawDebug(emitStage, "review", raw) + return parseReviewOutput(raw) +} + +func runPostReflectAfterTool( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + round int, + plan *reactLLMOutput, + call *reactToolCall, + result reactToolResult, + emitStage func(stage, detail string), +) (string, string, bool) { + if st == nil || chatModel == nil || call == nil { + return buildPostReflectFallback(plan, result), "", false + } + emitStage("schedule_refine.react.post_reflect.start", fmt.Sprintf("第 %d 轮|正在基于工具真实结果进行反思。", round)) + contractJSON, _ := json.Marshal(st.Contract) + callJSON, _ := json.Marshal(call) + resultJSON, _ := json.Marshal(result) + planDecision := "" + if plan != nil { + planDecision = strings.TrimSpace(plan.Decision) + } + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + planDecision, + string(callJSON), + string(resultJSON), + buildObservationPrompt(st.ObservationHistory, 2), + ), + jsonContractForPostReflect, + ) + raw, err := callModelText(ctx, chatModel, postReflectPrompt, userPrompt, false, 220, 0) + if err != nil { + fallback := buildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思失败,改用后端兜底复盘:%s", round, truncate(err.Error(), 160))) + return fallback, "", false + } + emitModelRawDebug(emitStage, fmt.Sprintf("post_reflect.round.%d", round), raw) + parsed, parseErr := parseJSON[postReflectOutput](raw) + if parseErr != nil { + fallback := buildPostReflectFallback(plan, result) + emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思解析失败,改用后端兜底复盘:%s", round, truncate(parseErr.Error(), 160))) + return fallback, "", false + } + reflection := strings.TrimSpace(parsed.Reflection) + if reflection == "" { + reflection = buildPostReflectFallback(plan, result) + } + nextStrategy := strings.TrimSpace(parsed.NextStrategy) + if nextStrategy != "" { + reflection = fmt.Sprintf("%s;下一步建议:%s", reflection, nextStrategy) + } + shouldStop := parsed.ShouldStop + emitStage("schedule_refine.react.post_reflect.done", fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, truncate(strings.TrimSpace(parsed.Reflection), 120), truncate(nextStrategy, 120), shouldStop)) + return reflection, nextStrategy, shouldStop +} + +func buildPostReflectFallback(plan *reactLLMOutput, result reactToolResult) string { + modelReflect := "" + if plan != nil { + modelReflect = strings.TrimSpace(plan.Decision) + } + return buildRuntimeReflect(modelReflect, result) +} + +func runPlannerNode( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), + mode string, +) error { + if st == nil || chatModel == nil { + return fmt.Errorf("planner: invalid input") + } + ensureCompositeStateMaps(st) + // 1. 正常模式下由后端判定“本轮必用复合工具”。 + // 2. 若已进入禁复合兜底模式,必须清空该标记,避免规划阶段再次把复合门禁写回去。 + if st.DisableCompositeTools { + st.RequiredCompositeTool = "" + } else { + st.RequiredCompositeTool = detectRequiredCompositeTool(st) + } + if st.PlanUsed >= st.PlanMax { + return nil + } + stage := "schedule_refine.plan.generating" + if strings.TrimSpace(mode) == "replan" { + stage = "schedule_refine.plan.regenerating" + } + emitStage(stage, fmt.Sprintf("正在生成执行计划(mode=%s,已用%d/%d)。", mode, st.PlanUsed, st.PlanMax)) + contractJSON, _ := json.Marshal(st.Contract) + userPrompt := withNearestJSONContract( + fmt.Sprintf( + "mode=%s\n用户请求=%s\n契约=%s\n上一轮工具观察=%s\n最近观察=%s\nsuggested简表=%s", + mode, + strings.TrimSpace(st.UserMessage), + string(contractJSON), + buildLastToolObservationPrompt(st.ObservationHistory), + buildObservationPrompt(st.ObservationHistory, 2), + buildSuggestedDigest(st.HybridEntries, 40), + ), + jsonContractForPlanner, + ) + raw, err := callModelText(ctx, chatModel, plannerPrompt, userPrompt, false, plannerMaxTokens, 0) + if err != nil { + st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) + st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) + st.PlanUsed++ + emitStage("schedule_refine.plan.fallback", "Planner 调用失败,已切换后端兜底计划。") + return nil + } + emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw) + parsed, parseErr := parsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage) + if parseErr != nil { + st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st)) + st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) + st.PlanUsed++ + emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败,已切换后端兜底计划:%s", truncate(parseErr.Error(), 180))) + return nil + } + st.CurrentPlan = PlannerPlan{ + Summary: fallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"), + Steps: uniqueNonEmpty(parsed.Steps), + } + st.CurrentPlan = applyCompositeHardConditionToPlan(st, st.CurrentPlan) + st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan) + if st.DisableCompositeTools { + st.BatchMoveAllowed = false + } + st.PlanUsed++ + emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成:%s", truncate(st.CurrentPlan.Summary, 180))) + return nil +} + +func handleBlockedToolResult( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), + round int, + parsed *reactLLMOutput, + call *reactToolCall, + callSignature string, + blockedResult reactToolResult, + observation *ReactRoundObservation, +) (bool, error) { + result := normalizeToolResult(blockedResult) + st.RoundUsed++ + st.LastFailedCallSignature = callSignature + st.ConsecutiveFailures++ + observation.ToolName = strings.TrimSpace(result.Tool) + observation.ToolParams = cloneToolParams(call.Params) + observation.ToolSuccess = result.Success + observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode) + observation.ToolResult = strings.TrimSpace(result.Result) + postReflectText, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage) + observation.Reflect = postReflectText + st.ObservationHistory = append(st.ObservationHistory, *observation) + emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|动作被后端策略拦截:%s", round, truncate(result.Result, 120))) + emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds)) + emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect)) + if shouldTriggerReplan(st, result) { + if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil { + return false, err + } else if replanned { + return false, nil + } + } + return shouldStop, nil +} + +func buildFallbackPlan(st *ScheduleRefineState) PlannerPlan { + summary := "兜底计划:先取证再动作,优先复合工具,其次 Move,冲突时尝试 Swap。" + if st != nil && st.Contract.KeepRelativeOrder { + summary = "兜底计划:先取证再动作,严格保持相对顺序,优先复合工具,其次 Move,冲突时尝试 Swap。" + } + return PlannerPlan{ + Summary: summary, + Steps: []string{ + "1) QueryTargetTasks 定位目标任务", + "2) QueryAvailableSlots 获取可用空位", + "3) 优先 SpreadEven/MinContextSwitch,其次 Move/Swap 执行动作并复盘", + "4) 收尾前执行 Verify 自检", + }, + } +} + +// ensureCompositeStateMaps 确保复合工具状态容器已初始化。 +func ensureCompositeStateMaps(st *ScheduleRefineState) { + if st == nil { + return + } + if st.CompositeToolCalled == nil { + st.CompositeToolCalled = map[string]bool{ + "SpreadEven": false, + "MinContextSwitch": false, + } + } + if st.CompositeToolSuccess == nil { + st.CompositeToolSuccess = map[string]bool{ + "SpreadEven": false, + "MinContextSwitch": false, + } + } +} + +// detectRequiredCompositeTool 根据请求语义识别本轮必用复合工具。 +// +// 规则: +// 1. “上下文切换最少/同科目连续”优先映射 MinContextSwitch; +// 2. “均匀分散/铺开”映射 SpreadEven; +// 3. 未命中时返回空串,不强制复合工具。 +func detectRequiredCompositeTool(st *ScheduleRefineState) string { + if st == nil { + return "" + } + joined := strings.TrimSpace(strings.Join([]string{ + st.UserMessage, + st.Contract.Intent, + strings.Join(st.Contract.HardRequirements, " "), + }, " ")) + if joined == "" { + return "" + } + contextKeys := []string{"上下文切换", "切换最少", "同个科目", "同科目", "连续处理", "连续学习", "min context", "context switch"} + if containsAny(strings.ToLower(joined), contextKeys) || containsAny(joined, contextKeys) { + return "MinContextSwitch" + } + evenKeys := []string{"均匀", "分散", "铺开", "平摊", "均摊", "spread even", "even spread"} + if containsAny(strings.ToLower(joined), evenKeys) || containsAny(joined, evenKeys) { + return "SpreadEven" + } + return "" +} + +// applyCompositeHardConditionToPlan 把“必用复合工具”硬条件注入计划文本。 +func applyCompositeHardConditionToPlan(st *ScheduleRefineState, plan PlannerPlan) PlannerPlan { + required := "" + if st != nil { + required = normalizeCompositeToolName(st.RequiredCompositeTool) + } + if required == "" { + return plan + } + + hardStep := fmt.Sprintf("硬条件:必须成功调用 %s(COMPOSITE_SUCCESS[%s]=true)后才允许整体收口", required, required) + hasHardStep := false + for _, step := range plan.Steps { + if strings.Contains(step, required) && strings.Contains(step, "COMPOSITE_SUCCESS") { + hasHardStep = true + break + } + } + if !hasHardStep { + plan.Steps = append([]string{hardStep}, plan.Steps...) + } + if !strings.Contains(plan.Summary, required) { + plan.Summary = strings.TrimSpace(plan.Summary + ";硬条件:" + required + " 成功==true") + } + return plan +} + +func normalizeCompositeToolName(name string) string { + switch strings.TrimSpace(name) { + case "SpreadEven": + return "SpreadEven" + case "MinContextSwitch": + return "MinContextSwitch" + default: + return "" + } +} + +func isCompositeToolName(toolName string) bool { + switch normalizeCompositeToolName(toolName) { + case "SpreadEven", "MinContextSwitch": + return true + default: + return false + } +} + +func isBaseMutatingToolName(toolName string) bool { + switch strings.TrimSpace(toolName) { + case "Move", "Swap", "BatchMove": + return true + default: + return false + } +} + +func isRequiredCompositeSatisfied(st *ScheduleRefineState) bool { + if st == nil { + return true + } + required := normalizeCompositeToolName(st.RequiredCompositeTool) + if required == "" { + return true + } + ensureCompositeStateMaps(st) + return st.CompositeToolSuccess[required] +} + +// applyCompositeGateToIntentResult 把“必用复合工具成功”并入业务目标判定。 +// +// 步骤化说明: +// 1. 先判断原始业务判定是否通过;未通过则原样返回; +// 2. 再判断是否配置了必用复合工具;未配置则原样返回; +// 3. 若配置但未成功,强制改判为失败并补充 unmet 原因。 +func applyCompositeGateToIntentResult(st *ScheduleRefineState, pass bool, reason string, unmet []string) (bool, string, []string) { + if !pass { + return pass, reason, append([]string(nil), unmet...) + } + required := normalizeCompositeToolName("") + if st != nil { + required = normalizeCompositeToolName(st.RequiredCompositeTool) + } + if required == "" { + return pass, reason, append([]string(nil), unmet...) + } + if isRequiredCompositeSatisfied(st) { + return pass, reason, append([]string(nil), unmet...) + } + newUnmet := append([]string(nil), unmet...) + newUnmet = append(newUnmet, fmt.Sprintf("复合工具门禁未通过:%s 尚未成功调用", required)) + return false, fmt.Sprintf("复合工具门禁未通过:要求 %s 成功==true。", required), newUnmet +} + +func markCompositeToolOutcome(st *ScheduleRefineState, toolName string, success bool) { + if st == nil { + return + } + tool := normalizeCompositeToolName(toolName) + if tool == "" { + return + } + ensureCompositeStateMaps(st) + st.CompositeToolCalled[tool] = true + if success { + st.CompositeToolSuccess[tool] = true + } +} + +func shouldAllowBatchMove(plan PlannerPlan) bool { + text := strings.ToLower(strings.TrimSpace(plan.Summary)) + if strings.Contains(text, "batchmove") || strings.Contains(text, "batch move") { + return true + } + for _, step := range plan.Steps { + s := strings.ToLower(strings.TrimSpace(step)) + if strings.Contains(s, "batchmove") || strings.Contains(s, "batch move") { + return true + } + } + return false +} + +func shouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) { + if st == nil { + return false, "" + } + if st.ConsecutiveFailures < 2 || st.ThinkingBoostArmed { + return false, "" + } + st.ThinkingBoostArmed = true + return true, fmt.Sprintf("连续失败=%d,触发 1 轮恢复态 thinking", st.ConsecutiveFailures) +} + +func shouldTriggerReplan(st *ScheduleRefineState, result reactToolResult) bool { + if st == nil { + return false + } + if st.ConsecutiveFailures < 3 { + return false + } + switch strings.TrimSpace(result.ErrorCode) { + case "SLOT_CONFLICT", "ORDER_VIOLATION", "REPEAT_FAILED_ACTION", "PARAM_MISSING", "BATCH_MOVE_FAILED", "VERIFY_FAILED", "TASK_BUDGET_EXCEEDED", "BATCH_MOVE_DISABLED", "CURRENT_TASK_MISMATCH", "QUERY_REDUNDANT", "SLOT_QUERY_FAILED", "PLAN_FAILED", "PLAN_EMPTY", "COMPOSITE_REQUIRED", "COMPOSITE_DISABLED": + return true + default: + return false + } +} + +func tryReplan( + ctx context.Context, + chatModel *ark.ChatModel, + st *ScheduleRefineState, + emitStage func(stage, detail string), +) (bool, error) { + if st == nil { + return false, nil + } + if st.ReplanUsed >= st.ReplanMax || st.PlanUsed >= st.PlanMax { + return false, nil + } + st.ReplanUsed++ + emitStage("schedule_refine.plan.replan_trigger", fmt.Sprintf("连续失败=%d,触发重规划(%d/%d)。", st.ConsecutiveFailures, st.ReplanUsed, st.ReplanMax)) + if err := runPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil { + return true, err + } + st.ConsecutiveFailures = 0 + st.ThinkingBoostArmed = false + return true, nil +} + +func callModelText( + ctx context.Context, + chatModel *ark.ChatModel, + systemPrompt string, + userPrompt string, + useThinking bool, + maxTokens int, + temperature float32, +) (string, error) { + if chatModel == nil { + return "", fmt.Errorf("model is nil") + } + nodeCtx, cancel := context.WithTimeout(ctx, nodeTimeout) + defer cancel() + thinkingType := arkModel.ThinkingTypeDisabled + if useThinking { + thinkingType = arkModel.ThinkingTypeEnabled + } + opts := []einoModel.Option{ + ark.WithThinking(&arkModel.Thinking{Type: thinkingType}), + einoModel.WithTemperature(temperature), + } + if maxTokens > 0 { + opts = append(opts, einoModel.WithMaxTokens(maxTokens)) + } + resp, err := chatModel.Generate(nodeCtx, []*schema.Message{ + schema.SystemMessage(systemPrompt), + schema.UserMessage(userPrompt), + }, opts...) + if err != nil { + if errors.Is(nodeCtx.Err(), context.DeadlineExceeded) { + return "", fmt.Errorf("model call node timeout(%dms): %w", nodeTimeout.Milliseconds(), err) + } + if nodeCtx.Err() != nil { + return "", fmt.Errorf("model call node canceled(%v): %w", nodeCtx.Err(), err) + } + if ctx.Err() != nil { + return "", fmt.Errorf("model call parent canceled(%v): %w", ctx.Err(), err) + } + return "", err + } + if resp == nil { + return "", fmt.Errorf("model response is nil") + } + content := strings.TrimSpace(resp.Content) + if content == "" { + return "", fmt.Errorf("model response content is empty") + } + return content, nil +} + +func parseJSON[T any](raw string) (*T, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("empty response") + } + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + var out T + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, err := extractFirstJSONObject(clean) + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +func extractFirstJSONObject(text string) (string, error) { + start := strings.Index(text, "{") + if start < 0 { + return "", fmt.Errorf("no json object found") + } + depth := 0 + inString := false + escape := false + for i := start; i < len(text); i++ { + ch := text[i] + if inString { + if escape { + escape = false + continue + } + if ch == '\\' { + escape = true + continue + } + if ch == '"' { + inString = false + } + continue + } + if ch == '"' { + inString = true + continue + } + if ch == '{' { + depth++ + continue + } + if ch == '}' { + depth-- + if depth == 0 { + return text[start : i+1], nil + } + } + } + return "", fmt.Errorf("json object not closed") +} + +func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) { + if emitStage == nil { + return + } + clean := strings.TrimSpace(raw) + if clean == "" { + clean = "" + } + const chunkSize = 1600 + runes := []rune(clean) + if len(runes) <= chunkSize { + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(tag), clean)) + return + } + total := (len(runes) + chunkSize - 1) / chunkSize + for i := 0; i < total; i++ { + start := i * chunkSize + end := start + chunkSize + if end > len(runes) { + end = len(runes) + } + emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end]))) + } +} + +func physicsCheck(entries []model.HybridScheduleEntry, allocatedCount int) []string { + issues := make([]string, 0, 8) + slotMap := make(map[string]string, len(entries)*2) + for _, entry := range entries { + if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo { + issues = append(issues, fmt.Sprintf("节次越界:%s W%dD%d %d-%d", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)) + } + if !entryBlocksSuggested(entry) { + continue + } + for sec := entry.SectionFrom; sec <= entry.SectionTo; sec++ { + key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, sec) + if existed, ok := slotMap[key]; ok { + issues = append(issues, fmt.Sprintf("冲突:%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec)) + } else { + slotMap[key] = entry.Name + } + } + } + if allocatedCount > 0 { + suggested := countSuggested(entries) + if suggested != allocatedCount { + issues = append(issues, fmt.Sprintf("数量不一致:suggested=%d,allocated_items=%d", suggested, allocatedCount)) + } + } + return issues +} + +func updateAllocatedItemsFromEntries(st *ScheduleRefineState) { + if st == nil || len(st.AllocatedItems) == 0 || len(st.HybridEntries) == 0 { + return + } + byTaskID := make(map[int]model.HybridScheduleEntry, len(st.HybridEntries)) + for _, entry := range st.HybridEntries { + if isMovableSuggestedTask(entry) { + byTaskID[entry.TaskItemID] = entry + } + } + for i := range st.AllocatedItems { + item := &st.AllocatedItems[i] + entry, ok := byTaskID[item.ID] + if !ok { + continue + } + if item.EmbeddedTime == nil { + item.EmbeddedTime = &model.TargetTime{} + } + item.EmbeddedTime.Week = entry.Week + item.EmbeddedTime.DayOfWeek = entry.DayOfWeek + item.EmbeddedTime.SectionFrom = entry.SectionFrom + item.EmbeddedTime.SectionTo = entry.SectionTo + } +} + +func countSuggested(entries []model.HybridScheduleEntry) int { + count := 0 + for _, entry := range entries { + if isMovableSuggestedTask(entry) { + count++ + } + } + return count +} + +func summarizeActionLogs(logs []string, tail int) string { + if len(logs) == 0 { + return "无" + } + if tail <= 0 || len(logs) <= tail { + return strings.Join(logs, "\n") + } + return strings.Join(logs[len(logs)-tail:], "\n") +} + +func fallbackText(text string, fallback string) string { + clean := strings.TrimSpace(text) + if clean == "" { + return fallback + } + return clean +} + +func withNearestJSONContract(userPrompt string, jsonContract string) string { + base := strings.TrimSpace(userPrompt) + rule := strings.TrimSpace(jsonContract) + if rule == "" { + return base + } + if base == "" { + return rule + } + return base + "\n\n" + rule +} + +// alignSummaryWithHardCheck 对齐总结文案与硬校验事实,避免“通过/失败”口径冲突。 +// +// 步骤化说明: +// 1. 先以 hard_check 最终结果作为唯一真值; +// 2. pass=true 且 round_used=0 时,强制输出“未执行动作但已满足”的口径; +// 3. pass=true 但文案含失败词,或 pass=false 但文案含通过词,统一纠偏。 +func alignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string { + clean := strings.TrimSpace(summary) + if st == nil { + return clean + } + passed := FinalHardCheckPassed(st) + if passed { + if st.RoundUsed == 0 { + return "本轮未执行调度动作(0轮),当前排程已满足终审条件。" + } + if clean == "" || containsAny(clean, []string{"未完全", "未达标", "未能", "差距", "失败", "未通过"}) { + return fmt.Sprintf("微调已完成,共执行 %d 轮动作,方案已通过终审。", st.RoundUsed) + } + return clean + } + + if clean == "" || containsAny(clean, []string{"终审通过", "已通过终审", "完全达成", "全部满足"}) { + return fmt.Sprintf("已完成微调并返回当前最优结果(执行 %d 轮动作)。终审仍有未满足项:%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标")) + } + return clean +} + +func formatReactPlanStageDetail(round int, out *reactLLMOutput, remaining int, useThinking bool) string { + if out == nil { + return fmt.Sprintf("第 %d 轮:缺少计划输出。", round) + } + return fmt.Sprintf("第 %d 轮|thinking=%t|动作剩余=%d|goal_check=%s|decision=%s", round, useThinking, remaining, truncate(strings.TrimSpace(out.GoalCheck), 180), truncate(strings.TrimSpace(out.Decision), 180)) +} + +func formatReactNeedInfoStageDetail(round int, missing []string) string { + if len(missing) == 0 { + return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round) + } + return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, strings.Join(uniqueNonEmpty(missing), ";")) +} + +func formatReactReflectStageDetail(round int, reflect string) string { + return fmt.Sprintf("第 %d 轮|复盘=%s", round, truncate(strings.TrimSpace(reflect), 260)) +} + +func formatToolCallStageDetail(round int, call reactToolCall, remaining int) string { + paramsText := "{}" + if len(call.Params) > 0 { + if raw, err := json.Marshal(call.Params); err == nil { + paramsText = string(raw) + } + } + return fmt.Sprintf("第 %d 轮|调用工具=%s|参数=%s|调用前剩余轮次=%d", round, strings.TrimSpace(call.Tool), truncate(paramsText, 320), remaining) +} + +func formatToolResultStageDetail(round int, result reactToolResult, used int, total int) string { + errorCode := strings.TrimSpace(result.ErrorCode) + if !result.Success && errorCode == "" { + errorCode = "TOOL_EXEC_FAILED" + } + if errorCode == "" { + errorCode = "NONE" + } + return fmt.Sprintf("第 %d 轮|工具=%s|success=%t|error_code=%s|结果=%s|轮次进度=%d/%d", round, strings.TrimSpace(result.Tool), result.Success, errorCode, truncate(strings.TrimSpace(result.Result), 320), used, total) +} + +func condenseSummary(plans []model.UserWeekSchedule) string { + if len(plans) == 0 { + return "无历史排程摘要" + } + totalEvents := 0 + startWeek := plans[0].Week + endWeek := plans[0].Week + for _, week := range plans { + totalEvents += len(week.Events) + if week.Week < startWeek { + startWeek = week.Week + } + if week.Week > endWeek { + endWeek = week.Week + } + } + return fmt.Sprintf("共 %d 周,周次范围 W%d~W%d,事件总数 %d。", len(plans), startWeek, endWeek, totalEvents) +} + +func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule { + sectionTimeMap := map[int][2]string{ + 1: {"08:00", "08:45"}, 2: {"08:55", "09:40"}, + 3: {"10:15", "11:00"}, 4: {"11:10", "11:55"}, + 5: {"14:00", "14:45"}, 6: {"14:55", "15:40"}, + 7: {"16:15", "17:00"}, 8: {"17:10", "17:55"}, + 9: {"19:00", "19:45"}, 10: {"19:55", "20:40"}, + 11: {"20:50", "21:35"}, 12: {"21:45", "22:30"}, + } + weekMap := make(map[int][]model.WeeklyEventBrief) + for _, entry := range entries { + start, end := "", "" + if val, ok := sectionTimeMap[entry.SectionFrom]; ok { + start = val[0] + } + if val, ok := sectionTimeMap[entry.SectionTo]; ok { + end = val[1] + } + weekMap[entry.Week] = append(weekMap[entry.Week], model.WeeklyEventBrief{ + ID: entry.EventID, + DayOfWeek: entry.DayOfWeek, + Name: entry.Name, + StartTime: start, + EndTime: end, + Type: entry.Type, + Span: entry.SectionTo - entry.SectionFrom + 1, + Status: entry.Status, + }) + } + result := make([]model.UserWeekSchedule, 0, len(weekMap)) + for week, events := range weekMap { + result = append(result, model.UserWeekSchedule{Week: week, Events: events}) + } + sort.SliceStable(result, func(i, j int) bool { return result[i].Week < result[j].Week }) + return result +} + +func buildFallbackContract(st *ScheduleRefineState) RefineContract { + intent := strings.TrimSpace(st.UserMessage) + keepOrder := detectOrderIntent(st.UserMessage) + reqs := append([]string(nil), st.Constraints...) + if keepOrder { + reqs = append(reqs, "保持任务原始相对顺序不变") + } + assertions := inferHardAssertionsFromRequest(st.UserMessage, reqs) + return RefineContract{ + Intent: intent, + Strategy: "local_adjust", + HardRequirements: uniqueNonEmpty(reqs), + HardAssertions: assertions, + KeepRelativeOrder: keepOrder, + OrderScope: "global", + } +} + +func normalizeStrategy(strategy string) string { + switch strings.TrimSpace(strings.ToLower(strategy)) { + case "keep": + return "keep" + default: + return "local_adjust" + } +} + +func detectOrderIntent(userMessage string) bool { + msg := strings.TrimSpace(userMessage) + if msg == "" { + return true + } + // 1. 默认启用顺序约束,除非用户明确授权可打乱顺序。 + // 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。 + for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} { + if strings.Contains(msg, k) { + return false + } + } + return true +} + +func uniqueNonEmpty(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + out = append(out, clean) + } + return out +} + +func buildObservationPrompt(history []ReactRoundObservation, tail int) string { + if len(history) == 0 { + return "无" + } + start := 0 + if tail > 0 && len(history) > tail { + start = len(history) - tail + } + raw, err := json.Marshal(history[start:]) + if err != nil { + return err.Error() + } + return string(raw) +} + +func buildLastToolObservationPrompt(history []ReactRoundObservation) string { + for i := len(history) - 1; i >= 0; i-- { + item := history[i] + if strings.TrimSpace(item.ToolName) == "" { + continue + } + raw, err := json.Marshal(item) + if err != nil { + return "无" + } + return string(raw) + } + return "无" +} + +func buildToolCallSignature(call reactToolCall) string { + paramsText := "{}" + if len(call.Params) > 0 { + if raw, err := json.Marshal(call.Params); err == nil { + paramsText = string(raw) + } + } + return fmt.Sprintf("%s|%s", strings.ToUpper(strings.TrimSpace(call.Tool)), paramsText) +} + +func buildSlotQuerySignature(st *ScheduleRefineState, params map[string]any) string { + normalized := canonicalizeToolCall(reactToolCall{Tool: "QueryAvailableSlots", Params: params}) + raw, _ := json.Marshal(normalized.Params) + version := 0 + if st != nil { + version = st.EntriesVersion + } + return fmt.Sprintf("v=%d|%s", version, string(raw)) +} + +func canonicalizeToolCall(call reactToolCall) reactToolCall { + canonical := reactToolCall{ + Tool: strings.TrimSpace(call.Tool), + Params: cloneToolParams(call.Params), + } + switch canonical.Tool { + case "Move": + canonical.Params = canonicalizeMoveParams(canonical.Params) + case "BatchMove": + canonical.Params = canonicalizeBatchMoveParams(canonical.Params) + case "SpreadEven", "MinContextSwitch": + canonical.Params = canonicalizeCompositeMoveParams(canonical.Params) + case "QueryAvailableSlots": + canonical.Params = canonicalizeSlotQueryParams(canonical.Params) + } + return canonical +} + +func canonicalizeMoveParams(params map[string]any) map[string]any { + out := cloneToolParams(params) + setCanonicalInt(out, "task_item_id", out, "task_item_id", "task_id") + setCanonicalInt(out, "to_week", out, "to_week", "target_week", "new_week", "week") + setCanonicalInt(out, "to_day", out, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") + setCanonicalInt(out, "to_section_from", out, "to_section_from", "target_section_from", "new_section_from", "section_from") + setCanonicalInt(out, "to_section_to", out, "to_section_to", "target_section_to", "new_section_to", "section_to") + return out +} + +func canonicalizeBatchMoveParams(params map[string]any) map[string]any { + out := cloneToolParams(params) + rawMoves, ok := out["moves"] + if !ok { + return out + } + moves, ok := rawMoves.([]any) + if !ok { + return out + } + normalized := make([]any, 0, len(moves)) + for _, item := range moves { + moveMap, ok := item.(map[string]any) + if !ok { + continue + } + normalized = append(normalized, canonicalizeMoveParams(moveMap)) + } + out["moves"] = normalized + return out +} + +func canonicalizeCompositeMoveParams(params map[string]any) map[string]any { + out := cloneToolParams(params) + ids := readIntSlice(out, "task_item_ids", "task_ids") + if taskID, ok := paramIntAny(out, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + if len(ids) > 0 { + out["task_item_ids"] = uniquePositiveInts(ids) + } + + setCanonicalInt(out, "week", out, "week", "to_week", "target_week", "new_week") + if day, ok := paramIntAny(out, "day_of_week", "to_day", "target_day_of_week", "target_day", "new_day", "day"); ok { + out["day_of_week"] = []int{day} + } + if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { + out["week_filter"] = uniquePositiveInts(weeks) + } + if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { + out["day_of_week"] = uniquePositiveInts(days) + } + if sections := readIntSlice(out, "exclude_sections", "exclude_section"); len(sections) > 0 { + out["exclude_sections"] = uniquePositiveInts(sections) + } + return out +} + +func canonicalizeSlotQueryParams(params map[string]any) map[string]any { + out := cloneToolParams(params) + setCanonicalInt(out, "week", out, "week") + if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 { + out["week_filter"] = uniquePositiveInts(weeks) + } + if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 { + out["day_filter"] = uniquePositiveInts(days) + } + setCanonicalInt(out, "section_duration", out, "section_duration", "span", "task_duration") + setCanonicalInt(out, "section_from", out, "section_from", "target_section_from") + setCanonicalInt(out, "section_to", out, "section_to", "target_section_to") + setCanonicalInt(out, "limit", out, "limit") + return out +} + +func setCanonicalInt(dst map[string]any, dstKey string, src map[string]any, keys ...string) { + if dst == nil || src == nil { + return + } + if value, ok := paramIntAny(src, keys...); ok { + dst[dstKey] = value + } +} + +func listTaskIDsFromToolCall(call reactToolCall) []int { + switch strings.TrimSpace(call.Tool) { + case "Move": + taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id") + if !ok { + return nil + } + return []int{taskID} + case "Swap": + taskA, okA := paramIntAny(call.Params, "task_a", "task_item_a", "task_item_id_a") + taskB, okB := paramIntAny(call.Params, "task_b", "task_item_b", "task_item_id_b") + return uniquePositiveInts([]int{taskA, taskB}, okA, okB) + case "BatchMove": + rawMoves, ok := call.Params["moves"] + if !ok { + return nil + } + moves, ok := rawMoves.([]any) + if !ok { + return nil + } + ids := make([]int, 0, len(moves)) + for _, item := range moves { + moveMap, ok := item.(map[string]any) + if !ok { + continue + } + if taskID, ok := paramIntAny(moveMap, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + } + return uniquePositiveInts(ids) + case "SpreadEven", "MinContextSwitch": + ids := readIntSlice(call.Params, "task_item_ids", "task_ids") + if taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id"); ok { + ids = append(ids, taskID) + } + return uniquePositiveInts(ids) + default: + return nil + } +} + +func precheckCurrentTaskOwnership(call reactToolCall, taskIDs []int, currentTaskID int) (reactToolResult, bool) { + if currentTaskID <= 0 { + return reactToolResult{}, false + } + if !isMutatingToolName(strings.TrimSpace(call.Tool)) { + return reactToolResult{}, false + } + for _, id := range taskIDs { + if id == currentTaskID { + return reactToolResult{}, false + } + } + return reactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + ErrorCode: "CURRENT_TASK_MISMATCH", + Result: fmt.Sprintf("当前微循环任务为 id=%d,本轮改写动作未包含该任务,请改为围绕当前任务执行。", currentTaskID), + }, true +} + +func precheckToolCallPolicy(st *ScheduleRefineState, call reactToolCall, taskIDs []int) (reactToolResult, bool) { + if st == nil { + return reactToolResult{}, false + } + toolName := strings.TrimSpace(call.Tool) + if st.DisableCompositeTools && isCompositeToolName(toolName) { + return reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "COMPOSITE_DISABLED", + Result: "当前已进入 ReAct 兜底模式,禁止调用复合工具,请使用 Move/Swap 逐步处理。", + }, true + } + if st.DisableCompositeTools && toolName == "BatchMove" { + return reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "BATCH_MOVE_DISABLED", + Result: "当前兜底模式要求逐任务挪动,禁止使用 BatchMove。", + }, true + } + if toolName == "BatchMove" && !st.BatchMoveAllowed { + return reactToolResult{Tool: toolName, Success: false, ErrorCode: "BATCH_MOVE_DISABLED", Result: "当前计划未显式允许 BatchMove,请改用单步 Move/Swap。"}, true + } + if toolName == "QueryAvailableSlots" { + if st.SeenSlotQueries == nil { + st.SeenSlotQueries = make(map[string]struct{}) + } + signature := buildSlotQuerySignature(st, call.Params) + if _, exists := st.SeenSlotQueries[signature]; exists { + return reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "QUERY_REDUNDANT", + Result: "同版本排程下重复查询同一空位范围,已拒绝;请直接基于 ENV_SLOT_HINT 选择落点。", + }, true + } + st.SeenSlotQueries[signature] = struct{}{} + return reactToolResult{}, false + } + // 1. 当计划声明“必用复合工具”且尚未成功时,先锁住基础写工具。 + // 2. 这样可避免模型绕开复合工具直接 Move,导致“命中率低 + 语义漂移”。 + requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool) + if requiredComposite != "" && !isRequiredCompositeSatisfied(st) && isMutatingToolName(toolName) { + if toolName != requiredComposite { + return reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "COMPOSITE_REQUIRED", + Result: fmt.Sprintf("当前计划要求先成功调用 %s;在其成功前禁止使用 %s。", requiredComposite, toolName), + }, true + } + } + if !isMutatingToolName(toolName) { + return reactToolResult{}, false + } + if st.PerTaskBudget <= 0 || len(taskIDs) == 0 { + return reactToolResult{}, false + } + for _, taskID := range taskIDs { + if st.TaskActionUsed[taskID] >= st.PerTaskBudget { + return reactToolResult{Tool: toolName, Success: false, ErrorCode: "TASK_BUDGET_EXCEEDED", Result: fmt.Sprintf("任务 id=%d 已达到单任务动作预算上限=%d,请重规划或更换目标任务。", taskID, st.PerTaskBudget)}, true + } + } + return reactToolResult{}, false +} + +func isMutatingToolName(toolName string) bool { + switch strings.TrimSpace(toolName) { + case "Move", "Swap", "BatchMove", "SpreadEven", "MinContextSwitch": + return true + default: + return false + } +} + +func uniquePositiveInts(ids []int, oks ...bool) []int { + allowAll := len(oks) == 0 + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for i, id := range ids { + if !allowAll { + if i >= len(oks) || !oks[i] { + continue + } + } + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func isRepeatedFailedCall(st *ScheduleRefineState, signature string) bool { + if st == nil { + return false + } + current := strings.TrimSpace(signature) + last := strings.TrimSpace(st.LastFailedCallSignature) + return current != "" && last != "" && current == last +} + +func normalizeToolResult(result reactToolResult) reactToolResult { + if result.Success { + return result + } + if strings.TrimSpace(result.ErrorCode) != "" { + return result + } + result.ErrorCode = classifyToolFailureCode(result.Result) + return result +} + +func classifyToolFailureCode(detail string) string { + text := strings.TrimSpace(detail) + switch { + case strings.Contains(text, "单任务动作预算上限"): + return "TASK_BUDGET_EXCEEDED" + case strings.Contains(text, "未显式允许 BatchMove"): + return "BATCH_MOVE_DISABLED" + case strings.Contains(text, "重复失败动作"): + return "REPEAT_FAILED_ACTION" + case strings.Contains(text, "顺序约束不满足"): + return "ORDER_VIOLATION" + case strings.Contains(text, "参数缺失"): + return "PARAM_MISSING" + case strings.Contains(text, "目标时段已被"): + return "SLOT_CONFLICT" + case strings.Contains(text, "无法唯一定位"): + return "TASK_ID_AMBIGUOUS" + case strings.Contains(text, "任务跨度不一致"): + return "SPAN_MISMATCH" + case strings.Contains(text, "超出允许窗口"): + return "OUT_OF_WINDOW" + case strings.Contains(text, "day_of_week"): + return "DAY_INVALID" + case strings.Contains(text, "节次区间"): + return "SECTION_INVALID" + case strings.Contains(text, "未找到 task_item_id"): + return "TASK_NOT_FOUND" + case strings.Contains(text, "不支持的工具"): + return "TOOL_NOT_ALLOWED" + case strings.Contains(text, "BatchMove"): + return "BATCH_MOVE_FAILED" + case strings.Contains(text, "Verify"): + return "VERIFY_FAILED" + case strings.Contains(text, "序列化查询结果失败"), strings.Contains(text, "序列化空位结果失败"): + return "QUERY_ENCODE_FAILED" + default: + return "TOOL_EXEC_FAILED" + } +} + +func cloneToolParams(params map[string]any) map[string]any { + if len(params) == 0 { + return nil + } + raw, err := json.Marshal(params) + if err != nil { + dst := make(map[string]any, len(params)) + for k, v := range params { + dst[k] = v + } + return dst + } + var out map[string]any + if err = json.Unmarshal(raw, &out); err != nil { + dst := make(map[string]any, len(params)) + for k, v := range params { + dst[k] = v + } + return dst + } + return out +} + +func formatRoundModelErrorDetail(round int, err error, parentCtx context.Context) string { + parentState := "alive" + if parentCtx == nil { + parentState = "nil" + } else if parentCtx.Err() != nil { + parentState = parentCtx.Err().Error() + } + parentDeadline := "none" + if parentCtx != nil { + if deadline, ok := parentCtx.Deadline(); ok { + parentDeadline = fmt.Sprintf("%dms", time.Until(deadline).Milliseconds()) + } + } + return fmt.Sprintf("第 %d 轮模型调用失败:%v | parent_ctx=%s | parent_deadline_in_ms=%s | node_timeout_ms=%d", round, err, parentState, parentDeadline, nodeTimeout.Milliseconds()) +} + +func buildRuntimeReflect(modelReflect string, result reactToolResult) string { + modelText := strings.TrimSpace(modelReflect) + resultText := truncate(strings.TrimSpace(result.Result), 220) + if result.Success { + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行成功。%s", resultText) + } + return fmt.Sprintf("后端复盘:工具执行成功。%s。模型预期(动作前):%s", resultText, truncate(modelText, 180)) + } + if modelText == "" { + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。", resultText) + } + return fmt.Sprintf("后端复盘:工具执行失败。%s。本轮调整未生效,已保留原方案。模型预期(动作前,仅供参考):%s", resultText, truncate(modelText, 160)) +} + +func buildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string { + if len(entries) == 0 { + return "无" + } + list := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + if isMovableSuggestedTask(entry) { + list = append(list, entry) + } + } + if len(list) == 0 { + return "无 suggested 条目" + } + sortHybridEntries(list) + if limit <= 0 { + limit = len(list) + } + if len(list) > limit { + list = list[:limit] + } + lines := make([]string, 0, len(list)) + for _, item := range list { + lines = append(lines, fmt.Sprintf("id=%d|W%d|D%d(%s)|%d-%d|%s", item.TaskItemID, item.Week, item.DayOfWeek, weekdayLabel(item.DayOfWeek), item.SectionFrom, item.SectionTo, strings.TrimSpace(item.Name))) + } + return strings.Join(lines, "\n") +} + +func buildSuggestedDigestByWeek(entries []model.HybridScheduleEntry, week int, limit int) string { + if week <= 0 { + return buildSuggestedDigest(entries, limit) + } + filtered := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + if isMovableSuggestedTask(entry) && entry.Week == week { + filtered = append(filtered, entry) + } + } + if len(filtered) == 0 { + return "无同周 suggested 条目" + } + return buildSuggestedDigest(filtered, limit) +} + +func weekdayLabel(day int) string { + switch day { + case 1: + return "周一" + case 2: + return "周二" + case 3: + return "周三" + case 4: + return "周四" + case 5: + return "周五" + case 6: + return "周六" + case 7: + return "周日" + default: + return "未知" + } +} + +func parseReactOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + userPrompt string, + firstRaw string, + round int, + emitStage func(stage, detail string), + st *ScheduleRefineState, +) (*reactLLMOutput, error) { + if st == nil { + return nil, respond.ScheduleRefineOutputParseFailed + } + parsed, parseErr := parseReactLLMOutput(firstRaw) + if parseErr == nil { + return parsed, nil + } + emitStage("schedule_refine.react.parse_retry", fmt.Sprintf("第 %d 轮输出解析失败,准备重试1次:%s", round, truncate(parseErr.Error(), 260))) + retryRaw, retryErr := callModelText(ctx, chatModel, reactPrompt, userPrompt, false, reactMaxTokens, 0) + if retryErr != nil { + emitStage("schedule_refine.react.round_error", formatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx)) + return nil, respond.ScheduleRefineOutputParseFailed + } + emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw) + retryParsed, retryParseErr := parseReactLLMOutput(retryRaw) + if retryParseErr != nil { + emitStage("schedule_refine.react.round_error", fmt.Sprintf("第 %d 轮输出二次解析失败:%s", round, truncate(retryParseErr.Error(), 260))) + return nil, respond.ScheduleRefineOutputParseFailed + } + emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功,继续执行。", round)) + return retryParsed, nil +} + +func parsePlannerOutputWithRetryOnce( + ctx context.Context, + chatModel *ark.ChatModel, + originUserPrompt string, + firstRaw string, + mode string, + emitStage func(stage, detail string), +) (*plannerOutput, error) { + parsed, parseErr := parseJSON[plannerOutput](firstRaw) + if parseErr == nil { + return parsed, nil + } + emitStage("schedule_refine.plan.parse_retry", fmt.Sprintf("Planner 解析失败,准备重试1次(mode=%s):%s", strings.TrimSpace(mode), truncate(parseErr.Error(), 160))) + retryPrompt := withNearestJSONContract( + fmt.Sprintf("%s\n\n上一轮输出解析失败(原因:JSON 不完整或不闭合)。请缩短内容并严格输出完整 JSON。", originUserPrompt), + jsonContractForPlanner, + ) + retryRaw, retryErr := callModelText(ctx, chatModel, plannerPrompt, retryPrompt, false, plannerMaxTokens, 0) + if retryErr != nil { + return nil, retryErr + } + emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s.retry", strings.TrimSpace(mode)), retryRaw) + retryParsed, retryParseErr := parseJSON[plannerOutput](retryRaw) + if retryParseErr != nil { + return nil, retryParseErr + } + emitStage("schedule_refine.plan.parse_retry_success", fmt.Sprintf("Planner 重试解析成功(mode=%s)。", strings.TrimSpace(mode))) + return retryParsed, nil +} + +func buildSlicePlan(st *ScheduleRefineState) RefineSlicePlan { + msg := strings.TrimSpace(st.UserMessage) + lower := strings.ToLower(msg) + plan := RefineSlicePlan{ + WeekFilter: extractWeekFilters(msg), + ExcludeSections: extractExcludeSections(msg), + Reason: "根据用户请求抽取得到执行切片", + } + // 1. 优先解析“从A收敛到B”这类方向型表达,防止把 source/target 反向识别。 + // 2. 例如“周四到周五收敛到周一到周三”应得到 source=[4,5], target=[1,2,3]。 + if src, tgt, ok := extractDirectionalSourceTargetDays(msg); ok { + plan.SourceDays = src + plan.TargetDays = tgt + return plan + } + if strings.Contains(msg, "工作日") || strings.Contains(msg, "周一到周五") || strings.Contains(msg, "周1到周5") { + plan.TargetDays = []int{1, 2, 3, 4, 5} + } else if containsAny(lower, []string{"移到周末", "挪到周末", "安排在周末", "放到周末"}) { + plan.TargetDays = []int{6, 7} + } else if days := extractTargetDaysFromMessage(msg); len(days) > 0 { + plan.TargetDays = days + } + if len(plan.TargetDays) == 5 && isSameDays(plan.TargetDays, []int{1, 2, 3, 4, 5}) && strings.Contains(msg, "周末") { + plan.SourceDays = []int{6, 7} + } + if day := detectOverloadedDay(msg); day > 0 { + plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, day)) + } + if fromDays := extractSourceDaysFromMessage(msg); len(fromDays) > 0 { + plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, fromDays...)) + } + return plan +} + +// extractDirectionalSourceTargetDays 解析“来源日 -> 目标日”表达。 +// +// 规则: +// 1. 以“收敛到/移到/挪到/调整到”等方向词为分割; +// 2. 分割前提取 source days,分割后提取 target days; +// 3. 两侧都提取成功才返回 true,避免误判。 +func extractDirectionalSourceTargetDays(text string) ([]int, []int, bool) { + verbIdx := -1 + verbLen := 0 + for _, key := range []string{"收敛到", "移到", "挪到", "调整到", "安排到", "放到", "改到", "迁移到", "分散到"} { + if idx := strings.Index(text, key); idx >= 0 { + verbIdx = idx + verbLen = len(key) + break + } + } + if verbIdx < 0 { + return nil, nil, false + } + left := strings.TrimSpace(text[:verbIdx]) + right := strings.TrimSpace(text[verbIdx+verbLen:]) + if left == "" || right == "" { + return nil, nil, false + } + source := extractDayExpr(left) + target := extractDayExpr(right) + if len(source) == 0 || len(target) == 0 { + return nil, nil, false + } + return source, target, true +} + +// extractDayExpr 提取文本中的“星期表达式”。 +// 优先提取区间(周一到周三),提不到再提取离散天。 +func extractDayExpr(text string) []int { + if days := extractRangeDays(text); len(days) > 0 { + return days + } + return extractDays(text) +} + +// inferSourceWeekSet 推断“来源周”集合。 +// +// 规则: +// 1. 当 week_filter 至少两个值时,默认第一个值视为来源周(保留用户原话顺序); +// 2. 当 week_filter 少于两个值时,不强制来源周过滤,返回空集合; +// 3. 该规则用于收敛 workset,避免把目标周任务误纳入当前微循环。 +func inferSourceWeekSet(slice RefineSlicePlan) map[int]struct{} { + if len(slice.WeekFilter) < 2 { + return nil + } + sourceWeek := slice.WeekFilter[0] + if sourceWeek <= 0 { + return nil + } + return map[int]struct{}{sourceWeek: {}} +} + +// inferTargetWeekSet 推断“目标周”集合。 +// +// 规则: +// 1. 当 week_filter 至少两个值时,除首个来源周外,其余周视为目标周; +// 2. 当 week_filter 少于两个值时,不构造目标周集合,交由其他约束判定; +// 3. 返回升维集合用于 O(1) 命中判断。 +func inferTargetWeekSet(slice RefineSlicePlan) map[int]struct{} { + if len(slice.WeekFilter) < 2 { + return nil + } + set := make(map[int]struct{}, len(slice.WeekFilter)-1) + for _, week := range slice.WeekFilter[1:] { + if week > 0 { + set[week] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + return set +} + +func collectWorksetTaskIDs(entries []model.HybridScheduleEntry, slice RefineSlicePlan, originOrder map[int]int) []int { + type candidate struct { + TaskID int + Week int + Day int + SectionFrom int + Rank int + } + list := make([]candidate, 0, len(entries)) + seen := make(map[int]struct{}, len(entries)) + weekSet := intSliceToWeekSet(slice.WeekFilter) + sourceWeekSet := inferSourceWeekSet(slice) + sourceSet := intSliceToDaySet(slice.SourceDays) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + // 1. 方向型周次请求(例如“14周挪到13周”)下,只把“来源周”任务放入 workset。 + // 2. 这样做可以避免目标周/其他周任务被误当成当前微循环任务,触发串改。 + if len(sourceWeekSet) > 0 { + if _, ok := sourceWeekSet[entry.Week]; !ok { + continue + } + } + if len(weekSet) > 0 { + if _, ok := weekSet[entry.Week]; !ok { + continue + } + } + if len(sourceSet) > 0 { + if _, ok := sourceSet[entry.DayOfWeek]; !ok { + continue + } + } + if _, ok := seen[entry.TaskItemID]; ok { + continue + } + seen[entry.TaskItemID] = struct{}{} + rank := originOrder[entry.TaskItemID] + if rank <= 0 { + rank = 1 << 30 + } + list = append(list, candidate{ + TaskID: entry.TaskItemID, + Week: entry.Week, + Day: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + Rank: rank, + }) + } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Rank != list[j].Rank { + return list[i].Rank < list[j].Rank + } + if list[i].Week != list[j].Week { + return list[i].Week < list[j].Week + } + if list[i].Day != list[j].Day { + return list[i].Day < list[j].Day + } + if list[i].SectionFrom != list[j].SectionFrom { + return list[i].SectionFrom < list[j].SectionFrom + } + return list[i].TaskID < list[j].TaskID + }) + ids := make([]int, 0, len(list)) + for _, item := range list { + ids = append(ids, item.TaskID) + } + return ids +} + +func findSuggestedEntryByTaskID(entries []model.HybridScheduleEntry, taskID int) (model.HybridScheduleEntry, bool) { + for _, entry := range entries { + if isMovableSuggestedTask(entry) && entry.TaskItemID == taskID { + return entry, true + } + } + return model.HybridScheduleEntry{}, false +} + +// isCurrentTaskSatisfiedBySlice 判断“当前任务”是否已满足本轮切片目标。 +// +// 步骤化说明: +// 1. 该判断只用于“当前任务自动收口”,不替代全局 hard_check; +// 2. 若切片包含 source_days,则任务离开 source_days 视为关键进展; +// 3. 若切片包含 target_days / exclude_sections / week_filter,则需同时满足; +// 4. 若切片没有任何约束,返回 false,避免误判导致提前结束。 +func isCurrentTaskSatisfiedBySlice(entry model.HybridScheduleEntry, slice RefineSlicePlan) bool { + if !isMovableSuggestedTask(entry) { + return false + } + weekSet := intSliceToWeekSet(slice.WeekFilter) + sourceWeekSet := inferSourceWeekSet(slice) + sourceSet := intSliceToDaySet(slice.SourceDays) + targetSet := intSliceToDaySet(slice.TargetDays) + excludedSet := intSliceToSectionSet(slice.ExcludeSections) + + hasConstraint := len(sourceWeekSet) > 0 || len(weekSet) > 0 || len(sourceSet) > 0 || len(targetSet) > 0 || len(excludedSet) > 0 + if !hasConstraint { + return false + } + if len(sourceWeekSet) > 0 { + if _, stillInSourceWeek := sourceWeekSet[entry.Week]; stillInSourceWeek { + return false + } + } + if len(weekSet) > 0 { + if _, ok := weekSet[entry.Week]; !ok { + return false + } + } + if len(sourceSet) > 0 { + if _, stillInSource := sourceSet[entry.DayOfWeek]; stillInSource { + return false + } + } + if len(targetSet) > 0 { + if _, ok := targetSet[entry.DayOfWeek]; !ok { + return false + } + } + if len(excludedSet) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSet) { + return false + } + return true +} + +func taskProgressLabel(done bool, attemptUsed int, perTaskBudget int) string { + if done { + return "done" + } + if perTaskBudget > 0 && attemptUsed >= perTaskBudget { + return "budget_exhausted" + } + return "paused" +} + +func buildMicroReactUserPrompt(st *ScheduleRefineState, current model.HybridScheduleEntry, remainingAction int, remainingTotal int) string { + ensureCompositeStateMaps(st) + contractJSON, _ := json.Marshal(st.Contract) + planJSON, _ := json.Marshal(st.CurrentPlan) + sliceJSON, _ := json.Marshal(st.SlicePlan) + objectiveJSON, _ := json.Marshal(st.Objective) + currentJSON, _ := json.Marshal(current) + sourceWeeks := keysOfIntSet(inferSourceWeekSet(st.SlicePlan)) + requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool) + requiredSuccess := isRequiredCompositeSatisfied(st) + compositeToolsAllowed := !st.DisableCompositeTools + compositeCalledJSON, _ := json.Marshal(st.CompositeToolCalled) + compositeSuccessJSON, _ := json.Marshal(st.CompositeToolSuccess) + envSlotHint := buildEnvSlotHint(st, current) + userPrompt := fmt.Sprintf( + "用户本轮请求=%s\n契约=%s\n执行计划=%s\n切片=%s\n目标约束=%s\nCURRENT_TASK=%s\nSOURCE_WEEK_FILTER=%v\nBACKEND_GUARD=本轮只允许改写 task_item_id=%d;若该任务已满足切片目标或目标约束已整体达成且复合工具门禁通过,请直接 done=true;下一任务由后端自动切换。\nREQUIRED_COMPOSITE_TOOL=%s\nCOMPOSITE_TOOLS_ALLOWED=%t\nCOMPOSITE_REQUIRED_SUCCESS=%t\nCOMPOSITE_CALLED=%s\nCOMPOSITE_SUCCESS=%s\nCURRENT_TASK_ACTION_USED=%d\nPER_TASK_BUDGET=%d\n动作预算剩余=%d\n总预算剩余=%d\nENV_SLOT_HINT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\n最近观察=%s\n同周suggested摘要=%s", + strings.TrimSpace(st.UserMessage), + string(contractJSON), + string(planJSON), + string(sliceJSON), + string(objectiveJSON), + string(currentJSON), + sourceWeeks, + current.TaskItemID, + fallbackText(requiredComposite, "无"), + compositeToolsAllowed, + requiredSuccess, + string(compositeCalledJSON), + string(compositeSuccessJSON), + st.TaskActionUsed[current.TaskItemID], + st.PerTaskBudget, + remainingAction, + remainingTotal, + envSlotHint, + buildLastToolObservationPrompt(st.ObservationHistory), + fallbackText(st.LastFailedCallSignature, "无"), + buildObservationPrompt(st.ObservationHistory, 2), + buildSuggestedDigestByWeek(st.HybridEntries, current.Week, 24), + ) + return withNearestJSONContract(userPrompt, jsonContractForReact) +} + +type slotHintPayload struct { + Count int `json:"count"` + StrictCount int `json:"strict_count"` + EmbeddedCount int `json:"embedded_count"` + Slots []struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } `json:"slots"` +} + +func buildEnvSlotHint(st *ScheduleRefineState, current model.HybridScheduleEntry) string { + if st == nil || !isMovableSuggestedTask(current) { + return "无可用提示" + } + span := current.SectionTo - current.SectionFrom + 1 + if span <= 0 { + span = 2 + } + targetWeeks := append([]int(nil), st.Objective.TargetWeeks...) + if len(targetWeeks) == 0 { + targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan)) + } + if len(targetWeeks) == 0 && current.Week > 0 { + targetWeeks = []int{current.Week} + } + targetDays := append([]int(nil), st.Objective.TargetDays...) + if len(targetDays) == 0 { + targetDays = append([]int(nil), st.SlicePlan.TargetDays...) + } + params := map[string]any{ + "week_filter": targetWeeks, + "day_filter": targetDays, + "section_duration": span, + "limit": 8, + "slot_type": "pure", + "exclude_sections": st.SlicePlan.ExcludeSections, + } + _, pureResult := refineToolQueryAvailableSlots(st.HybridEntries, params, buildPlanningWindowFromEntries(st.HybridEntries)) + if !pureResult.Success { + return fmt.Sprintf("pure_slot_query_failed=%s", truncate(pureResult.Result, 100)) + } + purePayload, ok := decodeSlotHintPayload(pureResult.Result) + if !ok { + return "pure_slot_parse_failed" + } + + embedParams := map[string]any{ + "week_filter": targetWeeks, + "day_filter": targetDays, + "section_duration": span, + "limit": 8, + "exclude_sections": st.SlicePlan.ExcludeSections, + } + _, fallbackResult := refineToolQueryAvailableSlots(st.HybridEntries, embedParams, buildPlanningWindowFromEntries(st.HybridEntries)) + if !fallbackResult.Success { + return fmt.Sprintf("pure=%d fallback_query_failed=%s", purePayload.Count, truncate(fallbackResult.Result, 100)) + } + fallbackPayload, ok := decodeSlotHintPayload(fallbackResult.Result) + if !ok { + return fmt.Sprintf("pure=%d fallback_parse_failed", purePayload.Count) + } + + top := fallbackPayload.Slots + if len(top) > 3 { + top = top[:3] + } + slotText := make([]string, 0, len(top)) + for _, item := range top { + slotText = append(slotText, fmt.Sprintf("W%dD%d %d-%d", item.Week, item.DayOfWeek, item.SectionFrom, item.SectionTo)) + } + if len(slotText) == 0 { + slotText = append(slotText, "无") + } + return fmt.Sprintf("target_weeks=%v target_days=%v pure=%d embed_candidate=%d top=%s", targetWeeks, targetDays, purePayload.Count, fallbackPayload.EmbeddedCount, strings.Join(slotText, ",")) +} + +func decodeSlotHintPayload(raw string) (slotHintPayload, bool) { + var payload slotHintPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return slotHintPayload{}, false + } + return payload, true +} + +func extractWeekFilters(text string) []int { + patterns := []string{ + `第\s*(\d{1,2})\s*周`, + `W\s*(\d{1,2})`, + `(\d{1,2})\s*周`, + } + out := make([]int, 0, 8) + for _, p := range patterns { + re := regexp.MustCompile(p) + for _, m := range re.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + v, err := strconv.Atoi(strings.TrimSpace(m[1])) + if err != nil || v <= 0 { + continue + } + out = append(out, v) + } + } + return uniquePositiveInts(out) +} + +func extractExcludeSections(text string) []int { + normalized := strings.ReplaceAll(strings.ToLower(text), " ", "") + if containsAny(normalized, []string{ + "不要早八", "避开早八", "不想早八", "别在早八", + "不要1-2", "避开1-2", "不要第一节", "不要一二节", + }) { + return []int{1, 2} + } + return nil +} + +func extractTargetDaysFromMessage(text string) []int { + verbIdx := -1 + for _, key := range []string{"移到", "挪到", "改到", "安排到", "放到", "分散到", "调整到", "收敛到", "迁移到"} { + if idx := strings.Index(text, key); idx >= 0 { + verbIdx = idx + len(key) + break + } + } + if verbIdx < 0 || verbIdx >= len(text) { + return nil + } + targetPart := strings.TrimSpace(text[verbIdx:]) + return extractDayExpr(targetPart) +} + +func extractSourceDaysFromMessage(text string) []int { + source := make([]int, 0, 4) + re := regexp.MustCompile(`从\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + for _, m := range re.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + if day := dayTokenToInt(m[1]); day > 0 { + source = append(source, day) + } + } + re2 := regexp.MustCompile(`把\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + for _, m := range re2.FindAllStringSubmatch(text, -1) { + if len(m) < 2 { + continue + } + if day := dayTokenToInt(m[1]); day > 0 { + source = append(source, day) + } + } + return uniquePositiveInts(source) +} + +func detectOverloadedDay(text string) int { + re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天]).{0,8}(太多|过多|太满|过满|拥挤|太挤|塞满)`) + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return 0 + } + return dayTokenToInt(m[1]) +} + +func extractRangeDays(text string) []int { + re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天])\s*[到至\-]\s*(周[一二三四五六日天]|星期[一二三四五六日天])`) + m := re.FindStringSubmatch(text) + if len(m) < 3 { + return nil + } + start := dayTokenToInt(m[1]) + end := dayTokenToInt(m[2]) + if start <= 0 || end <= 0 { + return nil + } + if start > end { + start, end = end, start + } + out := make([]int, 0, end-start+1) + for day := start; day <= end; day++ { + out = append(out, day) + } + return out +} + +func extractDays(text string) []int { + re := regexp.MustCompile(`周[一二三四五六日天]|星期[一二三四五六日天]`) + matches := re.FindAllString(text, -1) + days := make([]int, 0, len(matches)) + for _, token := range matches { + if day := dayTokenToInt(token); day > 0 { + days = append(days, day) + } + } + return uniquePositiveInts(days) +} + +func dayTokenToInt(token string) int { + switch strings.TrimSpace(token) { + case "周一", "星期一": + return 1 + case "周二", "星期二": + return 2 + case "周三", "星期三": + return 3 + case "周四", "星期四": + return 4 + case "周五", "星期五": + return 5 + case "周六", "星期六": + return 6 + case "周日", "周天", "星期日", "星期天": + return 7 + default: + return 0 + } +} + +func containsAny(text string, keys []string) bool { + for _, k := range keys { + if strings.Contains(text, k) { + return true + } + } + return false +} + +func isSameDays(days []int, target []int) bool { + if len(days) != len(target) { + return false + } + for i := range days { + if days[i] != target[i] { + return false + } + } + return true +} diff --git a/backend/agent2/node/schedule_refine_impl/prompt.go b/backend/agent2/node/schedule_refine_impl/prompt.go new file mode 100644 index 0000000..e0be09d --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/prompt.go @@ -0,0 +1,188 @@ +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 等别名。` +) diff --git a/backend/agent2/node/schedule_refine_impl/refine_filters_test.go b/backend/agent2/node/schedule_refine_impl/refine_filters_test.go new file mode 100644 index 0000000..1f63867 --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/refine_filters_test.go @@ -0,0 +1,637 @@ +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("用户明确允许乱序时,应关闭顺序约束") + } +} diff --git a/backend/agent2/node/schedule_refine_impl/runner.go b/backend/agent2/node/schedule_refine_impl/runner.go new file mode 100644 index 0000000..851f554 --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/runner.go @@ -0,0 +1,53 @@ +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) +} diff --git a/backend/agent2/node/schedule_refine_impl/state.go b/backend/agent2/node/schedule_refine_impl/state.go new file mode 100644 index 0000000..604520b --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/state.go @@ -0,0 +1,377 @@ +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 +} diff --git a/backend/agent2/node/schedule_refine_impl/tool.go b/backend/agent2/node/schedule_refine_impl/tool.go new file mode 100644 index 0000000..21f3e79 --- /dev/null +++ b/backend/agent2/node/schedule_refine_impl/tool.go @@ -0,0 +1,2027 @@ +package schedulerefine + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/LoveLosita/smartflow/backend/logic" + "github.com/LoveLosita/smartflow/backend/model" +) + +// reactToolCall 表示模型输出的单个工具调用指令。 +type reactToolCall struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` +} + +// reactToolResult 表示工具调用的结构化执行结果。 +type reactToolResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + ErrorCode string `json:"error_code,omitempty"` + Result string `json:"result"` +} + +// reactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。 +// +// 字段语义: +// 1. goal_check:本轮要先验证的目标点; +// 2. decision:本轮动作选择依据; +// 3. tool_calls:本轮工具动作列表(业务侧只取第一条)。 +type reactLLMOutput struct { + Done bool `json:"done"` + Summary string `json:"summary"` + GoalCheck string `json:"goal_check"` + Decision string `json:"decision"` + MissingInfo []string `json:"missing_info,omitempty"` + ToolCalls []reactToolCall `json:"tool_calls"` +} + +// reviewOutput 表示终审节点要求的固定 JSON 输出结构。 +type reviewOutput struct { + Pass bool `json:"pass"` + Reason string `json:"reason"` + Unmet []string `json:"unmet"` +} + +// planningWindow 表示微调工具允许活动的 week/day 边界窗口。 +// +// 设计说明: +// 1. 这里用已有 HybridEntries 自动推导窗口,避免把任务移动到完全无关的周; +// 2. 若窗口不可用(没有任何 entry),则降级为“仅做基础合法性校验”。 +type planningWindow struct { + Enabled bool + StartWeek int + StartDay int + EndWeek int + EndDay int +} + +// refineToolPolicy 是工具层硬约束策略。 +// +// 职责边界: +// 1. 负责承载“是否强制保持相对顺序”的策略开关; +// 2. 负责承载顺序校验需要的 origin_order 映射; +// 3. 不负责语义判定(语义仍由 LLM 终审节点负责)。 +type refineToolPolicy struct { + KeepRelativeOrder bool + OrderScope string + OriginOrderMap map[int]int +} + +// dispatchRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。 +// +// 步骤化说明: +// 1. 先识别工具名并路由到对应实现; +// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验; +// 3. 任何失败都返回 Success=false 的结构化结果,而不是直接 panic。 +func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + switch strings.TrimSpace(call.Tool) { + case "QueryTargetTasks": + return refineToolQueryTargetTasks(entries, call.Params, policy) + case "QueryAvailableSlots": + return refineToolQueryAvailableSlots(entries, call.Params, window) + case "Move": + return refineToolMove(entries, call.Params, window, policy) + case "Swap": + return refineToolSwap(entries, call.Params, window, policy) + case "BatchMove": + return refineToolBatchMove(entries, call.Params, window, policy) + case "SpreadEven": + return refineToolSpreadEven(entries, call.Params, window, policy) + case "MinContextSwitch": + return refineToolMinContextSwitch(entries, call.Params, window, policy) + case "Verify": + return refineToolVerify(entries, call.Params, policy) + default: + return entries, reactToolResult{ + Tool: strings.TrimSpace(call.Tool), + Success: false, + Result: fmt.Sprintf("不支持的工具:%s(仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify)", strings.TrimSpace(call.Tool)), + } + } +} + +// pickSingleToolCall 在“单步动作”策略下选取一个工具调用。 +// +// 返回语义: +// 1. call=nil:本轮无可执行动作; +// 2. warn 非空:模型返回了多个调用,本轮只执行第一个并记录告警。 +func pickSingleToolCall(calls []reactToolCall) (*reactToolCall, string) { + if len(calls) == 0 { + return nil, "" + } + call := calls[0] + if len(calls) == 1 { + return &call, "" + } + return &call, fmt.Sprintf("模型返回了 %d 个工具调用,本轮仅执行第一个:%s", len(calls), call.Tool) +} + +// parseReactLLMOutput 解析模型输出的 ReAct JSON。 +// +// 容错策略: +// 1. 兼容 ```json 代码块包装; +// 2. 兼容 JSON 前后有解释性文字(提取最外层对象)。 +func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("ReAct 输出为空") + } + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out reactLLMOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, objErr := extractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从输出中提取 JSON:%s", truncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// parseReviewOutput 解析终审评估节点输出。 +func parseReviewOutput(raw string) (*reviewOutput, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("review 输出为空") + } + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out reviewOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + obj, objErr := extractFirstJSONObject(clean) + if objErr != nil { + return nil, fmt.Errorf("无法从 review 输出中提取 JSON:%s", truncate(clean, 220)) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +// refineToolMove 执行“移动一个 suggested 任务到指定时段”。 +// +// 步骤化说明: +// 1. 先校验参数完整性与目标时段合法性,避免写入脏坐标; +// 2. 再校验原任务是否存在、跨度是否一致(防止任务长度被模型改坏); +// 3. 再校验窗口边界与冲突,确保不会穿透到不可用位置; +// 4. 若启用顺序硬约束,再校验“移动后是否打乱原相对顺序”; +// 5. 全部通过后才真正修改 entries 并返回 Success=true。 +func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + // 0. task_id 兼容策略: + // 0.1 标准键是 task_item_id; + // 0.2 为了兼容模型偶发输出别名 task_id,这里做兜底兼容,避免“语义正确但参数名不一致”导致整轮白跑; + // 0.3 两者都不存在时,仍按参数缺失返回失败,由上层 ReAct 继续下一轮决策。 + taskID, ok := paramIntAny(params, "task_item_id", "task_id") + if !ok { + return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:task_item_id"} + } + // 1. 参数兼容策略: + // 1.1 优先读取标准键(to_week/to_day/...); + // 1.2 若模型输出了历史别名(target_xxx/day_of_week 等),也兼容解析; + // 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。 + toWeek, okWeek := paramIntAny(params, "to_week", "target_week", "new_week", "week") + toDay, okDay := paramIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day") + toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from") + toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to") + if !okWeek || !okDay || !okSF || !okST { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: "参数缺失:需要 to_week/to_day/to_section_from/to_section_to", + } + } + if toDay < 1 || toDay > 7 { + return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法,必须在 1~7", toDay)} + } + if toSF < 1 || toST > 12 || toSF > toST { + return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)} + } + allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + + idx, locateErr := findUniqueSuggestedByID(entries, taskID) + if locateErr != nil { + return entries, reactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()} + } + origSpan := entries[idx].SectionTo - entries[idx].SectionFrom + newSpan := toST - toSF + if origSpan != newSpan { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("任务跨度不一致:原跨度=%d,目标跨度=%d", origSpan+1, newSpan+1), + } + } + + if !isWithinWindow(window, toWeek, toDay) { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay), + } + } + + if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict { + return entries, reactToolResult{ + Tool: "Move", + Success: false, + Result: fmt.Sprintf("目标时段已被 %s 占用", name), + } + } + + beforeEntries := cloneHybridEntries(entries) + entry := &entries[idx] + before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) + entry.Week = toWeek + entry.DayOfWeek = toDay + entry.SectionFrom = toSF + entry.SectionTo = toST + after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo) + + sortHybridEntries(entries) + if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, reactToolResult{ + Tool: "Move", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, reactToolResult{ + Tool: "Move", + Success: true, + Result: fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after), + } +} + +// refineToolSwap 执行“交换两个 suggested 任务的位置”。 +// +// 步骤化说明: +// 1. 先校验两端 task_item_id; +// 2. 再双向验证交换后的落点是否与其他条目冲突; +// 3. 若启用顺序硬约束,再校验“交换后是否打乱相对顺序”; +// 4. 校验通过后提交交换并返回成功。 +func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + // 1. 参数兼容策略同 Move: + // 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名; + // 1.2 目标是减少模型输出字段差异导致的无效失败。 + idA, okA := paramIntAny(params, "task_a", "task_item_a", "task_item_id_a") + idB, okB := paramIntAny(params, "task_b", "task_item_b", "task_item_id_b") + if !okA || !okB { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:task_a/task_b"} + } + allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + if idA == idB { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"} + } + + idxA, errA := findUniqueSuggestedByID(entries, idA) + if errA != nil { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: errA.Error()} + } + idxB, errB := findUniqueSuggestedByID(entries, idB) + if errB != nil { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: errB.Error()} + } + + a := entries[idxA] + b := entries[idxB] + if !isWithinWindow(window, b.Week, b.DayOfWeek) || !isWithinWindow(window, a.Week, a.DayOfWeek) { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"} + } + + excludes := map[int]bool{idxA: true, idxB: true} + if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)} + } + if conflict, name := hasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)} + } + + beforeEntries := cloneHybridEntries(entries) + entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week + entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek + entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom + entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo + + sortHybridEntries(entries) + if issues := validateRelativeOrder(entries, policy); len(issues) > 0 { + return beforeEntries, reactToolResult{ + Tool: "Swap", + Success: false, + Result: "顺序约束不满足:" + strings.Join(issues, ";"), + } + } + + return entries, reactToolResult{ + Tool: "Swap", + Success: true, + Result: fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB), + } +} + +// refineToolBatchMove 执行“原子批量移动 suggested 任务”。 +// +// 步骤化说明: +// 1. 参数要求:params.moves 必须是数组,每个元素都满足 Move 的参数格式; +// 2. 执行策略:在 working 副本上按顺序逐条执行 Move; +// 3. 原子语义:任一步失败,整批回滚(返回原 entries);全部成功才一次性提交; +// 4. 适用场景:用户明确希望“同一轮挪多个任务”,减少 ReAct 往返轮次。 +func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + moveParamsList, parseErr := parseBatchMoveParams(params) + if parseErr != nil { + return entries, reactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: parseErr.Error(), + } + } + // 2. 批级 allow_embed 默认值: + // 2.1 如果子动作未显式声明 allow_embed/allow_embedding,则继承批级开关; + // 2.2 默认 true,和 Move/Swap 一致:允许嵌入,但由 QueryAvailableSlots 先给纯空位。 + batchAllowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + for i := range moveParamsList { + if _, ok := moveParamsList[i]["allow_embed"]; ok { + continue + } + if _, ok := moveParamsList[i]["allow_embedding"]; ok { + continue + } + moveParamsList[i]["allow_embed"] = batchAllowEmbed + } + + // 1. 在副本上执行,保证原子性: + // 1.1 每一步都复用 refineToolMove 的全部校验逻辑(冲突、窗口、顺序、跨度); + // 1.2 只要任一步失败就中止并回滚到原 entries; + // 1.3 全部成功后再返回 working,作为整批提交结果。 + working := cloneHybridEntries(entries) + stepSummary := make([]string, 0, len(moveParamsList)) + currentWindow := buildPlanningWindowFromEntries(working) + if !currentWindow.Enabled { + currentWindow = window + } + for idx, moveParams := range moveParamsList { + nextEntries, stepResult := refineToolMove(working, moveParams, currentWindow, policy) + if !stepResult.Success { + return entries, reactToolResult{ + Tool: "BatchMove", + Success: false, + ErrorCode: classifyBatchMoveErrorCode(stepResult.Result), + Result: fmt.Sprintf("BatchMove 第%d步失败:%s", idx+1, stepResult.Result), + } + } + working = nextEntries + currentWindow = buildPlanningWindowFromEntries(working) + stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, truncate(stepResult.Result, 120))) + } + + return working, reactToolResult{ + Tool: "BatchMove", + Success: true, + Result: fmt.Sprintf("BatchMove 原子提交成功,共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")), + } +} + +type compositePlannerFn func( + tasks []logic.RefineTaskCandidate, + slots []logic.RefineSlotCandidate, + options logic.RefineCompositePlanOptions, +) ([]logic.RefineMovePlanItem, error) + +// refineToolSpreadEven 执行“均匀铺开”复合动作。 +// +// 职责边界: +// 1. 负责参数解析、候选收集、调用确定性规划器; +// 2. 不直接改写 entries,统一通过 BatchMove 原子落地; +// 3. 规划算法实现位于 logic 包,工具层只负责编排。 +func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + return refineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves) +} + +// refineToolMinContextSwitch 执行“最少上下文切换”复合动作。 +// +// 职责边界: +// 1. 负责锁定“当前任务已占坑位集合”,避免为了聚类把任务远距离迁移; +// 2. 负责在固定坑位集合内调用确定性规划器,只重排“任务 -> 坑位”的映射; +// 3. 不直接改写 entries,统一通过 BatchMove 原子落地。 +func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + 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 是复合动作工具的统一执行框架。 +// +// 步骤化说明: +// 1. 先解析“目标任务集合”,确保任务来源明确且可唯一落到 task_item_id; +// 2. 再按任务跨度查询候选坑位,避免跨度不一致导致执行期失败; +// 3. 调用 logic 包的确定性规划函数,得到 moves; +// 4. 最后复用 BatchMove 原子提交,任一步失败整批回滚。 +func refineToolCompositeMove( + entries []model.HybridScheduleEntry, + params map[string]any, + window planningWindow, + policy refineToolPolicy, + toolName string, + planner compositePlannerFn, +) ([]model.HybridScheduleEntry, reactToolResult) { + taskIDs := collectCompositeTaskIDs(params) + if len(taskIDs) == 0 { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "参数缺失:复合工具需要 task_item_ids 或 task_item_id", + } + } + tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName) + if !ok { + return entries, taskResult + } + idSet := intSliceToIDSet(taskIDs) + spanNeed := buildCompositeSpanNeed(tasks) + + slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed) + if slotErr != nil { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "SLOT_QUERY_FAILED", + Result: slotErr.Error(), + } + } + options := logic.RefineCompositePlanOptions{ + ExistingDayLoad: buildCompositeDayLoadBaseline(entries, idSet, slots), + } + plannedMoves, planErr := planner(tasks, slots, options) + if planErr != nil { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: "PLAN_FAILED", + 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, + Success: false, + ErrorCode: "PLAN_EMPTY", + Result: "规划结果为空:未生成任何可执行移动", + } + } + + moveParams := make([]any, 0, len(plannedMoves)) + for _, move := range plannedMoves { + moveParams = append(moveParams, map[string]any{ + "task_item_id": move.TaskItemID, + "to_week": move.ToWeek, + "to_day": move.ToDay, + "to_section_from": move.ToSectionFrom, + "to_section_to": move.ToSectionTo, + }) + } + batchParams := map[string]any{ + "moves": moveParams, + "allow_embed": paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"), + } + nextEntries, batchResult := refineToolBatchMove(entries, batchParams, window, policy) + if !batchResult.Success { + return entries, reactToolResult{ + Tool: toolName, + Success: false, + ErrorCode: batchResult.ErrorCode, + Result: fmt.Sprintf("%s 执行失败:%s", toolName, batchResult.Result), + } + } + return nextEntries, reactToolResult{ + Tool: toolName, + Success: true, + Result: fmt.Sprintf("%s 执行成功:已规划并提交 %d 条移动。", toolName, len(plannedMoves)), + } +} + +// 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 { + ids = append(ids, id) + } + return uniquePositiveInts(ids) +} + +func collectCompositeSlotsBySpan( + entries []model.HybridScheduleEntry, + params map[string]any, + window planningWindow, + spanNeed map[int]int, +) ([]logic.RefineSlotCandidate, error) { + if len(spanNeed) == 0 { + return nil, fmt.Errorf("未识别到任务跨度需求") + } + + spans := make([]int, 0, len(spanNeed)) + for span := range spanNeed { + spans = append(spans, span) + } + sort.Ints(spans) + + allSlots := make([]logic.RefineSlotCandidate, 0, 16) + for _, span := range spans { + required := spanNeed[span] + queryParams := buildCompositeSlotQueryParams(params, span, required) + _, queryResult := refineToolQueryAvailableSlots(entries, queryParams, window) + if !queryResult.Success { + return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, queryResult.Result) + } + + var payload struct { + Slots []struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } `json:"slots"` + } + if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil { + return nil, fmt.Errorf("解析跨度=%d 的空位结果失败:%v", span, err) + } + if len(payload.Slots) < required { + return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots)) + } + for _, slot := range payload.Slots { + allSlots = append(allSlots, logic.RefineSlotCandidate{ + Week: slot.Week, + DayOfWeek: slot.DayOfWeek, + SectionFrom: slot.SectionFrom, + SectionTo: slot.SectionTo, + }) + } + } + return allSlots, nil +} + +func buildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any { + query := make(map[string]any, 12) + query["span"] = span + + // 1. limit 以“任务数 * 兜底系数”估算,给规划器保留可选空间; + // 2. 若调用方显式给了 limit,则采用更大的那个,避免被过小 limit 限死。 + limit := required * 6 + if limit < required { + limit = required + } + if customLimit, ok := paramIntAny(params, "limit"); ok && customLimit > limit { + limit = customLimit + } + query["limit"] = limit + query["allow_embed"] = paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + + for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} { + if value, ok := params[key]; ok { + query[key] = value + } + } + + // 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 { + query["week"] = week + } + if day, ok := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok { + query["day_of_week"] = []int{day} + } + return query +} + +func copyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) { + values := readIntSlice(src, srcKeys...) + if len(values) == 0 { + return + } + normalized := uniquePositiveInts(values) + if len(normalized) == 0 { + return + } + dst[dstKey] = normalized +} + +func buildCompositeDayLoadBaseline( + entries []model.HybridScheduleEntry, + excludeTaskIDs map[int]struct{}, + slots []logic.RefineSlotCandidate, +) map[string]int { + if len(slots) == 0 { + return nil + } + targetDays := make(map[string]struct{}, len(slots)) + for _, slot := range slots { + targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{} + } + + load := make(map[string]int, len(targetDays)) + for _, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded { + continue + } + key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek) + if _, inTarget := targetDays[key]; !inTarget { + continue + } + load[key]++ + } + return load +} + +// refineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。 +// +// 步骤化说明: +// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤; +// 2. 只读查询,不修改 entries; +// 3. 返回结构化 JSON 字符串,供下一轮模型直接消费。 +func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + scope := normalizeDayScope(readString(params, "day_scope", "all")) + statusFilter := normalizeStatusFilter(readString(params, "status", "suggested")) + weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks")) + weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") + if week, hasWeek := paramIntAny(params, "week"); hasWeek { + weekFrom, weekTo = week, week + hasWeekFrom, hasWeekTo = true, true + } + if hasWeekFrom && hasWeekTo && weekFrom > weekTo { + weekFrom, weekTo = weekTo, weekFrom + } + if !hasWeekFrom || !hasWeekTo { + startWeek, endWeek := inferWeekBounds(entries, planningWindow{Enabled: false}) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + limit, okLimit := paramIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 16 + } + dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter")) + taskIDs := readIntSlice(params, "task_item_ids", "task_ids") + if taskID, ok := paramIntAny(params, "task_item_id", "task_id"); ok { + taskIDs = append(taskIDs, taskID) + } + taskIDSet := intSliceToIDSet(taskIDs) + + type targetTask struct { + TaskItemID int `json:"task_item_id"` + Name string `json:"name"` + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + OriginRank int `json:"origin_rank,omitempty"` + ContextTag string `json:"context_tag,omitempty"` + CurrentState string `json:"status"` + } + + list := make([]targetTask, 0, 32) + for _, entry := range entries { + if !matchStatusFilter(entry.Status, statusFilter) { + continue + } + // suggested 视图只允许看到“可移动任务”,避免把课程类条目当成可调任务暴露给模型。 + if statusFilter == "suggested" && !isMovableSuggestedTask(entry) { + continue + } + if entry.TaskItemID <= 0 { + continue + } + if len(taskIDSet) > 0 { + if _, ok := taskIDSet[entry.TaskItemID]; !ok { + continue + } + } + if len(dayFilter) > 0 { + if _, ok := dayFilter[entry.DayOfWeek]; !ok { + continue + } + } else if !matchDayScope(entry.DayOfWeek, scope) { + continue + } + if len(weekFilter) > 0 { + if _, ok := weekFilter[entry.Week]; !ok { + continue + } + } + if hasWeekFrom && entry.Week < weekFrom { + continue + } + if hasWeekTo && entry.Week > weekTo { + continue + } + list = append(list, targetTask{ + TaskItemID: entry.TaskItemID, + Name: strings.TrimSpace(entry.Name), + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, + OriginRank: policy.OriginOrderMap[entry.TaskItemID], + ContextTag: strings.TrimSpace(entry.ContextTag), + CurrentState: entry.Status, + }) + } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Week != list[j].Week { + return list[i].Week < list[j].Week + } + if list[i].DayOfWeek != list[j].DayOfWeek { + return list[i].DayOfWeek < list[j].DayOfWeek + } + if list[i].SectionFrom != list[j].SectionFrom { + return list[i].SectionFrom < list[j].SectionFrom + } + return list[i].TaskItemID < list[j].TaskItemID + }) + if len(list) > limit { + list = list[:limit] + } + + payload := map[string]any{ + "tool": "QueryTargetTasks", + "count": len(list), + "status": statusFilter, + "day_scope": scope, + "week_filter": keysOfIntSet(weekFilter), + "week_from": weekFrom, + "week_to": weekTo, + "day_of_week": keysOfIntSet(dayFilter), + "items": list, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "QueryTargetTasks", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化查询结果失败:%v", err), + } + } + return entries, reactToolResult{ + Tool: "QueryTargetTasks", + Success: true, + Result: string(raw), + } +} + +// refineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。 +// +// 步骤化说明: +// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段; +// 2. 默认先收集“纯空位”,不足 limit 再补“可嵌入课程位”(第二优先级); +// 3. 返回结构化 JSON 字符串,不修改 entries。 +func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow) ([]model.HybridScheduleEntry, reactToolResult) { + scope := normalizeDayScope(readString(params, "day_scope", "all")) + dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter")) + weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks")) + // 1. 空位优先策略: + // 1.1 默认 allow_embed=true,但查询分两阶段执行; + // 1.2 第一阶段只收集“纯空白位”(不与 existing 重叠); + // 1.3 第二阶段仅在空白位不足 limit 时,补充“可嵌入课程位”。 + allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding") + // 1.4 兼容 slot_type/slot_types: + // 1.4.1 当明确请求 pure/empty/strict 时,强制只查纯空位(关闭嵌入候选)。 + // 1.4.2 当未声明时,维持“空位优先,空位不足再补嵌入候选”的默认策略。 + slotTypeHints := readStringSlice(params, "slot_types") + if single := strings.TrimSpace(readString(params, "slot_type", "")); single != "" { + slotTypeHints = append(slotTypeHints, single) + } + for _, hint := range slotTypeHints { + normalized := strings.ToLower(strings.TrimSpace(hint)) + if normalized == "pure" || normalized == "empty" || normalized == "strict" { + allowEmbed = false + break + } + } + span, okSpan := paramIntAny(params, "span", "section_duration", "task_duration") + if !okSpan || span <= 0 { + span = 2 + } + if span > 12 { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "SPAN_INVALID", + Result: fmt.Sprintf("span=%d 非法,必须在 1~12", span), + } + } + limit, okLimit := paramIntAny(params, "limit") + if !okLimit || limit <= 0 { + limit = 12 + } + + weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week") + weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week") + if week, hasWeek := paramIntAny(params, "week"); hasWeek { + weekFrom, weekTo = week, week + hasWeekFrom, hasWeekTo = true, true + } + if hasWeekFrom && hasWeekTo && weekFrom > weekTo { + weekFrom, weekTo = weekTo, weekFrom + } + if !hasWeekFrom || !hasWeekTo { + startWeek, endWeek := inferWeekBounds(entries, window) + if !hasWeekFrom { + weekFrom = startWeek + } + if !hasWeekTo { + weekTo = endWeek + } + } + weeksToIterate := buildWeekIterList(weekFilter, weekFrom, weekTo) + if len(weeksToIterate) == 0 { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "周范围为空:请提供 week / week_filter 或确保排程窗口有效", + } + } + weekFrom = weeksToIterate[0] + weekTo = weeksToIterate[len(weeksToIterate)-1] + + excludedSet := make(map[int]struct{}) + for _, sec := range readIntSlice(params, "exclude_sections", "exclude_section") { + if sec >= 1 && sec <= 12 { + excludedSet[sec] = struct{}{} + } + } + afterSection, hasAfter := paramIntAny(params, "after_section") + beforeSection, hasBefore := paramIntAny(params, "before_section") + exactSectionFrom, hasExactFrom := paramIntAny(params, "section_from", "target_section_from") + exactSectionTo, hasExactTo := paramIntAny(params, "section_to", "target_section_to") + if hasExactFrom != hasExactTo { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "PARAM_MISSING", + Result: "精确节次查询需同时提供 section_from 和 section_to", + } + } + if hasExactFrom { + if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "SPAN_INVALID", + Result: fmt.Sprintf("精确节次区间非法:%d-%d", exactSectionFrom, exactSectionTo), + } + } + span = exactSectionTo - exactSectionFrom + 1 + } + + type slot struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + SlotType string `json:"slot_type,omitempty"` + } + slots := make([]slot, 0, limit) + seen := make(map[string]struct{}, limit*2) + strictCount := 0 + collect := func(embedAllowed bool, slotType string) { + if len(slots) >= limit { + return + } + for _, week := range weeksToIterate { + for day := 1; day <= 7; day++ { + if len(dayFilter) > 0 { + if _, ok := dayFilter[day]; !ok { + continue + } + } else if !matchDayScope(day, scope) { + continue + } + if !isWithinWindow(window, week, day) { + continue + } + for sf := 1; sf+span-1 <= 12; sf++ { + st := sf + span - 1 + if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) { + continue + } + if hasAfter && sf <= afterSection { + continue + } + if hasBefore && st >= beforeSection { + continue + } + if intersectsExcludedSections(sf, st, excludedSet) { + continue + } + if conflict, _ := hasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict { + continue + } + key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + slots = append(slots, slot{ + Week: week, + DayOfWeek: day, + SectionFrom: sf, + SectionTo: st, + SlotType: slotType, + }) + if len(slots) >= limit { + return + } + } + } + } + } + collect(false, "empty") + strictCount = len(slots) + if allowEmbed && len(slots) < limit { + collect(true, "embedded_candidate") + } + embeddedCount := len(slots) - strictCount + + payload := map[string]any{ + "tool": "QueryAvailableSlots", + "count": len(slots), + "strict_count": strictCount, + "embedded_count": embeddedCount, + "fallback_used": embeddedCount > 0, + "day_scope": scope, + "day_of_week": keysOfIntSet(dayFilter), + "week_filter": keysOfIntSet(weekFilter), + "week_from": weekFrom, + "week_to": weekTo, + "span": span, + "allow_embed": allowEmbed, + "exclude_sections": keysOfIntSet(excludedSet), + "slots": slots, + } + if hasAfter { + payload["after_section"] = afterSection + } + if hasBefore { + payload["before_section"] = beforeSection + } + if hasExactFrom { + payload["section_from"] = exactSectionFrom + payload["section_to"] = exactSectionTo + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: false, + ErrorCode: "QUERY_ENCODE_FAILED", + Result: fmt.Sprintf("序列化空位结果失败:%v", err), + } + } + return entries, reactToolResult{ + Tool: "QueryAvailableSlots", + Success: true, + Result: string(raw), + } +} + +// refineToolVerify 进行“轻量确定性自检”。 +// +// 说明: +// 1. 当前只做 deterministic 校验(冲突/顺序),不做语义 LLM 终审; +// 2. 语义层终审仍在 hard_check 节点统一处理; +// 3. 该工具用于给执行阶段一个“可提前自查”的信号。 +func refineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) { + physicsIssues := physicsCheck(entries, 0) + orderIssues := validateRelativeOrder(entries, policy) + if len(physicsIssues) > 0 || len(orderIssues) > 0 { + payload := map[string]any{ + "tool": "Verify", + "pass": false, + "physics_issues": physicsIssues, + "order_issues": orderIssues, + } + raw, err := json.Marshal(payload) + if err != nil { + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: "Verify 校验失败且结果无法序列化", + } + } + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: string(raw), + } + } + + // 1. 若携带 task_item_id / 目标坐标参数,则执行“针对性核验”,避免“全局 pass”掩盖当前任务不匹配。 + // 2. 该核验是可选增强:没传 task_id 时仍维持全局 deterministic 行为。 + taskID, hasTaskID := paramIntAny(params, "task_item_id", "task_id") + if hasTaskID { + idx, locateErr := findUniqueSuggestedByID(entries, taskID) + if locateErr != nil { + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()), + } + } + target := entries[idx] + verifyWeek, hasWeek := paramIntAny(params, "week", "to_week", "target_week") + verifyDay, hasDay := paramIntAny(params, "day_of_week", "to_day", "target_day_of_week") + verifyFrom, hasFrom := paramIntAny(params, "section_from", "to_section_from", "target_section_from") + verifyTo, hasTo := paramIntAny(params, "section_to", "to_section_to", "target_section_to") + + mismatch := make([]string, 0, 4) + if hasWeek && target.Week != verifyWeek { + mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week)) + } + if hasDay && target.DayOfWeek != verifyDay { + mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek)) + } + if hasFrom && target.SectionFrom != verifyFrom { + mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom)) + } + if hasTo && target.SectionTo != verifyTo { + mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo)) + } + if len(mismatch) > 0 { + return entries, reactToolResult{ + Tool: "Verify", + Success: false, + ErrorCode: "VERIFY_FAILED", + Result: fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配:%s"}`, strings.Join(mismatch, ";")), + } + } + return entries, reactToolResult{ + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`, + } + } + + return entries, reactToolResult{ + Tool: "Verify", + Success: true, + Result: `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`, + } +} + +// validateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。 +// +// 步骤化说明: +// 1. 若策略未启用 keep_relative_order,直接通过; +// 2. 否则按时间位置排序 suggested 任务,并映射到 origin_rank; +// 3. 检查 rank 是否单调不降;一旦逆序即判定失败; +// 4. 支持 week 作用域:仅要求每周内保持相对顺序。 +func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToolPolicy) []string { + if !policy.KeepRelativeOrder { + return nil + } + if len(policy.OriginOrderMap) == 0 { + return []string{"未提供顺序基线(origin_order_map)"} + } + + suggested := make([]model.HybridScheduleEntry, 0, len(entries)) + for _, entry := range entries { + // 1. 顺序校验与执行口径必须一致: + // 1.1 这里只校验“可移动 suggested 任务”,避免把 course 等不可移动条目误纳入顺序约束; + // 1.2 若把不可移动条目纳入,会出现“动作层不允许改、顺序层却报错”的左右脑互搏。 + if isMovableSuggestedTask(entry) { + suggested = append(suggested, entry) + } + } + if len(suggested) <= 1 { + return nil + } + 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 + }) + + scope := normalizeOrderScope(policy.OrderScope) + issues := make([]string, 0, 4) + if scope == "week" { + lastRankByWeek := make(map[int]int) + lastNameByWeek := make(map[int]string) + lastIDByWeek := make(map[int]int) + for _, entry := range suggested { + rank, ok := policy.OriginOrderMap[entry.TaskItemID] + if !ok { + issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) + continue + } + last, exists := lastRankByWeek[entry.Week] + if exists && rank < last { + issues = append(issues, fmt.Sprintf( + "W%d 出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", + entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last, + )) + } + lastRankByWeek[entry.Week] = rank + lastNameByWeek[entry.Week] = entry.Name + lastIDByWeek[entry.Week] = entry.TaskItemID + } + return issues + } + + lastRank := -1 + lastName := "" + lastID := 0 + for _, entry := range suggested { + rank, ok := policy.OriginOrderMap[entry.TaskItemID] + if !ok { + issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID)) + continue + } + if lastRank >= 0 && rank < lastRank { + issues = append(issues, fmt.Sprintf( + "出现逆序:任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后", + entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank, + )) + } + lastRank = rank + lastName = entry.Name + lastID = entry.TaskItemID + } + return issues +} + +// normalizeOrderScope 规范化顺序约束作用域。 +func normalizeOrderScope(scope string) string { + switch strings.TrimSpace(strings.ToLower(scope)) { + case "week": + return "week" + default: + return "global" + } +} + +// buildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。 +func buildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) planningWindow { + if len(entries) == 0 { + return planningWindow{Enabled: false} + } + startWeek, startDay := entries[0].Week, entries[0].DayOfWeek + endWeek, endDay := entries[0].Week, entries[0].DayOfWeek + for _, entry := range entries { + if compareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 { + startWeek, startDay = entry.Week, entry.DayOfWeek + } + if compareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 { + endWeek, endDay = entry.Week, entry.DayOfWeek + } + } + return planningWindow{ + Enabled: true, + StartWeek: startWeek, + StartDay: startDay, + EndWeek: endWeek, + EndDay: endDay, + } +} + +// isWithinWindow 判断目标 week/day 是否落在窗口内。 +func isWithinWindow(window planningWindow, week, day int) bool { + if !window.Enabled { + return true + } + if day < 1 || day > 7 { + return false + } + if compareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 { + return false + } + if compareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 { + return false + } + return true +} + +// compareWeekDay 比较两个 week/day 坐标。 +// 返回: +// 1) <0:left 更早; +// 2) =0:相同; +// 3) >0:left 更晚。 +func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int { + if leftWeek != rightWeek { + return leftWeek - rightWeek + } + return leftDay - rightDay +} + +// findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。 +func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { + for i, entry := range entries { + if isMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID { + return i + } + } + return -1 +} + +// findUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。 +// +// 说明: +// 1. “可移动”定义由 isMovableSuggestedTask 统一控制; +// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误,避免把动作落到错误任务上。 +func findUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) { + first := -1 + count := 0 + for idx, entry := range entries { + if !isMovableSuggestedTask(entry) { + continue + } + if entry.TaskItemID != taskItemID { + continue + } + if first < 0 { + first = idx + } + count++ + } + if count == 0 { + return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID) + } + if count > 1 { + return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务,无法唯一定位", taskItemID, count) + } + return first, nil +} + +// isMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。 +// +// 规则: +// 1. 必须是 suggested 且 task_item_id>0; +// 2. type=course 明确禁止移动(即便被错误标记为 suggested); +// 3. 其余类型(含空值)按任务处理,兼容历史快照。 +func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool { + if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 { + return false + } + if strings.EqualFold(strings.TrimSpace(entry.Type), "course") { + return false + } + return true +} + +// hasConflict 检查目标时段是否与其他条目冲突。 +// +// 判断规则: +// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断; +// 2. excludes 中的索引会被跳过(常用于 Move 自身排除或 Swap 双排除)。 +func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) { + for idx, entry := range entries { + if excludes != nil && excludes[idx] { + continue + } + if !entryBlocksSuggestedWithPolicy(entry, allowEmbed) { + continue + } + if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) { + return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type) + } + } + return false, "" +} + +// entryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。 +func entryBlocksSuggested(entry model.HybridScheduleEntry) bool { + return entryBlocksSuggestedWithPolicy(entry, true) +} + +// entryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。 +// +// 策略说明: +// 1. allowEmbed=true:沿用 block_for_suggested 语义; +// 2. allowEmbed=false:existing 一律阻塞,只允许纯空白课位; +// 3. unknown status 保守阻塞,防止漏检。 +func entryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool { + if entry.Status == "suggested" { + return true + } + if entry.Status == "existing" { + if !allowEmbed { + return true + } + return entry.BlockForSuggested + } + // 未知状态保守处理为阻塞,避免写入潜在冲突。 + return true +} + +// sectionsOverlap 判断两个节次区间是否有交叠。 +func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { + return aFrom <= bTo && bFrom <= aTo +} + +// paramInt 从 map 中提取 int 参数,兼容 JSON 常见数值类型。 +func paramInt(params map[string]any, key string) (int, bool) { + raw, ok := params[key] + if !ok { + return 0, false + } + switch v := raw.(type) { + case int: + return v, true + case float64: + return int(v), true + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +// paramIntAny 按“候选键优先级”提取 int 参数。 +// +// 步骤化说明: +// 1. 按传入顺序依次尝试每个 key; +// 2. 命中第一个合法值即返回; +// 3. 全部未命中则返回 false,由上层统一抛参数缺失错误。 +func paramIntAny(params map[string]any, keys ...string) (int, bool) { + for _, key := range keys { + if v, ok := paramInt(params, key); ok { + return v, true + } + } + return 0, false +} + +// paramBool 从 map 中提取 bool 参数,兼容 JSON 常见布尔表示。 +func paramBool(params map[string]any, key string) (bool, bool) { + raw, ok := params[key] + if !ok { + return false, false + } + switch v := raw.(type) { + case bool: + return v, true + case string: + text := strings.TrimSpace(strings.ToLower(v)) + switch text { + case "true", "1", "yes", "y": + return true, true + case "false", "0", "no", "n": + return false, true + default: + return false, false + } + case int: + if v == 1 { + return true, true + } + if v == 0 { + return false, true + } + return false, false + case float64: + if v == 1 { + return true, true + } + if v == 0 { + return false, true + } + return false, false + default: + return false, false + } +} + +// paramBoolAnyWithDefault 按候选键提取 bool,未命中时返回 fallback。 +func paramBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool { + for _, key := range keys { + if v, ok := paramBool(params, key); ok { + return v + } + } + return fallback +} + +// readString 读取字符串参数,缺失时返回默认值。 +func readString(params map[string]any, key string, fallback string) string { + raw, ok := params[key] + if !ok { + return fallback + } + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" { + return fallback + } + return text +} + +// normalizeDayScope 规范化 day_scope 取值。 +func normalizeDayScope(scope string) string { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "weekend": + return "weekend" + case "workday": + return "workday" + default: + return "all" + } +} + +// normalizeStatusFilter 规范化 status 过滤条件。 +func normalizeStatusFilter(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "existing": + return "existing" + case "all": + return "all" + default: + return "suggested" + } +} + +// matchStatusFilter 判断条目状态是否命中 status 过滤。 +func matchStatusFilter(entryStatus string, statusFilter string) bool { + switch strings.ToLower(strings.TrimSpace(statusFilter)) { + case "all": + return true + case "existing": + return strings.TrimSpace(entryStatus) == "existing" + default: + return strings.TrimSpace(entryStatus) == "suggested" + } +} + +// matchDayScope 判断 day_of_week 是否满足 scope 过滤条件。 +func matchDayScope(day int, scope string) bool { + switch scope { + case "weekend": + return day == 6 || day == 7 + case "workday": + return day >= 1 && day <= 5 + default: + return day >= 1 && day <= 7 + } +} + +// intSliceToDaySet 把 day 切片转换为 set,并去除非法 day 值。 +func intSliceToDaySet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item < 1 || item > 7 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// intSliceToWeekSet 把周次切片转换为 set,并去除非正数。 +func intSliceToWeekSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item <= 0 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// intSliceToSectionSet 把节次切片转换为 set,并去除非法节次。 +func intSliceToSectionSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item < 1 || item > 12 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// intSliceToIDSet 把正整数 ID 切片转换为 set。 +func intSliceToIDSet(items []int) map[int]struct{} { + if len(items) == 0 { + return nil + } + set := make(map[int]struct{}, len(items)) + for _, item := range items { + if item <= 0 { + continue + } + set[item] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// inferWeekBounds 推断查询周区间。 +func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) (int, int) { + if window.Enabled { + return window.StartWeek, window.EndWeek + } + if len(entries) == 0 { + return 1, 1 + } + minWeek, maxWeek := entries[0].Week, entries[0].Week + for _, entry := range entries { + if entry.Week < minWeek { + minWeek = entry.Week + } + if entry.Week > maxWeek { + maxWeek = entry.Week + } + } + return minWeek, maxWeek +} + +// buildWeekIterList 构建周次迭代列表。 +// +// 规则: +// 1. weekFilter 非空时,严格按过滤集合遍历; +// 2. weekFilter 为空时,按 weekFrom~weekTo 连续区间遍历; +// 3. 返回结果升序,便于日志与排查。 +func buildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int { + if len(weekFilter) > 0 { + return keysOfIntSet(weekFilter) + } + if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo { + return nil + } + out := make([]int, 0, weekTo-weekFrom+1) + for w := weekFrom; w <= weekTo; w++ { + out = append(out, w) + } + return out +} + +// readIntSlice 读取 int 切片参数,兼容 []any / []int / 单个数值。 +func readIntSlice(params map[string]any, keys ...string) []int { + for _, key := range keys { + raw, ok := params[key] + if !ok { + continue + } + switch v := raw.(type) { + case []int: + out := make([]int, len(v)) + copy(out, v) + return out + case []any: + out := make([]int, 0, len(v)) + for _, item := range v { + switch n := item.(type) { + case int: + out = append(out, n) + case float64: + out = append(out, int(n)) + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { + out = append(out, parsed) + } + } + } + return out + default: + if n, okNum := paramInt(params, key); okNum { + return []int{n} + } + } + } + return nil +} + +// readStringSlice 读取 string 切片参数,兼容 []any / []string / 单个字符串。 +func readStringSlice(params map[string]any, keys ...string) []string { + for _, key := range keys { + raw, ok := params[key] + if !ok || raw == nil { + continue + } + switch vv := raw.(type) { + case []string: + out := make([]string, 0, len(vv)) + for _, item := range vv { + text := strings.TrimSpace(item) + if text != "" { + out = append(out, text) + } + } + return out + case []any: + out := make([]string, 0, len(vv)) + for _, item := range vv { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text != "" { + out = append(out, text) + } + } + return out + case string: + text := strings.TrimSpace(vv) + if text != "" { + return []string{text} + } + default: + text := strings.TrimSpace(fmt.Sprintf("%v", vv)) + if text != "" { + return []string{text} + } + } + } + return nil +} + +// intersectsExcludedSections 判断候选区间是否与排除节次有交集。 +func intersectsExcludedSections(from, to int, excluded map[int]struct{}) bool { + if len(excluded) == 0 { + return false + } + for sec := from; sec <= to; sec++ { + if _, ok := excluded[sec]; ok { + return true + } + } + return false +} + +// keysOfIntSet 返回 int set 的有序键。 +func keysOfIntSet(set map[int]struct{}) []int { + if len(set) == 0 { + return nil + } + keys := make([]int, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Ints(keys) + return keys +} + +// parseBatchMoveParams 解析 BatchMove 的 moves 参数。 +// +// 步骤化说明: +// 1. 先读取 params["moves"],必须存在且为非空数组; +// 2. 再把数组元素逐条转换成 map[string]any,便于复用 refineToolMove; +// 3. 任一元素类型非法即整体失败,避免“部分可执行、部分不可执行”带来的语义歧义。 +func parseBatchMoveParams(params map[string]any) ([]map[string]any, error) { + rawMoves, ok := params["moves"] + if !ok { + return nil, fmt.Errorf("参数缺失:BatchMove 需要 moves 数组") + } + + var items []any + switch v := rawMoves.(type) { + case []any: + items = v + case []map[string]any: + items = make([]any, 0, len(v)) + for _, item := range v { + items = append(items, item) + } + default: + return nil, fmt.Errorf("参数类型错误:BatchMove 的 moves 必须是数组") + } + if len(items) == 0 { + return nil, fmt.Errorf("参数错误:BatchMove 的 moves 不能为空") + } + + moveParamsList := make([]map[string]any, 0, len(items)) + for idx, item := range items { + paramMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("参数类型错误:BatchMove 第%d步不是对象", idx+1) + } + moveParamsList = append(moveParamsList, paramMap) + } + return moveParamsList, nil +} + +// classifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。 +// +// 说明: +// 1. 映射保持与普通 Move 的错误语义一致,便于模型统一处理; +// 2. 这里按失败文案做轻量推断,避免引入跨文件循环依赖。 +func classifyBatchMoveErrorCode(detail string) string { + text := strings.TrimSpace(detail) + switch { + case strings.Contains(text, "顺序约束不满足"): + return "ORDER_VIOLATION" + case strings.Contains(text, "参数缺失"): + return "PARAM_MISSING" + case strings.Contains(text, "目标时段已被"): + return "SLOT_CONFLICT" + case strings.Contains(text, "任务跨度不一致"): + return "SPAN_MISMATCH" + case strings.Contains(text, "超出允许窗口"): + return "OUT_OF_WINDOW" + case strings.Contains(text, "day_of_week"): + return "DAY_INVALID" + case strings.Contains(text, "节次区间"): + return "SECTION_INVALID" + case strings.Contains(text, "未找到 task_item_id"): + return "TASK_NOT_FOUND" + default: + return "BATCH_MOVE_FAILED" + } +} + +// sortHybridEntries 对混合条目做稳定排序,保证日志与预览输出稳定。 +func sortHybridEntries(entries []model.HybridScheduleEntry) { + sort.SliceStable(entries, func(i, j int) bool { + left := entries[i] + right := entries[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.Name < right.Name + }) +} + +// truncate 截断日志内容,避免错误信息无上限增长。 +func truncate(text string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= maxLen { + return text + } + return string(runes[:maxLen]) + "..." +} diff --git a/backend/agent2/node/schedule_refine_tool.go b/backend/agent2/node/schedule_refine_tool.go new file mode 100644 index 0000000..3585821 --- /dev/null +++ b/backend/agent2/node/schedule_refine_tool.go @@ -0,0 +1,4 @@ +package agentnode + +// schedule_refine_tool.go keeps the dual-file layout stable during migration. +// The concrete refine tool implementation remains in schedule_refine_impl for now. diff --git a/backend/agent2/prompt/schedule_refine.go b/backend/agent2/prompt/schedule_refine.go new file mode 100644 index 0000000..f05da85 --- /dev/null +++ b/backend/agent2/prompt/schedule_refine.go @@ -0,0 +1,131 @@ +package agentprompt + +const ( + ScheduleRefineContractPrompt = `You are SmartFlow's schedule refine contract analyzer. +Return exactly one JSON object. + +Schema: +{ + "intent": "short summary", + "strategy": "local_adjust|keep", + "hard_requirements": ["..."], + "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" +} + +Rules: +- Default keep_relative_order=true unless the user explicitly allows reordering. +- If tasks are being moved, strategy must be local_adjust. +- hard_requirements must be concrete and verifiable. +- hard_assertions should be as structured as possible.` + + ScheduleRefinePlannerPrompt = `You are SmartFlow's schedule refine planner. +Return exactly one JSON object: +{ + "summary": "one sentence", + "steps": ["step1","step2","step3"] +} + +Rules: +- Keep 3-4 steps. +- Prefer "inspect first, then act". +- If the goal is even spreading, the steps must mention SpreadEven and success gating. +- If the goal is minimizing context switching, the steps must mention MinContextSwitch and success gating.` + + ScheduleRefineReactPrompt = `You are SmartFlow's single-task micro ReAct executor. +You may do exactly one thing each round: +1. call one tool +2. return done=true + +Tool groups: +- Basic: QueryTargetTasks, QueryAvailableSlots, Move, Swap, BatchMove, Verify +- Composite: SpreadEven, MinContextSwitch + +Return exactly one JSON object: +{ + "done": false, + "summary": "", + "goal_check": "", + "decision": "", + "missing_info": [], + "tool_calls": [ + { + "tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify", + "params": {} + } + ] +} + +Rules: +- At most one tool call. +- If done=true, tool_calls must be []. +- Only modify suggested tasks. +- Do not invent tools. +- Respect REQUIRED_COMPOSITE_TOOL and COMPOSITE_TOOLS_ALLOWED.` + + ScheduleRefinePostReflectPrompt = `You are SmartFlow's post-tool reflector. +Return exactly one JSON object: +{ + "reflection": "", + "next_strategy": "", + "should_stop": false +} + +Rules: +- Base the reflection on the real tool result only. +- If the tool failed, explain the failure reason. +- If should_stop=true, it must mean the goal is already met or further work has low value.` + + ScheduleRefineReviewPrompt = `You are SmartFlow's final refine reviewer. +Return exactly one JSON object: +{ + "pass": true, + "reason": "", + "unmet": [] +} + +Rules: +- If pass=true, unmet must be []. +- If pass=false, reason must state the core gap.` + + ScheduleRefineSummaryPrompt = `You are SmartFlow's result summarizer. +Write a short user-facing summary in 2-4 Chinese sentences: +1. what changed +2. what benefit was achieved +3. if final review still failed, what remains` + + ScheduleRefineRepairPrompt = `You are SmartFlow's one-step repair executor. +The current plan failed final review. +Return exactly one JSON object with exactly one tool call: +{ + "done": false, + "summary": "", + "goal_check": "", + "decision": "", + "missing_info": [], + "tool_calls": [ + { + "tool": "Move|Swap", + "params": {} + } + ] +} + +Use standard Move keys only: +- task_item_id +- to_week +- to_day +- to_section_from +- to_section_to` +) diff --git a/backend/agent2/通用能力接入文档.md b/backend/agent2/通用能力接入文档.md index cbcd5cb..cb65f5d 100644 --- a/backend/agent2/通用能力接入文档.md +++ b/backend/agent2/通用能力接入文档.md @@ -1,82 +1,82 @@ -# agent2 通用能力接入文档 +# agent2 閫氱敤鑳藉姏鎺ュ叆鏂囨。 -## 1. 文档目的 +## 1. 鏂囨。鐩殑 -本文档用于说明 `agent2` 目录下“通用能力”的边界、放置位置、接入方式与维护要求。 +鏈枃妗g敤浜庤鏄?`agent2` 鐩綍涓嬧€滈€氱敤鑳藉姏鈥濈殑杈圭晫銆佹斁缃綅缃€佹帴鍏ユ柟寮忎笌缁存姢瑕佹眰銆? -这里的“通用能力”特指: +杩欓噷鐨勨€滈€氱敤鑳藉姏鈥濈壒鎸囷細 -1. 不只服务于某一个技能链路,而是可能被 `chat`、`quicknote`、`taskquery`、`schedule` 等多个模块共同复用的能力。 -2. 与具体业务语义弱耦合,抽出来后不会强行把某个单一技能的 prompt、状态字段、业务规则污染到其它模块。 -3. 抽出来后,能够明显减少样板代码、降低重复实现和后续迁移成本。 +1. 涓嶅彧鏈嶅姟浜庢煇涓€涓妧鑳介摼璺紝鑰屾槸鍙兘琚?`chat`銆乣quicknote`銆乣taskquery`銆乣schedule` 绛夊涓ā鍧楀叡鍚屽鐢ㄧ殑鑳藉姏銆? +2. 涓庡叿浣撲笟鍔¤涔夊急鑰﹀悎锛屾娊鍑烘潵鍚庝笉浼氬己琛屾妸鏌愪釜鍗曚竴鎶€鑳界殑 prompt銆佺姸鎬佸瓧娈点€佷笟鍔¤鍒欐薄鏌撳埌鍏跺畠妯″潡銆? +3. 鎶藉嚭鏉ュ悗锛岃兘澶熸槑鏄惧噺灏戞牱鏉夸唬鐮併€侀檷浣庨噸澶嶅疄鐜板拰鍚庣画杩佺Щ鎴愭湰銆? -本文档不负责描述某个具体技能的业务流程,技能自身的图编排、状态字段、prompt 细节,应继续放在对应技能目录或对应决策记录中维护。 +鏈枃妗d笉璐熻矗鎻忚堪鏌愪釜鍏蜂綋鎶€鑳界殑涓氬姟娴佺▼锛屾妧鑳借嚜韬殑鍥剧紪鎺掋€佺姸鎬佸瓧娈点€乸rompt 缁嗚妭锛屽簲缁х画鏀惧湪瀵瑰簲鎶€鑳界洰褰曟垨瀵瑰簲鍐崇瓥璁板綍涓淮鎶ゃ€? -## 2. 当前目录分层 +## 2. 褰撳墠鐩綍鍒嗗眰 -### 2.1 总入口层 +### 2.1 鎬诲叆鍙e眰 -文件: +鏂囦欢锛? - `entrance.go` -职责: +鑱岃矗锛? -1. 作为 `agent2` 模块对上层服务的统一入口。 -2. 负责把“路由器 + 各技能 handler”装配到一起。 -3. 不负责具体技能逻辑,不负责直接调模型,也不负责工具执行。 +1. 浣滀负 `agent2` 妯″潡瀵逛笂灞傛湇鍔$殑缁熶竴鍏ュ彛銆? +2. 璐熻矗鎶娾€滆矾鐢卞櫒 + 鍚勬妧鑳?handler鈥濊閰嶅埌涓€璧枫€? +3. 涓嶈礋璐e叿浣撴妧鑳介€昏緫锛屼笉璐熻矗鐩存帴璋冩ā鍨嬶紝涔熶笉璐熻矗宸ュ叿鎵ц銆? -适合放什么: +閫傚悎鏀句粈涔堬細 -1. 模块级入口对象。 -2. 通用注册方法。 -3. 与“总分发”有关的最小门面封装。 +1. 妯″潡绾у叆鍙e璞°€? +2. 閫氱敤娉ㄥ唽鏂规硶銆? +3. 涓庘€滄€诲垎鍙戔€濇湁鍏崇殑鏈€灏忛棬闈㈠皝瑁呫€? -不适合放什么: +涓嶉€傚悎鏀句粈涔堬細 -1. 某个具体技能的节点逻辑。 -2. 具体业务 DAO 调用。 -3. 某个技能独占的 prompt 或状态机。 +1. 鏌愪釜鍏蜂綋鎶€鑳界殑鑺傜偣閫昏緫銆? +2. 鍏蜂綋涓氬姟 DAO 璋冪敤銆? +3. 鏌愪釜鎶€鑳界嫭鍗犵殑 prompt 鎴栫姸鎬佹満銆? -### 2.2 路由层 +### 2.2 璺敱灞? -目录: +鐩綍锛? - `router/` -当前通用能力: +褰撳墠閫氱敤鑳藉姏锛? 1. `Dispatcher` 2. `Resolver` 3. `AgentRequest / AgentResponse` -4. `Action` 与路由控制码解析 +4. `Action` 涓庤矾鐢辨帶鍒剁爜瑙f瀽 -职责: +鑱岃矗锛? -1. 统一处理“请求该走哪条技能链路”的分流问题。 -2. 提供对上层稳定的动作枚举与请求壳结构。 -3. 兼容迁移期的新旧 action 语义,避免上层服务直接依赖旧目录。 +1. 缁熶竴澶勭悊鈥滆姹傝璧板摢鏉℃妧鑳介摼璺€濈殑鍒嗘祦闂銆? +2. 鎻愪緵瀵逛笂灞傜ǔ瀹氱殑鍔ㄤ綔鏋氫妇涓庤姹傚3缁撴瀯銆? +3. 鍏煎杩佺Щ鏈熺殑鏂版棫 action 璇箟锛岄伩鍏嶄笂灞傛湇鍔$洿鎺ヤ緷璧栨棫鐩綍銆? -适合放什么: +閫傚悎鏀句粈涔堬細 -1. 通用路由协议。 -2. 控制码解析。 -3. 分发器。 -4. 所有技能共用的路由请求/响应结构。 +1. 閫氱敤璺敱鍗忚銆? +2. 鎺у埗鐮佽В鏋愩€? +3. 鍒嗗彂鍣ㄣ€? +4. 鎵€鏈夋妧鑳藉叡鐢ㄧ殑璺敱璇锋眰/鍝嶅簲缁撴瀯銆? -不适合放什么: +涓嶉€傚悎鏀句粈涔堬細 -1. 某个技能内部的二次判断。 -2. 某个技能专属的 prompt。 -3. 技能内部重试或状态流转逻辑。 +1. 鏌愪釜鎶€鑳藉唴閮ㄧ殑浜屾鍒ゆ柇銆? +2. 鏌愪釜鎶€鑳戒笓灞炵殑 prompt銆? +3. 鎶€鑳藉唴閮ㄩ噸璇曟垨鐘舵€佹祦杞€昏緫銆? -### 2.3 模型交互层 +### 2.3 妯″瀷浜や簰灞? -目录: +鐩綍锛? - `llm/` -当前通用能力: +褰撳墠閫氱敤鑳藉姏锛? 1. `Client` 2. `GenerateOptions` @@ -85,96 +85,96 @@ 5. `GenerateJSON` 6. `ParseJSONObject` 7. `MergeUsage / CloneUsage` -8. `ark.go` 中的 Ark 适配实现 +8. `ark.go` 涓殑 Ark 閫傞厤瀹炵幇 -职责: +鑱岃矗锛? -1. 统一收口模型调用接口,减少各技能重复拼装 `messages`、`thinking`、`temperature`、`max_tokens`。 -2. 提供通用 JSON 解析与 usage 合并能力。 -3. 把具体厂商 SDK 细节尽量压在适配层,不向上层节点扩散。 +1. 缁熶竴鏀跺彛妯″瀷璋冪敤鎺ュ彛锛屽噺灏戝悇鎶€鑳介噸澶嶆嫾瑁?`messages`銆乣thinking`銆乣temperature`銆乣max_tokens`銆? +2. 鎻愪緵閫氱敤 JSON 瑙f瀽涓?usage 鍚堝苟鑳藉姏銆? +3. 鎶婂叿浣撳巶鍟?SDK 缁嗚妭灏介噺鍘嬪湪閫傞厤灞傦紝涓嶅悜涓婂眰鑺傜偣鎵╂暎銆? -适合放什么: +閫傚悎鏀句粈涔堬細 -1. 所有技能都可能复用的模型调用壳。 -2. 通用 JSON 提取与反序列化。 -3. 流式/非流式调用的统一适配接口。 -4. usage 收敛、空响应错误格式化。 +1. 鎵€鏈夋妧鑳介兘鍙兘澶嶇敤鐨勬ā鍨嬭皟鐢ㄥ3銆? +2. 閫氱敤 JSON 鎻愬彇涓庡弽搴忓垪鍖栥€? +3. 娴佸紡/闈炴祦寮忚皟鐢ㄧ殑缁熶竴閫傞厤鎺ュ彛銆? +4. usage 鏀舵暃銆佺┖鍝嶅簲閿欒鏍煎紡鍖栥€? -不适合放什么: +涓嶉€傚悎鏀句粈涔堬細 -1. 只服务于某一个技能的 prompt 文案。 -2. 某一个技能特有的输出结构体。 -3. 某一个技能独占的“成功文案润色”规则。 +1. 鍙湇鍔′簬鏌愪竴涓妧鑳界殑 prompt 鏂囨銆? +2. 鏌愪竴涓妧鑳界壒鏈夌殑杈撳嚭缁撴瀯浣撱€? +3. 鏌愪竴涓妧鑳界嫭鍗犵殑鈥滄垚鍔熸枃妗堟鼎鑹测€濊鍒欍€? -说明: +璇存槑锛? -1. 如果只是“基于通用 `Client` 再包一层技能专用函数”,例如 quicknote 的聚合规划调用,这种代码可以放在 `llm/`,但文件名应明确带技能语义,避免误认为完全通用能力。 -2. 真正跨技能复用的内容,优先沉到 `client.go`、`ark.go`、`json.go` 这类公共文件。 +1. 濡傛灉鍙槸鈥滃熀浜庨€氱敤 `Client` 鍐嶅寘涓€灞傛妧鑳戒笓鐢ㄥ嚱鏁扳€濓紝渚嬪 quicknote 鐨勮仛鍚堣鍒掕皟鐢紝杩欑浠g爜鍙互鏀惧湪 `llm/`锛屼絾鏂囦欢鍚嶅簲鏄庣‘甯︽妧鑳借涔夛紝閬垮厤璇涓哄畬鍏ㄩ€氱敤鑳藉姏銆? +2. 鐪熸璺ㄦ妧鑳藉鐢ㄧ殑鍐呭锛屼紭鍏堟矇鍒?`client.go`銆乣ark.go`銆乣json.go` 杩欑被鍏叡鏂囦欢銆? -### 2.4 流输出协议层 +### 2.4 娴佽緭鍑哄崗璁眰 -目录: +鐩綍锛? - `stream/` -当前通用能力: +褰撳墠閫氱敤鑳藉姏锛? -1. OpenAI 兼容 chunk DTO -2. reasoning chunk 构造 -3. assistant chunk 构造 -4. finish / done 输出 -5. 阶段推送 emitter +1. OpenAI 鍏煎 chunk DTO +2. reasoning chunk 鏋勯€? +3. assistant chunk 鏋勯€? +4. finish / done 杈撳嚭 +5. 闃舵鎺ㄩ€?emitter -职责: +鑱岃矗锛? -1. 统一处理 SSE / OpenAI 兼容输出格式。 -2. 让 service、graph、node 只关心“我要推什么内容”,而不是自己拼 JSON。 -3. 为后续前端协议升级预留统一修改点。 +1. 缁熶竴澶勭悊 SSE / OpenAI 鍏煎杈撳嚭鏍煎紡銆? +2. 璁?service銆乬raph銆乶ode 鍙叧蹇冣€滄垜瑕佹帹浠€涔堝唴瀹光€濓紝鑰屼笉鏄嚜宸辨嫾 JSON銆? +3. 涓哄悗缁墠绔崗璁崌绾ч鐣欑粺涓€淇敼鐐广€? -适合放什么: +閫傚悎鏀句粈涔堬細 -1. chunk DTO。 -2. reasoning / content / finish 的统一封装。 -3. 阶段消息推送器接口。 +1. chunk DTO銆? +2. reasoning / content / finish 鐨勭粺涓€灏佽銆? +3. 闃舵娑堟伅鎺ㄩ€佸櫒鎺ュ彛銆? -不适合放什么: +涓嶉€傚悎鏀句粈涔堬細 -1. 某个技能的阶段命名表。 -2. 某个技能专属的正文文案。 -3. 具体业务状态对象。 +1. 鏌愪釜鎶€鑳界殑闃舵鍛藉悕琛ㄣ€? +2. 鏌愪釜鎶€鑳戒笓灞炵殑姝f枃鏂囨銆? +3. 鍏蜂綋涓氬姟鐘舵€佸璞°€? -### 2.5 共享工具层 +### 2.5 鍏变韩宸ュ叿灞? -目录: +鐩綍锛? - `shared/` -当前通用能力: +褰撳墠閫氱敤鑳藉姏锛? -1. 时间格式化与分钟级归一化 -2. 深拷贝 -3. 通用重试辅助 +1. 鏃堕棿鏍煎紡鍖栦笌鍒嗛挓绾у綊涓€鍖? +2. 娣辨嫹璐? +3. 閫氱敤閲嶈瘯杈呭姪 -职责: +鑱岃矗锛? -1. 承载纯工具型、无业务语义、无技能耦合的辅助函数。 -2. 作为多个技能都能直接复用的最底层工具层。 +1. 鎵胯浇绾伐鍏峰瀷銆佹棤涓氬姟璇箟銆佹棤鎶€鑳借€﹀悎鐨勮緟鍔╁嚱鏁般€? +2. 浣滀负澶氫釜鎶€鑳介兘鑳界洿鎺ュ鐢ㄧ殑鏈€搴曞眰宸ュ叿灞傘€? -适合放什么: +閫傚悎鏀句粈涔堬細 -1. 时间工具。 -2. clone 工具。 -3. retry 帮助函数。 -4. 纯函数型小工具。 +1. 鏃堕棿宸ュ叿銆? +2. clone 宸ュ叿銆? +3. retry 甯姪鍑芥暟銆? +4. 绾嚱鏁板瀷灏忓伐鍏枫€? -不适合放什么: +涓嶉€傚悎鏀句粈涔堬細 -1. 夹带具体技能字段名的工具。 -2. 依赖数据库、缓存、模型、路由动作的逻辑。 +1. 澶瑰甫鍏蜂綋鎶€鑳藉瓧娈靛悕鐨勫伐鍏枫€? +2. 渚濊禆鏁版嵁搴撱€佺紦瀛樸€佹ā鍨嬨€佽矾鐢卞姩浣滅殑閫昏緫銆? -### 2.6 技能内部层 +### 2.6 鎶€鑳藉唴閮ㄥ眰 -目录: +鐩綍锛? - `graph/` - `node/` @@ -182,156 +182,165 @@ - `model/` - `chat/` -职责: +鑱岃矗锛? -1. 这几层主要承载技能内部能力。 -2. 即使其中某个文件现在位于 `agent2` 根体系内,只要它带明显技能语义,就不要误判成“通用能力”。 +1. 杩欏嚑灞備富瑕佹壙杞芥妧鑳藉唴閮ㄨ兘鍔涖€? +2. 鍗充娇鍏朵腑鏌愪釜鏂囦欢鐜板湪浣嶄簬 `agent2` 鏍逛綋绯诲唴锛屽彧瑕佸畠甯︽槑鏄炬妧鑳借涔夛紝灏变笉瑕佽鍒ゆ垚鈥滈€氱敤鑳藉姏鈥濄€? -判断标准: +鍒ゆ柇鏍囧噯锛? -1. 如果代码里天然绑定某个技能状态结构、某个技能阶段名、某个技能 prompt 输出契约,一般不应硬抽成通用能力。 -2. 如果只是多个技能都重复了同一段样板代码,且抽出后不会让抽象变形,就应该评估下沉。 +1. 濡傛灉浠g爜閲屽ぉ鐒剁粦瀹氭煇涓妧鑳界姸鎬佺粨鏋勩€佹煇涓妧鑳介樁娈靛悕銆佹煇涓妧鑳?prompt 杈撳嚭濂戠害锛屼竴鑸笉搴旂‖鎶芥垚閫氱敤鑳藉姏銆? +2. 濡傛灉鍙槸澶氫釜鎶€鑳介兘閲嶅浜嗗悓涓€娈垫牱鏉夸唬鐮侊紝涓旀娊鍑哄悗涓嶄細璁╂娊璞″彉褰紝灏卞簲璇ヨ瘎浼颁笅娌夈€? -### 2.7 图层与节点层的协作约定 +### 2.7 鍥惧眰涓庤妭鐐瑰眰鐨勫崗浣滅害瀹? -这是当前 `agent2` 已经明确下来的结构约束: +杩欐槸褰撳墠 `agent2` 宸茬粡鏄庣‘涓嬫潵鐨勭粨鏋勭害鏉燂細 -1. `graph/` 只负责“画图”: - - 注册节点 - - 添加边 - - 添加分支 - - 编译与运行图 -2. `graph/` 不再负责: - - 额外创建 runner 适配层 - - 在图内继续堆请求级依赖转发逻辑 - - 直接实现节点业务 -3. `node/` 负责: - - 定义节点容器(例如 `QuickNoteNodes`) - - 通过对象方法直接向 graph 暴露可挂载节点 - - 在节点方法内部消费请求级依赖 +1. `graph/` 鍙礋璐b€滅敾鍥锯€濓細 + - 娉ㄥ唽鑺傜偣 + - 娣诲姞杈? + - 娣诲姞鍒嗘敮 + - 缂栬瘧涓庤繍琛屽浘 +2. `graph/` 涓嶅啀璐熻矗锛? + - 棰濆鍒涘缓 runner 閫傞厤灞? + - 鍦ㄥ浘鍐呯户缁爢璇锋眰绾т緷璧栬浆鍙戦€昏緫 + - 鐩存帴瀹炵幇鑺傜偣涓氬姟 +3. `node/` 璐熻矗锛? + - 瀹氫箟鑺傜偣瀹瑰櫒锛堜緥濡?`QuickNoteNodes`锛? + - 閫氳繃瀵硅薄鏂规硶鐩存帴鍚?graph 鏆撮湶鍙寕杞借妭鐐? + - 鍦ㄨ妭鐐规柟娉曞唴閮ㄦ秷璐硅姹傜骇渚濊禆 -推荐形态: +鎺ㄨ崘褰㈡€侊細 -1. `graph` 里直接挂: +1. `graph` 閲岀洿鎺ユ寕锛? - `nodes.Intent` - `nodes.Priority` - `nodes.Persist` - `nodes.Exit` -2. 分支也直接挂: +2. 鍒嗘敮涔熺洿鎺ユ寕锛? - `nodes.NextAfterIntent` - `nodes.NextAfterPersist` -3. 不推荐再额外引入 `runner -> node` 这种转接层。 +3. 涓嶆帹鑽愬啀棰濆寮曞叆 `runner -> node` 杩欑杞帴灞傘€? -这样设计的目的: +杩欐牱璁捐鐨勭洰鐨勶細 -1. 避免 graph 文件随着模块变多再次长成“大装配文件”。 -2. 让“请求级依赖注入”回到 node 层自己的节点容器里。 -3. 让阅读路径稳定成: - - 先看 graph 知道流程图 - - 再跳 node 看节点方法实现 - - 不需要在 graph 和 runner 两层之间来回跳。 +1. 閬垮厤 graph 鏂囦欢闅忕潃妯″潡鍙樺鍐嶆闀挎垚鈥滃ぇ瑁呴厤鏂囦欢鈥濄€? +2. 璁┾€滆姹傜骇渚濊禆娉ㄥ叆鈥濆洖鍒?node 灞傝嚜宸辩殑鑺傜偣瀹瑰櫒閲屻€? +3. 璁╅槄璇昏矾寰勭ǔ瀹氭垚锛? + - 鍏堢湅 graph 鐭ラ亾娴佺▼鍥? + - 鍐嶈烦 node 鐪嬭妭鐐规柟娉曞疄鐜? + - 涓嶉渶瑕佸湪 graph 鍜?runner 涓ゅ眰涔嬮棿鏉ュ洖璺炽€? -## 3. 什么应该抽成通用能力 +## 3. 浠€涔堝簲璇ユ娊鎴愰€氱敤鑳藉姏 -满足以下任意两条,一般就应该认真评估抽公共层: +婊¤冻浠ヤ笅浠绘剰涓ゆ潯锛屼竴鑸氨搴旇璁ょ湡璇勪及鎶藉叕鍏卞眰锛? -1. 在第二个技能里出现了明显重复实现。 -2. 这段逻辑本质上不依赖某个技能独占状态。 -3. 抽出来后接口可以做到“入参少、职责清、语义稳定”。 -4. 上层重复代码主要是在做样板装配,而不是业务决策。 +1. 鍦ㄧ浜屼釜鎶€鑳介噷鍑虹幇浜嗘槑鏄鹃噸澶嶅疄鐜般€? +2. 杩欐閫昏緫鏈川涓婁笉渚濊禆鏌愪釜鎶€鑳界嫭鍗犵姸鎬併€? +3. 鎶藉嚭鏉ュ悗鎺ュ彛鍙互鍋氬埌鈥滃叆鍙傚皯銆佽亴璐f竻銆佽涔夌ǔ瀹氣€濄€? +4. 涓婂眰閲嶅浠g爜涓昏鏄湪鍋氭牱鏉胯閰嶏紝鑰屼笉鏄笟鍔″喅绛栥€? -典型例子: +鍏稿瀷渚嬪瓙锛? -1. 模型消息拼装。 -2. JSON 提取与解析。 -3. usage 合并。 -4. OpenAI chunk 构造。 -5. 时间归一化。 -6. 深拷贝与重试工具。 -7. 总入口路由与技能分发。 +1. 妯″瀷娑堟伅鎷艰銆? +2. JSON 鎻愬彇涓庤В鏋愩€? +3. usage 鍚堝苟銆? +4. OpenAI chunk 鏋勯€犮€? +5. 鏃堕棿褰掍竴鍖栥€? +6. 娣辨嫹璐濅笌閲嶈瘯宸ュ叿銆? +7. 鎬诲叆鍙h矾鐢变笌鎶€鑳藉垎鍙戙€? -## 4. 什么不应该强行抽公共层 +## 4. 浠€涔堜笉搴旇寮鸿鎶藉叕鍏卞眰 -出现以下情况时,不要为了“看起来复用”而硬抽: +鍑虹幇浠ヤ笅鎯呭喌鏃讹紝涓嶈涓轰簡鈥滅湅璧锋潵澶嶇敤鈥濊€岀‖鎶斤細 -1. 抽完之后函数签名反而要塞一堆技能专属参数。 -2. 公共层开始知道某个技能的状态字段、阶段名、错误文案。 -3. 表面相似,实则每个技能的业务约束完全不同。 -4. 为了复用而引入大量 `if action == xxx`、`switch skill` 这类分支。 +1. 鎶藉畬涔嬪悗鍑芥暟绛惧悕鍙嶈€岃濉炰竴鍫嗘妧鑳戒笓灞炲弬鏁般€? +2. 鍏叡灞傚紑濮嬬煡閬撴煇涓妧鑳界殑鐘舵€佸瓧娈点€侀樁娈靛悕銆侀敊璇枃妗堛€? +3. 琛ㄩ潰鐩镐技锛屽疄鍒欐瘡涓妧鑳界殑涓氬姟绾︽潫瀹屽叏涓嶅悓銆? +4. 涓轰簡澶嶇敤鑰屽紩鍏ュぇ閲?`if action == xxx`銆乣switch skill` 杩欑被鍒嗘敮銆? -典型例子: +鍏稿瀷渚嬪瓙锛? -1. quicknote 的优先级判定输出结构。 -2. taskquery 的查询规划字段。 -3. schedule 的排程状态快照。 -4. 某个技能特有的 prompt 模板。 +1. quicknote 鐨勪紭鍏堢骇鍒ゅ畾杈撳嚭缁撴瀯銆? +2. taskquery 鐨勬煡璇㈣鍒掑瓧娈点€? +3. schedule 鐨勬帓绋嬬姸鎬佸揩鐓с€? +4. 鏌愪釜鎶€鑳界壒鏈夌殑 prompt 妯℃澘銆? -## 5. 新增通用能力的接入步骤 +## 5. 鏂板閫氱敤鑳藉姏鐨勬帴鍏ユ楠? -### 5.1 先判断是否值得抽 +### 5.1 鍏堝垽鏂槸鍚﹀€煎緱鎶? -1. 先确认这段逻辑是否已经在第二处出现重复。 -2. 再确认它是不是可以脱离单一技能语义独立存在。 -3. 如果暂时还不能抽,也要在代码注释或决策记录里写明原因,避免后面第三次重复时忘记。 +1. 鍏堢‘璁よ繖娈甸€昏緫鏄惁宸茬粡鍦ㄧ浜屽鍑虹幇閲嶅銆? +2. 鍐嶇‘璁ゅ畠鏄笉鏄彲浠ヨ劚绂诲崟涓€鎶€鑳借涔夌嫭绔嬪瓨鍦ㄣ€? +3. 濡傛灉鏆傛椂杩樹笉鑳芥娊锛屼篃瑕佸湪浠g爜娉ㄩ噴鎴栧喅绛栬褰曢噷鍐欐槑鍘熷洜锛岄伩鍏嶅悗闈㈢涓夋閲嶅鏃跺繕璁般€? -### 5.2 选择落点目录 +### 5.2 閫夋嫨钀界偣鐩綍 -按职责优先落到以下目录: +鎸夎亴璐d紭鍏堣惤鍒颁互涓嬬洰褰曪細 -1. 路由协议与分发:`router/` -2. 模型调用与 JSON 解析:`llm/` -3. 流输出协议:`stream/` -4. 纯工具:`shared/` -5. 技能专属但可复用的壳:放对应技能语义文件,不要伪装成完全公共层 +1. 璺敱鍗忚涓庡垎鍙戯細`router/` +2. 妯″瀷璋冪敤涓?JSON 瑙f瀽锛歚llm/` +3. 娴佽緭鍑哄崗璁細`stream/` +4. 绾伐鍏凤細`shared/` +5. 鎶€鑳戒笓灞炰絾鍙鐢ㄧ殑澹筹細鏀惧搴旀妧鑳借涔夋枃浠讹紝涓嶈浼鎴愬畬鍏ㄥ叕鍏卞眰 -### 5.3 定义最小接口 +### 5.3 瀹氫箟鏈€灏忔帴鍙? -1. 先定义最小可复用接口,只暴露上层真正需要的能力。 -2. 不要把下层 SDK、DAO、缓存实现细节直接透传到所有调用方。 -3. 优先让“公共层知道得更少”,而不是让“上层为了复用被迫知道更多”。 +1. 鍏堝畾涔夋渶灏忓彲澶嶇敤鎺ュ彛锛屽彧鏆撮湶涓婂眰鐪熸闇€瑕佺殑鑳藉姏銆? +2. 涓嶈鎶婁笅灞?SDK銆丏AO銆佺紦瀛樺疄鐜扮粏鑺傜洿鎺ラ€忎紶鍒版墍鏈夎皟鐢ㄦ柟銆? +3. 浼樺厛璁┾€滃叕鍏卞眰鐭ラ亾寰楁洿灏戔€濓紝鑰屼笉鏄鈥滀笂灞備负浜嗗鐢ㄨ杩煡閬撴洿澶氣€濄€? -### 5.4 补注释 +### 5.4 琛ユ敞閲? -必须写清楚: +蹇呴』鍐欐竻妤氾細 -1. 这个通用能力负责什么。 -2. 不负责什么。 -3. 为什么它适合抽到公共层。 -4. 失败时由谁兜底。 +1. 杩欎釜閫氱敤鑳藉姏璐熻矗浠€涔堛€? +2. 涓嶈礋璐d粈涔堛€? +3. 涓轰粈涔堝畠閫傚悎鎶藉埌鍏叡灞傘€? +4. 澶辫触鏃剁敱璋佸厹搴曘€? -### 5.5 补测试 +### 5.5 琛ユ祴璇? -至少覆盖: +鑷冲皯瑕嗙洊锛? -1. 正常路径。 -2. 关键边界。 -3. 明确的失败路径。 +1. 姝e父璺緞銆? +2. 鍏抽敭杈圭晫銆? +3. 鏄庣‘鐨勫け璐ヨ矾寰勩€? -如果迁移期暂时没法完整补齐,也要优先保证公共函数本身有最小回归测试。 +濡傛灉杩佺Щ鏈熸殏鏃舵病娉曞畬鏁磋ˉ榻愶紝涔熻浼樺厛淇濊瘉鍏叡鍑芥暟鏈韩鏈夋渶灏忓洖褰掓祴璇曘€? -### 5.6 更新本文档 +### 5.6 鏇存柊鏈枃妗? -只要出现以下任一情况,必须同步更新本文档: +鍙鍑虹幇浠ヤ笅浠讳竴鎯呭喌锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛? -1. 新增了一个通用能力。 -2. 调整了某个通用能力的落点目录。 -3. 修改了某个公共接口的职责边界。 -4. 删掉了某个旧的公共实现,并由新实现替代。 +1. 鏂板浜嗕竴涓€氱敤鑳藉姏銆? +2. 璋冩暣浜嗘煇涓€氱敤鑳藉姏鐨勮惤鐐圭洰褰曘€? +3. 淇敼浜嗘煇涓叕鍏辨帴鍙g殑鑱岃矗杈圭晫銆? +4. 鍒犳帀浜嗘煇涓棫鐨勫叕鍏卞疄鐜帮紝骞剁敱鏂板疄鐜版浛浠c€? -## 6. 推荐接入模板 +## 6. 鎺ㄨ崘鎺ュ叆妯℃澘 -可以按下面这个思路接入: +鍙互鎸変笅闈㈣繖涓€濊矾鎺ュ叆锛? -1. 先在技能代码里识别重复片段。 -2. 提炼出“最小公共函数 / 最小公共结构体 / 最小公共接口”。 -3. 放进 `router / llm / stream / shared` 之一。 -4. 先让新技能接这个公共实现。 -5. 再逐步回收旧技能里重复的老代码。 -6. 最后补本文档,说明这个能力现在归谁管、上层该怎么用。 +1. 鍏堝湪鎶€鑳戒唬鐮侀噷璇嗗埆閲嶅鐗囨銆? +2. 鎻愮偧鍑衡€滄渶灏忓叕鍏卞嚱鏁?/ 鏈€灏忓叕鍏辩粨鏋勪綋 / 鏈€灏忓叕鍏辨帴鍙b€濄€? +3. 鏀捐繘 `router / llm / stream / shared` 涔嬩竴銆? +4. 鍏堣鏂版妧鑳芥帴杩欎釜鍏叡瀹炵幇銆? +5. 鍐嶉€愭鍥炴敹鏃ф妧鑳介噷閲嶅鐨勮€佷唬鐮併€? +6. 鏈€鍚庤ˉ鏈枃妗o紝璇存槑杩欎釜鑳藉姏鐜板湪褰掕皝绠°€佷笂灞傝鎬庝箞鐢ㄣ€? -## 7. 当前维护要求 +## 7. 褰撳墠缁存姢瑕佹眰 + +1. `agent2` 鐨勫叕鍏卞眰瑕佸敖閲忎繚鎸佲€滀綆鑰﹀悎銆佸己娉ㄩ噴銆佹槗杩佺Щ鈥濄€? +2. 鏂版妧鑳藉紑鍙戞椂锛屼紭鍏堝鐢ㄨ繖閲屽凡鏈夌殑鍏叡鑳藉姏锛岃€屼笉鏄洿鎺ュ鍒舵棫鎶€鑳戒唬鐮併€? +3. 濡傛灉鍙戠幇鏌愭閫昏緫宸茬粡鍑虹幇绗簩浠藉疄鐜帮紝搴斾紭鍏堣瘎浼版娊鍏叡灞傦紝鑰屼笉鏄户缁啓绗笁浠姐€? +4. 鍚庣画鍙鎵╁睍閫氱敤鑳藉姏锛屽繀椤诲悓姝ユ洿鏂版湰鏂囨。锛屽惁鍒欒涓鸿縼绉绘垨閲嶆瀯鏈畬鎴愩€? + +## 8. 2026-03-25 schedule_refine 鎺ュ叆璁板綍 + +1. 鏂板 `agent2/node/schedule_refine_impl` 鐩綍锛屽鍒舵壙鎺ュ師 `agent/schedulerefine` 鍏ㄩ噺杩炵画寰皟瀹炵幇锛坓raph + runner + state + nodes + tool + prompt锛夈€?2. `agent2/node/schedule_refine.go` 浣滀负 node 灞傜粺涓€闂ㄩ潰锛氬澶栨毚闇?`ScheduleRefineState`銆乣ScheduleRefineGraphRunInput`銆乣NewScheduleRefineState`銆乣RunScheduleRefineGraph`銆乣FinalHardCheckPassed`銆?3. `agent2/node/schedule_refine_tool.go` 淇濈暀鍙屾枃浠舵牸灞€涓殑宸ュ叿鎵胯浇浣嶏紝褰撳墠宸ュ叿鍏蜂綋瀹炵幇涓嬫矇鍦?`agent2/node/schedule_refine_impl/tool.go`銆?4. `agent2/graph/schedule.go` 宸叉柊澧?`RunScheduleRefineGraph`锛岄€氳繃 node 闂ㄩ潰杩涘叆杩炵画寰皟鍥俱€?5. `service/agentsvc/agent_schedule_refine.go` 宸插垏娴佸埌 agent2锛氱姸鎬佸垵濮嬪寲銆佸浘鎵ц銆佺粓瀹¢€氳繃鍒ゅ畾鍧囦笉鍐嶄緷璧栨棫 `agent/schedulerefine`銆?6. 鍏煎璇存槑锛氭棫 `agent/schedulerefine` 鐩綍鏆備繚鐣欙紝浣滀负杩佺Щ鏈熷苟琛屽疄鐜帮紝褰撳墠鐢熶骇鍏ュ彛宸叉寚鍚?agent2 閾捐矾銆? + +## 9. 2026-03-26 schedule_refine 缁撴瀯淇锛堣ˉ璁帮級 + +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` 的公共层要尽量保持“低耦合、强注释、易迁移”。 -2. 新技能开发时,优先复用这里已有的公共能力,而不是直接复制旧技能代码。 -3. 如果发现某段逻辑已经出现第二份实现,应优先评估抽公共层,而不是继续写第三份。 -4. 后续只要扩展通用能力,必须同步更新本文档,否则视为迁移或重构未完成。 diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go index 7942384..0c7f688 100644 --- a/backend/service/agentsvc/agent_schedule_preview.go +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -7,21 +7,20 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" ) -// saveSchedulePlanPreview 把排程结果以结构化 JSON 快照写入 Redis。 +// saveSchedulePlanPreview 鎶婃帓绋嬬粨鏋滀互缁撴瀯鍖?JSON 蹇収鍐欏叆 Redis銆? // -// 职责边界: -// 1. 负责把 finalState 中的 summary + candidate_plans 收敛为缓存 DTO; -// 2. 负责以“失败不阻断聊天主链路”的策略执行写入; -// 3. 不负责 SSE 返回协议,不负责数据库落库。 -func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *scheduleplan.SchedulePlanState) { - // 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。 +// 鑱岃矗杈圭晫锛? +// 1. 璐熻矗鎶?finalState 涓殑 summary + candidate_plans 鏀舵暃涓虹紦瀛?DTO锛? +// 2. 璐熻矗浠モ€滃け璐ヤ笉闃绘柇鑱婂ぉ涓婚摼璺€濈殑绛栫暐鎵ц鍐欏叆锛? +// 3. 涓嶈礋璐?SSE 杩斿洖鍗忚锛屼笉璐熻矗鏁版嵁搴撹惤搴撱€? +func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) { + // 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€? if s == nil || finalState == nil { return } @@ -30,10 +29,10 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, return } - // 2. 组装缓存快照: - // 2.1 summary 优先取 final summary,空值时使用统一兜底文案; - // 2.2 candidate_plans 做切片拷贝,避免后续引用共享导致意外覆盖; - // 2.3 generated_at 用于前端判断“当前预览的新鲜度”。 + // 2. 缁勮缂撳瓨蹇収锛? + // 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛? + // 2.2 candidate_plans 鍋氬垏鐗囨嫹璐濓紝閬垮厤鍚庣画寮曠敤鍏变韩瀵艰嚧鎰忓瑕嗙洊锛? + // 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堢殑鏂伴矞搴︹€濄€? summary := strings.TrimSpace(finalState.FinalSummary) if summary == "" { summary = "排程流程已完成,但未生成结果摘要。" @@ -50,35 +49,35 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, GeneratedAt: time.Now(), } - // 3. 调用目的:先写 Redis 预览,保证前端查询接口能快速读取结构化结果。 - // 3.1 Redis 是“快路径”;失败只记录日志,不中断主链路; - // 3.2 失败兜底由后续 MySQL 快照承接。 + // 3. 璋冪敤鐩殑锛氬厛鍐?Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘蹇€熻鍙栫粨鏋勫寲缁撴灉銆? + // 3.1 Redis 鏄€滃揩璺緞鈥濓紱澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂富閾捐矾锛? + // 3.2 澶辫触鍏滃簳鐢卞悗缁?MySQL 蹇収鎵挎帴銆? if s.cacheDAO != nil { if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil { - log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err) } } - // 4. 调用目的:同步写 MySQL 状态快照,保证 Redis 失效后仍可连续微调。 - // 4.1 这里采用“同步写库”而不是 outbox:因为下一轮微调要强实时读取; - // 4.2 快照写入失败只打日志,不阻断本轮用户回复,避免体验抖动; - // 4.3 revision 自增由 DAO 的 upsert 冲突更新负责。 + // 4. 璋冪敤鐩殑锛氬悓姝ュ啓 MySQL 鐘舵€佸揩鐓э紝淇濊瘉 Redis 澶辨晥鍚庝粛鍙繛缁井璋冦€? + // 4.1 杩欓噷閲囩敤鈥滃悓姝ュ啓搴撯€濊€屼笉鏄?outbox锛氬洜涓轰笅涓€杞井璋冭寮哄疄鏃惰鍙栵紱 + // 4.2 蹇収鍐欏叆澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆鐢ㄦ埛鍥炲锛岄伩鍏嶄綋楠屾姈鍔紱 + // 4.3 revision 鑷鐢?DAO 鐨?upsert 鍐茬獊鏇存柊璐熻矗銆? if s.repo != nil { snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState) if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil { - log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err) } } } -// saveSchedulePlanPreviewAgent2 把 agent2 的 schedule_plan 结果写入 Redis 预览与 MySQL 快照。 +// saveSchedulePlanPreviewAgent2 鎶?agent2 鐨?schedule_plan 缁撴灉鍐欏叆 Redis 棰勮涓?MySQL 蹇収銆? // -// 职责边界: -// 1. 负责承接“新 agent2 首次排程链路”的最终状态; -// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写; -// 3. 不负责 refine 状态转换,refine 仍继续走旧链路的 saveSchedulePlanPreview。 +// 鑱岃矗杈圭晫锛? +// 1. 璐熻矗鎵挎帴鈥滄柊 agent2 棣栨鎺掔▼閾捐矾鈥濈殑鏈€缁堢姸鎬侊紱 +// 2. 璐熻矗娌跨敤鐜版湁棰勮缂撳瓨/鐘舵€佸揩鐓у崗璁紝淇濊瘉鏌ヨ鎺ュ彛涓?refine 璇诲彇閫昏緫涓嶉渶瑕佽窡鐫€閲嶅啓锛? +// 3. 涓嶈礋璐?refine 鐘舵€佽浆鎹紝refine 浠嶇户缁蛋鏃ч摼璺殑 saveSchedulePlanPreview銆? func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) { - // 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。 + // 1. 鍩虹鍓嶇疆鏍¢獙锛歴tate 涓虹┖鏃剁洿鎺ヨ繑鍥烇紝閬垮厤鍐欏叆鍗婃垚鍝佸揩鐓с€? if s == nil || finalState == nil { return } @@ -87,10 +86,10 @@ func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID return } - // 2. 组装缓存快照。 - // 2.1 summary 优先取 final summary,空值时使用统一兜底文案; - // 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片; - // 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。 + // 2. 缁勮缂撳瓨蹇収銆? + // 2.1 summary 浼樺厛鍙?final summary锛岀┖鍊兼椂浣跨敤缁熶竴鍏滃簳鏂囨锛? + // 2.2 candidate_plans / hybrid_entries / allocated_items 缁熶竴娣辨嫹璐濓紝閬垮厤缂撳瓨涓?graph state 鍏辩敤搴曞眰鍒囩墖锛? + // 2.3 generated_at 鐢ㄤ簬鍓嶇鍒ゆ柇鈥滃綋鍓嶉瑙堟槸鍚︿负鏈€鏂版柟妗堚€濄€? summary := strings.TrimSpace(finalState.FinalSummary) if summary == "" { summary = "排程流程已完成,但未生成结果摘要。" @@ -107,34 +106,34 @@ func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID GeneratedAt: time.Now(), } - // 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。 - // 3.1 Redis 是“快路径”; - // 3.2 失败只记录日志,不中断聊天主链路。 + // 3. 鍏堝啓 Redis 棰勮锛屼繚璇佸墠绔煡璇㈡帴鍙h兘绔嬪嵆璇诲彇缁撴瀯鍖栫粨鏋溿€? + // 3.1 Redis 鏄€滃揩璺緞鈥濓紱 + // 3.2 澶辫触鍙褰曟棩蹇楋紝涓嶄腑鏂亰澶╀富閾捐矾銆? if s.cacheDAO != nil { if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil { - log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("鍐欏叆鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err) } } - // 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。 - // 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖; - // 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。 + // 4. 鍚屾鍐?MySQL 蹇収锛屼繚璇?Redis 澶辨晥鍚庝粛鑳芥仮澶嶉瑙堜笌杩炵画寰皟涓婁笅鏂囥€? + // 4.1 杩欓噷缁х画淇濇寔鈥滃悓姝ュ啓搴撯€濈瓥鐣ワ紝鍥犱负涓嬩竴杞井璋冨蹇収璇诲彇鏄己瀹炴椂渚濊禆锛? + // 4.2 鍐欏簱澶辫触鍙墦鏃ュ織锛屼笉闃绘柇鏈疆缁欑敤鎴风殑鏂囨湰鍥炲銆? if s.repo != nil { snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState) if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil { - log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("鍐欏叆鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err) } } } -// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。 +// GetSchedulePlanPreview 鎸?conversation_id 璇诲彇缁撴瀯鍖栨帓绋嬮瑙堛€? // -// 职责边界: -// 1. 负责参数归一化、缓存读取与会话归属校验; -// 2. 负责把缓存 DTO 转成 API 响应 DTO; -// 3. 不负责触发排程,不负责补算缓存。 +// 鑱岃矗杈圭晫锛? +// 1. 璐熻矗鍙傛暟褰掍竴鍖栥€佺紦瀛樿鍙栦笌浼氳瘽褰掑睘鏍¢獙锛? +// 2. 璐熻矗鎶婄紦瀛?DTO 杞垚 API 鍝嶅簲 DTO锛? +// 3. 涓嶈礋璐hЕ鍙戞帓绋嬶紝涓嶈礋璐hˉ绠楃紦瀛樸€? func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) { - // 1. 参数校验:conversation_id 为空直接返回参数错误,避免无效 Redis 请求。 + // 1. 鍙傛暟鏍¢獙锛歝onversation_id 涓虹┖鐩存帴杩斿洖鍙傛暟閿欒锛岄伩鍏嶆棤鏁?Redis 璇锋眰銆? normalizedChatID := strings.TrimSpace(chatID) if normalizedChatID == "" { return nil, respond.MissingParam @@ -143,10 +142,10 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c return nil, errors.New("agent service is not initialized") } - // 2. 查询缓存并校验归属: - // 2.1 缓存未命中:统一返回“预览不存在/已过期”; - // 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息; - // 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。 + // 2. 鏌ヨ缂撳瓨骞舵牎楠屽綊灞烇細 + // 2.1 缂撳瓨鏈懡涓細缁熶竴杩斿洖鈥滈瑙堜笉瀛樺湪/宸茶繃鏈熲€濓紱 + // 2.2 鍛戒腑浣?user_id 涓嶄竴鑷达細鎸夋湭鍛戒腑澶勭悊锛岄伩鍏嶆硠闇蹭粬浜轰細璇濅俊鎭紱 + // 2.3 澶辫触鍏滃簳锛氱紦瀛樿寮傚父鐩存帴涓婃姏锛岀敱 API 灞傜粺涓€閿欒澶勭悊銆? if s.cacheDAO != nil { preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) if err != nil { @@ -170,10 +169,10 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c } } - // 3. Redis 未命中时回落 MySQL 快照: - // 3.1 读取成功后直接返回,避免用户看到“预览不存在”的假阴性; - // 3.2 若本次命中 DB 且缓存可用,则顺手回填 Redis,提升后续命中率; - // 3.3 DB 也未命中时再返回 not found。 + // 3. Redis 鏈懡涓椂鍥炶惤 MySQL 蹇収锛? + // 3.1 璇诲彇鎴愬姛鍚庣洿鎺ヨ繑鍥烇紝閬垮厤鐢ㄦ埛鐪嬪埌鈥滈瑙堜笉瀛樺湪鈥濈殑鍋囬槾鎬э紱 + // 3.2 鑻ユ湰娆″懡涓?DB 涓旂紦瀛樺彲鐢紝鍒欓『鎵嬪洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛? + // 3.3 DB 涔熸湭鍛戒腑鏃跺啀杩斿洖 not found銆? if s.repo != nil { snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) if err != nil { @@ -184,7 +183,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c if s.cacheDAO != nil { cachePreview := snapshotToSchedulePlanPreviewCache(snapshot) if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil { - log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr) + log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr) } } return response, nil @@ -193,28 +192,28 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c return nil, respond.SchedulePlanPreviewNotFound } -// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。 +// cloneWeekSchedules 瀵瑰懆瑙嗗浘鎺掔▼缁撴灉鍋氭繁鎷疯礉锛岄伩鍏嶅垏鐗囧紩鐢ㄥ叡浜€? func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { return agentshared.CloneWeekSchedules(src) } -// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。 +// cloneHybridEntries 娣辨嫹璐濇贩鍚堟潯鐩垏鐗囷紝閬垮厤缂撳瓨/鐘舵€佷箣闂寸浉浜掓薄鏌撱€? func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { return agentshared.CloneHybridEntries(src) } -// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。 +// cloneTaskClassItems 娣辨嫹璐濅换鍔″潡鍒囩墖锛堝寘鍚寚閽堝瓧娈碉級锛岄伩鍏嶈法璇锋眰寮曠敤鍏变韩銆? func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { return agentshared.CloneTaskClassItems(src) } -// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。 +// buildSchedulePlanSnapshotFromState 鎶?graph 杩愯缁撴灉鏄犲皠鎴愬彲鎸佷箙鍖栧揩鐓?DTO銆? // -// 职责边界: -// 1. 负责字段映射与深拷贝,避免跨层共享可变切片; -// 2. 负责补齐 state_version 默认值; -// 3. 不负责数据库写入(写入由 DAO 承担)。 -func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *scheduleplan.SchedulePlanState) *model.SchedulePlanStateSnapshot { +// 鑱岃矗杈圭晫锛? +// 1. 璐熻矗瀛楁鏄犲皠涓庢繁鎷疯礉锛岄伩鍏嶈法灞傚叡浜彲鍙樺垏鐗囷紱 +// 2. 璐熻矗琛ラ綈 state_version 榛樿鍊硷紱 +// 3. 涓嶈礋璐f暟鎹簱鍐欏叆锛堝啓鍏ョ敱 DAO 鎵挎媴锛夈€? +func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot { if st == nil { return nil } @@ -237,12 +236,12 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s } } -// buildSchedulePlanSnapshotFromAgent2State 把 agent2 的排程状态映射成可持久化快照 DTO。 +// buildSchedulePlanSnapshotFromAgent2State 鎶?agent2 鐨勬帓绋嬬姸鎬佹槧灏勬垚鍙寔涔呭寲蹇収 DTO銆? // -// 调用目的: -// 1. 这轮只迁移 schedule_plan,不动 refine; -// 2. 因此 preview/快照协议继续复用老结构,但要补一个“agent2 state -> snapshot DTO”的映射层; -// 3. 这样可以做到:计划创建链路切到 agent2,而 refine / 预览查询链路暂时无需大改。 +// 璋冪敤鐩殑锛? +// 1. 杩欒疆鍙縼绉?schedule_plan锛屼笉鍔?refine锛? +// 2. 鍥犳 preview/蹇収鍗忚缁х画澶嶇敤鑰佺粨鏋勶紝浣嗚琛ヤ竴涓€渁gent2 state -> snapshot DTO鈥濈殑鏄犲皠灞傦紱 +// 3. 杩欐牱鍙互鍋氬埌锛氳鍒掑垱寤洪摼璺垏鍒?agent2锛岃€?refine / 棰勮鏌ヨ閾捐矾鏆傛椂鏃犻渶澶ф敼銆? func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot { if st == nil { return nil @@ -266,7 +265,7 @@ func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, } } -// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。 +// snapshotToSchedulePlanPreviewCache 鎶?MySQL 蹇収杞崲涓?Redis 棰勮缂撳瓨缁撴瀯銆? func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache { if snapshot == nil { return nil @@ -292,7 +291,7 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho } } -// snapshotToSchedulePlanPreviewResponse 把 MySQL 快照转换为查询接口响应。 +// snapshotToSchedulePlanPreviewResponse 鎶?MySQL 蹇収杞崲涓烘煡璇㈡帴鍙e搷搴斻€? func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse { if snapshot == nil { return nil diff --git a/backend/service/agentsvc/agent_schedule_refine.go b/backend/service/agentsvc/agent_schedule_refine.go index fb9de14..53580da 100644 --- a/backend/service/agentsvc/agent_schedule_refine.go +++ b/backend/service/agentsvc/agent_schedule_refine.go @@ -6,20 +6,21 @@ import ( "log" "strings" - "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" - "github.com/LoveLosita/smartflow/backend/agent/schedulerefine" + agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph" + agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model" + agentnode "github.com/LoveLosita/smartflow/backend/agent2/node" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" "github.com/cloudwego/eino-ext/components/model/ark" ) -// runScheduleRefineFlow 执行“连续对话微调排程”分支。 +// runScheduleRefineFlow 鎵ц鈥滆繛缁璇濆井璋冩帓绋嬧€濆垎鏀€? // -// 职责边界: -// 1. 负责读取“上一版排程预览快照”(优先 Redis,缺失再回源 MySQL); -// 2. 负责调用独立 schedulerefine 图链路完成本轮微调; -// 3. 负责把微调结果回写预览缓存与状态快照,供后续继续微调; -// 4. 不负责聊天消息持久化(消息持久化由 AgentChat 主链路统一处理)。 +// 鑱岃矗杈圭晫锛? +// 1. 璐熻矗璇诲彇鈥滀笂涓€鐗堟帓绋嬮瑙堝揩鐓р€濓紙浼樺厛 Redis锛岀己澶卞啀鍥炴簮 MySQL锛夛紱 +// 2. 璐熻矗璋冪敤鐙珛 schedulerefine 鍥鹃摼璺畬鎴愭湰杞井璋冿紱 +// 3. 璐熻矗鎶婂井璋冪粨鏋滃洖鍐欓瑙堢紦瀛樹笌鐘舵€佸揩鐓э紝渚涘悗缁户缁井璋冿紱 +// 4. 涓嶈礋璐h亰澶╂秷鎭寔涔呭寲锛堟秷鎭寔涔呭寲鐢?AgentChat 涓婚摼璺粺涓€澶勭悊锛夈€? func (s *AgentService) runScheduleRefineFlow( ctx context.Context, selectedModel *ark.ChatModel, @@ -34,24 +35,24 @@ func (s *AgentService) runScheduleRefineFlow( _ = outChan _ = modelName - // 1. 依赖预检:模型为空时无法执行任何节点,直接失败避免空指针。 + // 1. 渚濊禆棰勬锛氭ā鍨嬩负绌烘椂鏃犳硶鎵ц浠讳綍鑺傜偣锛岀洿鎺ュけ璐ラ伩鍏嶇┖鎸囬拡銆? if selectedModel == nil { return "", errors.New("schedule refine model is nil") } emitStage("schedule_refine.context.loading", "正在加载上一版排程上下文。") - // 2. 先查 Redis 预览快照,保证热路径低延迟。 - // 2.1 如果 Redis 未命中,再回源 MySQL 快照兜底; - // 2.2 如果两者都没有,说明当前会话没有可微调基础,直接返回业务错误。 + // 2. 鍏堟煡 Redis 棰勮蹇収锛屼繚璇佺儹璺緞浣庡欢杩熴€? + // 2.1 濡傛灉 Redis 鏈懡涓紝鍐嶅洖婧?MySQL 蹇収鍏滃簳锛? + // 2.2 濡傛灉涓よ€呴兘娌℃湁锛岃鏄庡綋鍓嶄細璇濇病鏈夊彲寰皟鍩虹锛岀洿鎺ヨ繑鍥炰笟鍔¢敊璇€? preview := s.loadSchedulePreviewContext(ctx, userID, chatID) if preview == nil { return "", respond.SchedulePlanPreviewNotFound } - // 3. 初始化微调状态并运行独立图。 - state := schedulerefine.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview) - finalState, runErr := schedulerefine.RunScheduleRefineGraph(ctx, schedulerefine.ScheduleRefineGraphRunInput{ + // 3. 鍒濆鍖栧井璋冪姸鎬佸苟杩愯鐙珛鍥俱€? + state := agentnode.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview) + finalState, runErr := agentgraph.RunScheduleRefineGraph(ctx, agentnode.ScheduleRefineGraphRunInput{ Model: selectedModel, State: state, EmitStage: emitStage, @@ -63,12 +64,12 @@ func (s *AgentService) runScheduleRefineFlow( return "", errors.New("schedule refine graph returned nil state") } - // 4. 调用目的: - // 4.1 saveSchedulePlanPreview 目前是“预览缓存 + MySQL 快照”的统一写入口; - // 4.2 这里把 refine state 映射为 scheduleplan state,复用已有落盘链路; - // 4.3 但若是“独立复合分支已出站、终审仍失败”,则不覆盖上一版预览,避免外部误以为新方案已验证通过。 + // 4. 璋冪敤鐩殑锛? + // 4.1 saveSchedulePlanPreview 鐩墠鏄€滈瑙堢紦瀛?+ MySQL 蹇収鈥濈殑缁熶竴鍐欏叆鍙o紱 + // 4.2 杩欓噷鎶?refine state 鏄犲皠涓?scheduleplan state锛屽鐢ㄥ凡鏈夎惤鐩橀摼璺紱 + // 4.3 浣嗚嫢鏄€滅嫭绔嬪鍚堝垎鏀凡鍑虹珯銆佺粓瀹′粛澶辫触鈥濓紝鍒欎笉瑕嗙洊涓婁竴鐗堥瑙堬紝閬垮厤澶栭儴璇互涓烘柊鏂规宸查獙璇侀€氳繃銆? if shouldPersistScheduleRefinePreview(finalState) { - s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) + s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, convertRefineStateToPlanState(finalState)) } else { emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。") } @@ -80,13 +81,13 @@ func (s *AgentService) runScheduleRefineFlow( return reply, nil } -// loadSchedulePreviewContext 读取“可用于连续微调”的排程上下文快照。 +// loadSchedulePreviewContext 璇诲彇鈥滃彲鐢ㄤ簬杩炵画寰皟鈥濈殑鎺掔▼涓婁笅鏂囧揩鐓с€? // -// 步骤化说明: -// 1. 先查 Redis:命中则直接返回,时延最小; -// 2. Redis miss 再查 MySQL:保证缓存过期后仍可继续微调; -// 3. 若 MySQL 命中且 Redis 可用,顺便回填 Redis,提升后续命中率; -// 4. 任一步失败仅打日志,不 panic,由上层根据返回 nil 做统一处理。 +// 姝ラ鍖栬鏄庯細 +// 1. 鍏堟煡 Redis锛氬懡涓垯鐩存帴杩斿洖锛屾椂寤舵渶灏忥紱 +// 2. Redis miss 鍐嶆煡 MySQL锛氫繚璇佺紦瀛樿繃鏈熷悗浠嶅彲缁х画寰皟锛? +// 3. 鑻?MySQL 鍛戒腑涓?Redis 鍙敤锛岄『渚垮洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛? +// 4. 浠讳竴姝ュけ璐ヤ粎鎵撴棩蹇楋紝涓?panic锛岀敱涓婂眰鏍规嵁杩斿洖 nil 鍋氱粺涓€澶勭悊銆? func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID int, chatID string) *model.SchedulePlanPreviewCache { normalizedChatID := strings.TrimSpace(chatID) if normalizedChatID == "" || userID <= 0 { @@ -96,7 +97,7 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in if s.cacheDAO != nil { preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) if err != nil { - log.Printf("读取排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("璇诲彇鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err) } else if preview != nil { return preview } @@ -107,7 +108,7 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in } snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) if err != nil { - log.Printf("读取排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) + log.Printf("璇诲彇鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err) return nil } if snapshot == nil { @@ -117,19 +118,19 @@ func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID in preview := snapshotToSchedulePlanPreviewCache(snapshot) if preview != nil && s.cacheDAO != nil { if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); setErr != nil { - log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr) + log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr) } } return preview } -// convertRefineStateToPlanState 把 schedulerefine 状态映射为 scheduleplan 状态。 +// convertRefineStateToPlanState 鎶?schedulerefine 鐘舵€佹槧灏勪负 scheduleplan 鐘舵€併€? // -// 设计意图: -// 1. 复用现有 saveSchedulePlanPreview 写入链路,减少重复落盘代码; -// 2. 仅映射“预览持久化必须字段”,避免把 refine 运行期临时字段带入存储层; -// 3. 后续如要扩展 refine 专属快照字段,可在该映射处集中演进。 -func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *scheduleplan.SchedulePlanState { +// 璁捐鎰忓浘锛? +// 1. 澶嶇敤鐜版湁 saveSchedulePlanPreview 鍐欏叆閾捐矾锛屽噺灏戦噸澶嶈惤鐩樹唬鐮侊紱 +// 2. 浠呮槧灏勨€滈瑙堟寔涔呭寲蹇呴』瀛楁鈥濓紝閬垮厤鎶?refine 杩愯鏈熶复鏃跺瓧娈靛甫鍏ュ瓨鍌ㄥ眰锛? +// 3. 鍚庣画濡傝鎵╁睍 refine 涓撳睘蹇収瀛楁锛屽彲鍦ㄨ鏄犲皠澶勯泦涓紨杩涖€? +func convertRefineStateToPlanState(st *agentnode.ScheduleRefineState) *agentmodel.SchedulePlanState { if st == nil { return nil } @@ -137,7 +138,7 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche if st.Contract.Strategy == "keep" { adjustmentScope = "small" } - return &scheduleplan.SchedulePlanState{ + return &agentmodel.SchedulePlanState{ TraceID: strings.TrimSpace(st.TraceID), UserID: st.UserID, ConversationID: strings.TrimSpace(st.ConversationID), @@ -157,17 +158,17 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche } } -// shouldPersistScheduleRefinePreview 判断“本轮微调结果是否应覆盖上一版预览”。 +// shouldPersistScheduleRefinePreview 鍒ゆ柇鈥滄湰杞井璋冪粨鏋滄槸鍚﹀簲瑕嗙洊涓婁竴鐗堥瑙堚€濄€? // -// 职责边界: -// 1. 默认沿用原有 refine 持久化策略,保证普通 ReAct 微调链路不受影响; -// 2. 仅当“独立复合分支已直接出站,但终审未通过”时,拒绝覆盖上一版预览; -// 3. 这样可以避免外层把未经验证的复合结果当成新的基线继续滚动微调。 -func shouldPersistScheduleRefinePreview(st *schedulerefine.ScheduleRefineState) bool { +// 鑱岃矗杈圭晫锛? +// 1. 榛樿娌跨敤鍘熸湁 refine 鎸佷箙鍖栫瓥鐣ワ紝淇濊瘉鏅€?ReAct 寰皟閾捐矾涓嶅彈褰卞搷锛? +// 2. 浠呭綋鈥滅嫭绔嬪鍚堝垎鏀凡鐩存帴鍑虹珯锛屼絾缁堝鏈€氳繃鈥濇椂锛屾嫆缁濊鐩栦笂涓€鐗堥瑙堬紱 +// 3. 杩欐牱鍙互閬垮厤澶栧眰鎶婃湭缁忛獙璇佺殑澶嶅悎缁撴灉褰撴垚鏂扮殑鍩虹嚎缁х画婊氬姩寰皟銆? +func shouldPersistScheduleRefinePreview(st *agentnode.ScheduleRefineState) bool { if st == nil { return false } - if st.CompositeRouteSucceeded && !schedulerefine.FinalHardCheckPassed(st) { + if st.CompositeRouteSucceeded && !agentnode.FinalHardCheckPassed(st) { return false } return true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b12c92a..fa0f51f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -845,7 +845,6 @@ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -874,7 +873,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1524,15 +1522,13 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -1688,7 +1684,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1702,7 +1697,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1730,7 +1724,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -1944,7 +1937,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2048,7 +2040,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", diff --git a/frontend/src/api/scheduleCenter.ts b/frontend/src/api/scheduleCenter.ts new file mode 100644 index 0000000..cdac2e7 --- /dev/null +++ b/frontend/src/api/scheduleCenter.ts @@ -0,0 +1,132 @@ +import http from '@/api/http' +import type { ApiResponse, PlainResponse } from '@/types/api' +import type { + ApplyBatchIntoScheduleItem, + ScheduleDeletePayloadItem, + ScheduleWeekData, + TaskClassCreatePayload, + TaskClassDetail, + TaskClassListItem, +} from '@/types/schedule' +import { extractErrorMessage } from '@/utils/http' +import { createIdempotencyKey } from '@/utils/idempotency' + +export async function getWeekSchedule(week?: number) { + try { + const response = await http.get>('/schedule/week', { + params: typeof week === 'number' ? { week } : undefined, + }) + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '周日程加载失败,请稍后重试')) + } +} + +export async function getTaskClassList() { + try { + const response = await http.get>('/task-class/list') + return response.data.data?.task_classes ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '任务类列表加载失败,请稍后重试')) + } +} + +export async function getTaskClassDetail(taskClassId: number) { + try { + const response = await http.get>('/task-class/get', { + params: { + task_class_id: taskClassId, + }, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '任务类详情加载失败,请稍后重试')) + } +} + +export async function createTaskClass(payload: TaskClassCreatePayload, idempotencyKey = createIdempotencyKey('task-class-add')) { + try { + const response = await http.post('/task-class/add', payload, { + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '创建任务类失败,请稍后重试')) + } +} + +export async function smartPlanning(taskClassId: number) { + try { + const response = await http.get>('/schedule/smart-planning', { + params: { + task_class_id: taskClassId, + }, + }) + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '智能粗排失败,请稍后重试')) + } +} + +export async function smartPlanningMulti(taskClassIds: number[]) { + try { + const response = await http.post>('/schedule/smart-planning-multi', { + task_class_ids: taskClassIds, + }) + return response.data.data ?? [] + } catch (error) { + throw new Error(extractErrorMessage(error, '批量智能粗排失败,请稍后重试')) + } +} + +export async function applyBatchIntoSchedule(taskClassId: number, items: ApplyBatchIntoScheduleItem[], idempotencyKey = createIdempotencyKey('schedule-apply')) { + try { + const response = await http.put( + '/task-class/apply-batch-into-schedule', + { + task_class_id: taskClassId, + items, + }, + { + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }, + ) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '正式应用日程失败,请稍后重试')) + } +} + +export async function deleteScheduleEntries(items: ScheduleDeletePayloadItem[], idempotencyKey = createIdempotencyKey('schedule-delete')) { + try { + const response = await http.delete('/schedule/delete', { + data: items, + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '解除安排失败,请稍后重试')) + } +} + +export async function deleteTaskClassItem(taskItemId: number, idempotencyKey = createIdempotencyKey('task-class-item-delete')) { + try { + const response = await http.delete('/task-class/delete-item', { + params: { + task_item_id: taskItemId, + }, + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '删除任务块失败,请稍后重试')) + } +} diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 8922612..bb450ab 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -44,6 +44,12 @@ interface StreamEventPayload { type ModelType = 'worker' | 'strategist' +interface ConversationGroup { + key: string + label: string + items: ConversationListItem[] +} + const props = withDefaults( defineProps<{ initialHistoryWidth?: number @@ -97,6 +103,8 @@ const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v let messageScrollRaf = 0 let reasoningTicker = 0 const reasoningDisplayNow = ref(Date.now()) +const shouldAutoFollowMessages = ref(true) +const messageBottomTolerancePx = 6 const isStandaloneMode = computed(() => props.viewMode === 'standalone') @@ -121,6 +129,59 @@ const selectedMessages = computed(() => { return conversationMessagesMap[selectedConversationId.value] ?? [] }) +function resolveConversationGroupLabel(timeText?: string | null) { + if (!timeText) { + return '更早' + } + + const messageDate = new Date(timeText) + if (Number.isNaN(messageDate.getTime())) { + return '更早' + } + + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const targetDay = new Date(messageDate.getFullYear(), messageDate.getMonth(), messageDate.getDate()) + const diffDays = Math.floor((today.getTime() - targetDay.getTime()) / (24 * 60 * 60 * 1000)) + + if (diffDays <= 0) { + return '今天' + } + if (diffDays < 7) { + return '7 天内' + } + if (diffDays < 30) { + return '30 天内' + } + + return `${messageDate.getFullYear()}-${String(messageDate.getMonth() + 1).padStart(2, '0')}` +} + +const groupedConversationList = computed(() => { + const orderedGroups: ConversationGroup[] = [] + const groupMap = new Map() + + for (const item of conversationList.value) { + const label = resolveConversationGroupLabel(item.last_message_at || item.created_at) + const key = label + const existed = groupMap.get(key) + if (existed) { + existed.items.push(item) + continue + } + + const nextGroup: ConversationGroup = { + key, + label, + items: [item], + } + groupMap.set(key, nextGroup) + orderedGroups.push(nextGroup) + } + + return orderedGroups +}) + const selectedConversationTitle = computed(() => { if (!selectedConversationId.value) { return '新对话' @@ -415,8 +476,10 @@ function markReasoningStart(message: AssistantMessage) { return } - const parsedCreatedAt = Date.parse(message.createdAt) - reasoningStartedAtMap[message.id] = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now() + // 1. 计时起点绑定到“首个思考 token 到达前端”的瞬间,而不是消息发送时间。 + // 2. 这样可避免网络排队/后端排队时间被错误计入“已思考用时”。 + // 3. 只在首次命中时写入,后续增量不会重复覆盖起点。 + reasoningStartedAtMap[message.id] = Date.now() } function markReasoningFinished(message: AssistantMessage) { @@ -470,12 +533,58 @@ function shouldShowAnsweringIndicator(message: AssistantMessage) { return isStreamingMessage(message) && !isThinkingMessage(message) && !message.content.trim() } -function scheduleScrollMessagesToBottom(smooth = false) { +function isMessageViewportAtBottom(viewport: HTMLElement) { + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= messageBottomTolerancePx +} + +function stopMessageAutoFollow() { + shouldAutoFollowMessages.value = false + if (messageScrollRaf) { + cancelAnimationFrame(messageScrollRaf) + messageScrollRaf = 0 + } +} + +function handleMessageViewportWheel(event: WheelEvent) { + if (event.deltaY < 0) { + // 1. 用户一旦尝试向上滚动,立即关闭自动跟随,优先保证人工浏览体验。 + // 2. 这里不依赖是否真的滚动成功,避免 SSE 高频刷新把用户拉回底部。 + // 3. 恢复自动跟随交给 handleMessageViewportScroll 在“回到底部”时统一处理。 + stopMessageAutoFollow() + } +} + +function handleMessageViewportScroll(event: Event) { + const viewport = event.target as HTMLElement | null + if (!viewport) { + return + } + + // 1. 若滚动到底部(最后一行完整露出),恢复自动跟随。 + // 2. 只要离底部有距离,就维持“手动阅读模式”,防止流式输出打断阅读。 + // 3. 该状态会影响后续 scheduleScrollMessagesToBottom,形成可控的跟随策略。 + shouldAutoFollowMessages.value = isMessageViewportAtBottom(viewport) +} + +function scheduleScrollMessagesToBottom(smooth = false, force = false) { + if (!force && !shouldAutoFollowMessages.value) { + return + } + + if (force) { + shouldAutoFollowMessages.value = true + } + if (messageScrollRaf) { cancelAnimationFrame(messageScrollRaf) } messageScrollRaf = window.requestAnimationFrame(() => { + if (!force && !shouldAutoFollowMessages.value) { + messageScrollRaf = 0 + return + } + const viewport = messageViewportRef.value if (!viewport) { messageScrollRaf = 0 @@ -627,13 +736,14 @@ async function selectConversation(conversationId: string) { selectedConversationId.value = conversationId applyPreferredModelForConversation(conversationId) await Promise.allSettled([loadConversationMessages(conversationId), ensureConversationMeta(conversationId)]) - scheduleScrollMessagesToBottom(false) + scheduleScrollMessagesToBottom(false, true) } function startNewConversation() { selectedConversationId.value = '' messageInput.value = '' activeStreamingMessageId.value = '' + shouldAutoFollowMessages.value = true } // fetchChatStream 负责以 fetch 方式发起聊天请求,并处理一次 refresh token 自动重试。 @@ -803,7 +913,7 @@ async function sendMessage(preset?: string) { messageInput.value = '' prependConversationPreview(draftConversationId, text, now) - scheduleScrollMessagesToBottom(false) + scheduleScrollMessagesToBottom(false, true) try { const response = await fetchChatStream({ @@ -910,7 +1020,6 @@ onBeforeUnmount(() => { {{ selectedConversationTitle }}

{{ selectedConversationSubtitle }}

-
{ 'assistant-body--standalone': isStandaloneMode, }" :style="assistantBodyStyle" - > -