package schedule import ( "encoding/json" "fmt" "sort" "strings" ) const ( analyzeSeverityCritical = "critical" analyzeSeverityWarning = "warning" analyzeSeverityInfo = "info" ) type analyzeMetricSchemaItem struct { Description string `json:"description"` Unit string `json:"unit,omitempty"` Direction string `json:"direction,omitempty"` } type analyzeIssueTrigger struct { Metric string `json:"metric"` Operator string `json:"operator"` Threshold float64 `json:"threshold"` Actual float64 `json:"actual"` } type analyzeIssueItem struct { IssueID string `json:"issue_id"` Dimension string `json:"dimension"` Severity string `json:"severity"` Trigger *analyzeIssueTrigger `json:"trigger,omitempty"` } type analyzeCandidateScope struct { DayRange []int `json:"day_range"` Categories []string `json:"categories"` TaskPool string `json:"task_pool"` } type analyzeNextAction struct { ActionID string `json:"action_id"` Priority int `json:"priority"` IntentCode string `json:"intent_code"` TargetFilter map[string]any `json:"target_filter"` SlotFilter map[string]any `json:"slot_filter"` CandidateScope analyzeCandidateScope `json:"candidate_scope"` RequiredReads []string `json:"required_reads"` SuccessCriteria map[string]any `json:"success_criteria"` CandidateWriteTools []string `json:"candidate_write_tools"` } type analyzeFeasibility struct { IsFeasible bool `json:"is_feasible"` CapacityGap int `json:"capacity_gap"` ReasonCode string `json:"reason_code"` } type analyzeEnvelope struct { Tool string `json:"tool"` Success bool `json:"success"` MetricSchema map[string]analyzeMetricSchemaItem `json:"metric_schema"` Metrics any `json:"metrics"` Issues []analyzeIssueItem `json:"issues"` NextActions []analyzeNextAction `json:"next_actions"` Feasibility *analyzeFeasibility `json:"feasibility,omitempty"` Decision *analyzeHealthDecision `json:"decision,omitempty"` Error string `json:"error"` ErrorCode string `json:"error_code"` } type analyzeSubjectItem struct { Category string `json:"category"` TaskCount int `json:"task_count"` PlacedCount int `json:"placed_count"` PendingCount int `json:"pending_count"` SubjectType string `json:"subject_type,omitempty"` DifficultyLevel string `json:"difficulty_level,omitempty"` CognitiveIntensity string `json:"cognitive_intensity,omitempty"` } type analyzeContextDay struct { DayIndex int `json:"day_index"` SwitchCount int `json:"switch_count"` Sequence []string `json:"sequence"` MaxBlock int `json:"max_block"` Fragmentation float64 `json:"fragmentation"` HeavyAdjacent bool `json:"heavy_adjacent"` } type analyzeContextOverall struct { AvgSwitchesPerDay float64 `json:"avg_switches_per_day"` MaxSwitchDay int `json:"max_switch_day"` MaxSwitchCount int `json:"max_switch_count"` AvgBlockSize float64 `json:"avg_block_size"` LongestSameSubjectRun int `json:"longest_same_subject_run"` } type analyzeRhythmOverview struct { AvgSwitchesPerDay float64 `json:"avg_switches_per_day"` MaxSwitchDay int `json:"max_switch_day"` MaxSwitchCount int `json:"max_switch_count"` AvgBlockSize float64 `json:"avg_block_size"` LongestSameSubjectRun int `json:"longest_same_subject_run"` HeavyAdjacentDays int `json:"heavy_adjacent_days"` HighIntensityDays int `json:"high_intensity_days"` LongHighIntensityDays int `json:"long_high_intensity_days"` FragmentedCount int `json:"fragmented_count"` CompressedRunCount int `json:"compressed_run_count"` BlockBalance int `json:"block_balance"` SameTypeTransitionRatio float64 `json:"same_type_transition_ratio"` } type analyzeRhythmMetrics struct { Overview analyzeRhythmOverview `json:"overview"` Subjects []analyzeSubjectItem `json:"subjects"` Days []analyzeContextDay `json:"days"` } type analyzeSlackMetrics struct { MovableTaskCount int `json:"movable_task_count"` RigidTaskCount int `json:"rigid_task_count"` AvgAlternativeSlots float64 `json:"avg_alternative_slots"` CrossClassSwapOptions int `json:"cross_class_swap_options"` AdjustabilityLevel string `json:"adjustability_level"` PreferSwap bool `json:"prefer_swap"` } type analyzeTightnessMetrics struct { LocallyMovableTaskCount int `json:"locally_movable_task_count"` AvgLocalAlternativeSlots float64 `json:"avg_local_alternative_slots"` CrossClassSwapOptions int `json:"cross_class_swap_options"` ForcedHeavyAdjacentDays int `json:"forced_heavy_adjacent_days"` TightnessLevel string `json:"tightness_level"` } type analyzeSemanticProfileMetrics struct { TotalSubjects int `json:"total_subjects"` MissingSubjectTypeCount int `json:"missing_subject_type_count"` MissingDifficultyCount int `json:"missing_difficulty_count"` MissingCognitiveCount int `json:"missing_cognitive_count"` MissingCompleteProfileCount int `json:"missing_complete_profile_count"` } type analyzeProblemScope struct { DayRange []int `json:"day_range,omitempty"` TaskIDs []int `json:"task_ids,omitempty"` } type analyzeHealthDecision struct { ShouldContinueOptimize bool `json:"should_continue_optimize"` PrimaryProblem string `json:"primary_problem"` ProblemScope *analyzeProblemScope `json:"problem_scope,omitempty"` IsForcedImperfection bool `json:"is_forced_imperfection"` RecommendedOperation string `json:"recommended_operation"` ImprovementSignal string `json:"improvement_signal"` Candidates []analyzeHealthCandidate `json:"candidates,omitempty"` } type analyzeHealthMetrics struct { Rhythm *analyzeRhythmOverview `json:"rhythm,omitempty"` Tightness *analyzeTightnessMetrics `json:"tightness,omitempty"` Profile *analyzeSemanticProfileMetrics `json:"profile,omitempty"` CanClose bool `json:"can_close"` } // AnalyzeRhythm 输出认知节奏层面的结构化观察。 func AnalyzeRhythm(state *ScheduleState, args map[string]any) string { if state == nil { return encodeAnalyzeFailure("analyze_rhythm", "state_empty", "日程状态为空") } allowed := []string{"category", "include_pending", "detail", "hard_categories"} if err := validateToolArgsStrict(args, allowed); err != nil { return encodeAnalyzeFailure("analyze_rhythm", "invalid_args", err.Error()) } includePending := readBoolAnyWithDefault(args, true, "include_pending") categoryFilter := strings.TrimSpace(readStringAny(args, "category")) subjects := computeAnalyzeSubjectMetricsV2(state, includePending, categoryFilter) days := computeAnalyzeContextDaysV2(state) overview := computeAnalyzeRhythmOverviewV2(subjects, days) metrics := analyzeRhythmMetrics{ Overview: overview, Subjects: subjects, Days: days, } issues, actions := buildRhythmIssuesAndActionsV2(metrics) return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ Tool: "analyze_rhythm", Success: true, MetricSchema: rhythmMetricSchemaV2(), Metrics: metrics, Issues: issues, NextActions: actions, Error: "", ErrorCode: "", }) } // AnalyzeHealth 输出主动优化唯一总入口。 func AnalyzeHealth(state *ScheduleState, args map[string]any) string { if state == nil { return encodeAnalyzeFailure("analyze_health", "state_empty", "日程状态为空") } allowed := []string{"dimensions", "threshold", "detail"} if err := validateToolArgsStrict(args, allowed); err != nil { return encodeAnalyzeFailure("analyze_health", "invalid_args", err.Error()) } if len(normalizeHealthDimensionsV3(parseAnalyzeStringSlice(args["dimensions"]))) == 0 { return encodeAnalyzeFailure("analyze_health", "invalid_args", "dimensions 全部非法") } snapshot := buildAnalyzeHealthSnapshotFromState(state) rhythm := snapshot.Rhythm.Overview rhythmMetrics := snapshot.Rhythm tightness := snapshot.Tightness profile := snapshot.Profile feasibility := snapshot.Feasibility issues := make([]analyzeIssueItem, 0) rhythmIssues, _ := buildRhythmIssuesAndActionsV2(rhythmMetrics) issues = append(issues, rhythmIssues...) profileIssues := buildSemanticProfileIssues(profile) issues = append(issues, profileIssues...) if !feasibility.IsFeasible { issues = append(issues, analyzeIssueItem{ IssueID: "issue_feasibility_capacity_gap", Dimension: "feasibility", Severity: analyzeSeverityCritical, Trigger: &analyzeIssueTrigger{ Metric: "capacity_gap", Operator: ">", Threshold: 0, Actual: float64(feasibility.CapacityGap), }, }) } sort.SliceStable(issues, func(i, j int) bool { return analyzeSeverityRank(issues[i].Severity) < analyzeSeverityRank(issues[j].Severity) }) decision := buildAnalyzeHealthDecisionV2(state, snapshot) metrics := analyzeHealthMetrics{ Rhythm: &rhythm, Tightness: &tightness, Profile: &profile, CanClose: !decision.ShouldContinueOptimize, } return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ Tool: "analyze_health", Success: true, MetricSchema: healthMetricSchemaV4(), Metrics: metrics, Issues: issues, NextActions: []analyzeNextAction{}, Feasibility: &feasibility, Decision: &decision, Error: "", ErrorCode: "", }) } func computeAnalyzeSubjectMetricsV2(state *ScheduleState, includePending bool, categoryFilter string) []analyzeSubjectItem { type counter struct { taskCount int placedCount int pendingCount int } counterByCategory := make(map[string]*counter) for _, task := range state.Tasks { if task.Source != "task_item" || strings.TrimSpace(task.Category) == "" { continue } if categoryFilter != "" && strings.TrimSpace(task.Category) != categoryFilter { continue } if !includePending && IsPendingTask(task) { continue } entry := counterByCategory[task.Category] if entry == nil { entry = &counter{} counterByCategory[task.Category] = entry } entry.taskCount++ if IsPendingTask(task) { entry.pendingCount++ } if IsSuggestedTask(task) || IsExistingTask(task) { entry.placedCount++ } } out := make([]analyzeSubjectItem, 0, len(counterByCategory)) for category, item := range counterByCategory { meta := findTaskClassMetaByName(state, category) out = append(out, analyzeSubjectItem{ Category: category, TaskCount: item.taskCount, PlacedCount: item.placedCount, PendingCount: item.pendingCount, SubjectType: metaValue(meta, func(m *TaskClassMeta) string { return m.SubjectType }), DifficultyLevel: metaValue(meta, func(m *TaskClassMeta) string { return m.DifficultyLevel }), CognitiveIntensity: metaValue(meta, func(m *TaskClassMeta) string { return m.CognitiveIntensity }), }) } sort.Slice(out, func(i, j int) bool { return out[i].Category < out[j].Category }) return out } func computeAnalyzeContextDaysV2(state *ScheduleState) []analyzeContextDay { out := make([]analyzeContextDay, 0, state.Window.TotalDays) highIntensityCategories := make(map[string]struct{}) for _, meta := range state.TaskClasses { if isHighIntensityMeta(meta) { highIntensityCategories[strings.TrimSpace(meta.Name)] = struct{}{} } } for day := 1; day <= state.Window.TotalDays; day++ { sequence := buildContextDaySequenceV2(state, day) switchCount := 0 maxBlock := 0 currentBlock := 0 prev := "" heavyAdjacent := false for _, category := range sequence { if prev != "" && prev != category { switchCount++ _, prevHigh := highIntensityCategories[prev] _, currHigh := highIntensityCategories[category] if prevHigh && currHigh { heavyAdjacent = true } } if category == prev { currentBlock++ } else { currentBlock = 1 prev = category } if currentBlock > maxBlock { maxBlock = currentBlock } } fragmentation := 0.0 if len(sequence) > 1 { fragmentation = safeDivideFloat(float64(switchCount), float64(len(sequence)-1)) } out = append(out, analyzeContextDay{ DayIndex: day, SwitchCount: switchCount, Sequence: sequence, MaxBlock: maxBlock, Fragmentation: fragmentation, HeavyAdjacent: heavyAdjacent, }) } return out } func computeAnalyzeRhythmOverviewV2(subjects []analyzeSubjectItem, days []analyzeContextDay) analyzeRhythmOverview { overview := analyzeRhythmOverview{} totalSwitches := 0 totalBlocks := 0 totalBlockLength := 0 totalTransitions := 0 sameTypeTransitions := 0 subjectTypeByCategory := make(map[string]string, len(subjects)) highIntensityByCategory := make(map[string]bool, len(subjects)) for _, subject := range subjects { subjectTypeByCategory[subject.Category] = subject.SubjectType highIntensityByCategory[subject.Category] = isHighIntensitySubject(subject) } for _, day := range days { totalSwitches += day.SwitchCount if day.SwitchCount > overview.MaxSwitchCount { overview.MaxSwitchCount = day.SwitchCount overview.MaxSwitchDay = day.DayIndex } if day.HeavyAdjacent { overview.HeavyAdjacentDays++ } if isFragmentedRhythmDay(day) { overview.FragmentedCount++ } if day.MaxBlock > overview.LongestSameSubjectRun { overview.LongestSameSubjectRun = day.MaxBlock } currentHighRun := 0 maxHighRun := 0 hasHighIntensity := false prev := "" for _, category := range day.Sequence { totalBlocks++ totalBlockLength++ if highIntensityByCategory[category] { hasHighIntensity = true currentHighRun++ if currentHighRun > maxHighRun { maxHighRun = currentHighRun } } else { currentHighRun = 0 } if prev != "" { totalTransitions++ if sameSemanticType(subjectTypeByCategory[prev], subjectTypeByCategory[category]) { sameTypeTransitions++ } } prev = category } if hasHighIntensity { overview.HighIntensityDays++ } if maxHighRun >= 4 { overview.LongHighIntensityDays++ } if isCompressedRhythmDay(day, maxHighRun) { overview.CompressedRunCount++ } } overview.AvgSwitchesPerDay = safeDivideFloat(float64(totalSwitches), float64(maxInt(len(days), 1))) overview.AvgBlockSize = safeDivideFloat(float64(totalBlockLength), float64(maxInt(totalBlocks, 1))) overview.BlockBalance = overview.FragmentedCount - overview.CompressedRunCount overview.SameTypeTransitionRatio = safeDivideFloat(float64(sameTypeTransitions), float64(maxInt(totalTransitions, 1))) return overview } // isFragmentedRhythmDay 判断某一天是否更像“认知块切得过碎”。 // // 职责边界: // 1. 这里只复用当前 analyze_health 已有的偏碎观察阈值,保证 block_balance 和 issue 口径一致。 // 2. 不负责驱动新的候选类型;当前候选闭环仍然只允许 heavy_adjacent。 // 3. 只要达到 warning 级碎片化观察条件,就把这一天记入 fragmented_count。 func isFragmentedRhythmDay(day analyzeContextDay) bool { return day.SwitchCount >= 3 || day.Fragmentation >= 0.55 } // isCompressedRhythmDay 判断某一天是否更像“认知块过长或过于压缩”。 // // 职责边界: // 1. 这里只做统一观测:把“长同科目块”和“高强度连续过长”都视为 compressed 信号。 // 2. 不负责生成 long_block / compressed 的候选动作;这次只补统一指标。 // 3. 同一天即使同时命中两个信号,也只记 1 次,避免 block_balance 被重复放大。 func isCompressedRhythmDay(day analyzeContextDay, maxHighRun int) bool { return day.MaxBlock >= 5 || maxHighRun >= 4 } func buildRhythmIssuesAndActionsV2(metrics analyzeRhythmMetrics) ([]analyzeIssueItem, []analyzeNextAction) { issues := make([]analyzeIssueItem, 0) actions := make([]analyzeNextAction, 0) for _, day := range metrics.Days { if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 { issues = append(issues, analyzeIssueItem{ IssueID: fmt.Sprintf("issue_rhythm_switch_day_%d", day.DayIndex), Dimension: "rhythm", Severity: analyzeSeverityCritical, Trigger: &analyzeIssueTrigger{ Metric: "switch_count", Operator: ">=", Threshold: 5, Actual: float64(day.SwitchCount), }, }) actions = append(actions, analyzeNextAction{ ActionID: fmt.Sprintf("na_rhythm_reduce_switch_day_%d", day.DayIndex), Priority: 1, IntentCode: "reduce_switch", TargetFilter: map[string]any{ "status": "suggested", }, SlotFilter: map[string]any{ "day": day.DayIndex, }, CandidateScope: analyzeCandidateScope{ DayRange: []int{day.DayIndex}, Categories: []string{}, TaskPool: "placed", }, RequiredReads: []string{"query_range", "query_target_tasks"}, SuccessCriteria: map[string]any{"switch_count<": 5}, CandidateWriteTools: []string{"swap", "move"}, }) } else if day.SwitchCount >= 3 || day.Fragmentation >= 0.55 { issues = append(issues, analyzeIssueItem{ IssueID: fmt.Sprintf("issue_rhythm_switch_warn_day_%d", day.DayIndex), Dimension: "rhythm", Severity: analyzeSeverityWarning, }) } if day.HeavyAdjacent { issues = append(issues, analyzeIssueItem{ IssueID: fmt.Sprintf("issue_rhythm_heavy_adjacent_day_%d", day.DayIndex), Dimension: "rhythm", Severity: analyzeSeverityWarning, }) actions = append(actions, analyzeNextAction{ ActionID: fmt.Sprintf("na_rhythm_reorder_day_%d", day.DayIndex), Priority: 2, IntentCode: "smooth_rhythm", TargetFilter: map[string]any{ "status": "suggested", }, SlotFilter: map[string]any{ "day": day.DayIndex, }, CandidateScope: analyzeCandidateScope{ DayRange: []int{day.DayIndex}, Categories: []string{}, TaskPool: "placed", }, RequiredReads: []string{"query_range", "query_target_tasks"}, SuccessCriteria: map[string]any{"heavy_adjacent": false}, CandidateWriteTools: []string{"swap", "move"}, }) } if day.MaxBlock >= 5 { issues = append(issues, analyzeIssueItem{ IssueID: fmt.Sprintf("issue_rhythm_long_block_day_%d", day.DayIndex), Dimension: "rhythm", Severity: analyzeSeverityWarning, }) } } if len(issues) == 0 { issues = append(issues, analyzeIssueItem{ IssueID: "issue_rhythm_info", Dimension: "rhythm", Severity: analyzeSeverityInfo, }) } return issues, actions } func computeAnalyzeSlackMetrics(state *ScheduleState) analyzeSlackMetrics { metrics := analyzeSlackMetrics{AdjustabilityLevel: "low"} if state == nil { return metrics } suggested := collectSuggestedTaskItems(state) if len(suggested) == 0 { return metrics } totalAlternatives := 0 for _, task := range suggested { alternatives := countAlternativePlacements(state, task, 6) if alternatives > 0 { metrics.MovableTaskCount++ totalAlternatives += alternatives } else { metrics.RigidTaskCount++ } } metrics.AvgAlternativeSlots = safeDivideFloat(float64(totalAlternatives), float64(maxInt(metrics.MovableTaskCount, 1))) metrics.CrossClassSwapOptions = countCrossClassSwapOptions(state, suggested, 24) switch { case metrics.MovableTaskCount >= 3 && metrics.AvgAlternativeSlots >= 2.0: metrics.AdjustabilityLevel = "high" case metrics.MovableTaskCount >= 1 || metrics.CrossClassSwapOptions > 0: metrics.AdjustabilityLevel = "medium" default: metrics.AdjustabilityLevel = "low" } metrics.PreferSwap = metrics.AdjustabilityLevel == "low" || metrics.CrossClassSwapOptions > 0 return metrics } // computeAnalyzeTightnessMetrics 评估“当前是否还值得继续优化”。 // // 设计说明: // 1. 这里不再问“全窗口理论上还能不能挪”,而是问“在写工具顺序约束下,还剩多少合法候选”; // 2. 合法性口径直接复用写工具的前驱/后继顺序边界,不再人为限定 day±1; // 3. forced_heavy_adjacent_days 用来识别“即使有问题,也更像时间窗过紧下的代价”。 func computeAnalyzeTightnessMetrics(state *ScheduleState, rhythm analyzeRhythmMetrics) analyzeTightnessMetrics { metrics := analyzeTightnessMetrics{TightnessLevel: "locked"} if state == nil { return metrics } // 1. 主动优化只关心“当前问题域附近还有没有低代价修法”, // 不能再用全窗口可动任务数去放大“还可以继续折腾”的错觉。 // 2. 若当前没有明显问题域,则退化为 suggested 全量,保证粗排初次诊断仍有结果。 // 3. focusDays 会优先取 heavy_adjacent / 高切换 / 长连续块出现的天,并补前后 1 天作为局部缓冲区。 suggested := filterSuggestedTasksByFocusDays(state, selectProblemFocusDays(rhythm)) if len(suggested) == 0 { suggested = collectSuggestedTaskItems(state) } if len(suggested) == 0 { return metrics } totalAlternatives := 0 for _, task := range suggested { alternatives := countLocalAlternativePlacements(state, task, 1, 4) if alternatives > 0 { metrics.LocallyMovableTaskCount++ totalAlternatives += alternatives } } metrics.AvgLocalAlternativeSlots = safeDivideFloat( float64(totalAlternatives), float64(maxInt(metrics.LocallyMovableTaskCount, 1)), ) metrics.CrossClassSwapOptions = countCrossClassSwapOptions(state, suggested, 12) for _, day := range rhythm.Days { if day.HeavyAdjacent && !hasRepairOpportunityOnDay(state, day.DayIndex) { metrics.ForcedHeavyAdjacentDays++ } } switch { case metrics.LocallyMovableTaskCount >= 4 && (metrics.AvgLocalAlternativeSlots >= 2.0 || metrics.CrossClassSwapOptions >= 2): metrics.TightnessLevel = "loose" case metrics.LocallyMovableTaskCount == 0 && metrics.CrossClassSwapOptions == 0: metrics.TightnessLevel = "locked" default: metrics.TightnessLevel = "tight" } return metrics } func selectProblemFocusDays(rhythm analyzeRhythmMetrics) []int { seen := map[int]struct{}{} out := make([]int, 0, 12) appendDay := func(day int) { if day <= 0 { return } if _, ok := seen[day]; ok { return } seen[day] = struct{}{} out = append(out, day) } // 1. 高认知相邻优先级最高,因为这是主动优化当前最关心的认知负荷问题。 // 2. 其次是高切换高碎片,再其次是超长连续块。 // 3. 每个问题日都补前后 1 天,让局部 move/swap 的可行空间评估更贴近真实操作面。 for _, day := range rhythm.Days { if day.HeavyAdjacent { appendDay(day.DayIndex - 1) appendDay(day.DayIndex) appendDay(day.DayIndex + 1) } } for _, day := range rhythm.Days { if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 { appendDay(day.DayIndex - 1) appendDay(day.DayIndex) appendDay(day.DayIndex + 1) } } for _, day := range rhythm.Days { if day.MaxBlock >= 5 { appendDay(day.DayIndex - 1) appendDay(day.DayIndex) appendDay(day.DayIndex + 1) } } sort.Ints(out) return out } func filterSuggestedTasksByFocusDays(state *ScheduleState, focusDays []int) []ScheduleTask { if state == nil || len(focusDays) == 0 { return nil } daySet := make(map[int]struct{}, len(focusDays)) for _, day := range focusDays { if day > 0 { daySet[day] = struct{}{} } } out := make([]ScheduleTask, 0) for _, task := range collectSuggestedTaskItems(state) { if len(task.Slots) == 0 { continue } if _, ok := daySet[task.Slots[0].Day]; !ok { continue } out = append(out, task) } return out } func collectSuggestedTaskItems(state *ScheduleState) []ScheduleTask { out := make([]ScheduleTask, 0) for _, task := range state.Tasks { if !IsSuggestedTask(task) || len(task.Slots) == 0 || task.Source != "task_item" { continue } out = append(out, task) } return out } func countLocalAlternativePlacements(state *ScheduleState, task ScheduleTask, dayRadius int, limit int) int { if state == nil || len(task.Slots) == 0 { return 0 } _ = dayRadius duration := taskDuration(task) if duration <= 0 { return 0 } count := 0 for day := 1; day <= state.Window.TotalDays; day++ { for _, gap := range findFreeRangesOnDay(state, day) { maxStart := gap.slotEnd - duration + 1 for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ { target := []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1}} if sameTaskSlots(task.Slots, target) { continue } if err := validateLocalOrderForSinglePlacement(state, task.StateID, target); err != nil { continue } count++ if count >= limit { return count } } } } return count } func localCandidateDays(totalDays int, currentDay int, dayRadius int) []int { if totalDays <= 0 || currentDay <= 0 { return nil } out := make([]int, 0, dayRadius*2+1) for day := currentDay - dayRadius; day <= currentDay+dayRadius; day++ { if day < 1 || day > totalDays { continue } out = append(out, day) } return out } func hasRepairOpportunityOnDay(state *ScheduleState, dayIndex int) bool { if state == nil || dayIndex <= 0 { return false } dayTasks := make([]ScheduleTask, 0) for _, task := range collectSuggestedTaskItems(state) { if len(task.Slots) == 0 || task.Slots[0].Day != dayIndex { continue } dayTasks = append(dayTasks, task) if countLocalAlternativePlacements(state, task, 1, 1) > 0 { return true } } return countCrossClassSwapOptions(state, dayTasks, 12) > 0 } func countAlternativePlacements(state *ScheduleState, task ScheduleTask, limit int) int { if state == nil || len(task.Slots) == 0 { return 0 } duration := taskDuration(task) if duration <= 0 { return 0 } count := 0 for day := 1; day <= state.Window.TotalDays; day++ { for _, gap := range findFreeRangesOnDay(state, day) { maxStart := gap.slotEnd - duration + 1 for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ { target := []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1}} if sameTaskSlots(task.Slots, target) { continue } if err := validateLocalOrderForSinglePlacement(state, task.StateID, target); err != nil { continue } count++ if count >= limit { return count } } } } return count } func countCrossClassSwapOptions(state *ScheduleState, tasks []ScheduleTask, pairLimit int) int { if state == nil || len(tasks) < 2 { return 0 } count := 0 checked := 0 for i := 0; i < len(tasks); i++ { for j := i + 1; j < len(tasks); j++ { if tasks[i].TaskClassID <= 0 || tasks[j].TaskClassID <= 0 || tasks[i].TaskClassID == tasks[j].TaskClassID { continue } checked++ if canSwapTasksForSlack(state, tasks[i], tasks[j]) { count++ } if checked >= pairLimit { return count } } } return count } func canSwapTasksForSlack(state *ScheduleState, taskA, taskB ScheduleTask) bool { if len(taskA.Slots) == 0 || len(taskB.Slots) == 0 { return false } return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{ taskA.StateID: cloneScheduleTaskSlots(taskB.Slots), taskB.StateID: cloneScheduleTaskSlots(taskA.Slots), }) == nil } func sameTaskSlots(left, right []TaskSlot) bool { if len(left) != len(right) { return false } for i := range left { if left[i] != right[i] { return false } } return true } func buildSlackIssuesAndActions(metrics analyzeSlackMetrics) ([]analyzeIssueItem, []analyzeNextAction) { issues := make([]analyzeIssueItem, 0, 1) actions := make([]analyzeNextAction, 0, 1) switch metrics.AdjustabilityLevel { case "low": issues = append(issues, analyzeIssueItem{ IssueID: "issue_slack_low", Dimension: "slack", Severity: analyzeSeverityInfo, }) if metrics.CrossClassSwapOptions > 0 { actions = append(actions, analyzeNextAction{ ActionID: "na_slack_prefer_swap", Priority: 1, IntentCode: "prefer_swap", TargetFilter: map[string]any{ "status": "suggested", "different_task_class": true, }, SlotFilter: map[string]any{}, CandidateScope: analyzeCandidateScope{ DayRange: []int{}, Categories: []string{}, TaskPool: "placed", }, RequiredReads: []string{"query_range", "query_target_tasks"}, SuccessCriteria: map[string]any{"cross_class_swap_options>": 0}, CandidateWriteTools: []string{"swap"}, }) } case "medium": issues = append(issues, analyzeIssueItem{ IssueID: "issue_slack_medium", Dimension: "slack", Severity: analyzeSeverityInfo, }) default: issues = append(issues, analyzeIssueItem{ IssueID: "issue_slack_info", Dimension: "slack", Severity: analyzeSeverityInfo, }) } return issues, actions } func computeSemanticProfileMetrics(subjects []analyzeSubjectItem) analyzeSemanticProfileMetrics { metrics := analyzeSemanticProfileMetrics{TotalSubjects: len(subjects)} for _, subject := range subjects { missing := false if strings.TrimSpace(subject.SubjectType) == "" { metrics.MissingSubjectTypeCount++ missing = true } if strings.TrimSpace(subject.DifficultyLevel) == "" { metrics.MissingDifficultyCount++ missing = true } if strings.TrimSpace(subject.CognitiveIntensity) == "" { metrics.MissingCognitiveCount++ missing = true } if missing { metrics.MissingCompleteProfileCount++ } } return metrics } func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyzeIssueItem { if metrics.MissingCompleteProfileCount <= 0 { return nil } return []analyzeIssueItem{{ IssueID: "issue_semantic_profile_missing", Dimension: "semantic_profile", Severity: analyzeSeverityWarning, Trigger: &analyzeIssueTrigger{ Metric: "missing_complete_profile_count", Operator: ">", Threshold: 0, Actual: float64(metrics.MissingCompleteProfileCount), }, }} } func shouldTreatHeavyAdjacencyAsAcceptable(rhythm analyzeRhythmMetrics, day analyzeContextDay) bool { // 1. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”, // 这类情况不该因为“高认知相邻”四个字就被反复优化。 // 2. 这里只做保守放宽:必须同时满足整体平稳 + 当天不碎,才把该问题视为可接受。 // 3. 这样可以减少“把问题从第 3 天搬到第 2 天”的空转行为。 return rhythm.Overview.SameTypeTransitionRatio >= 0.80 && rhythm.Overview.AvgSwitchesPerDay <= 1.0 && rhythm.Overview.MaxSwitchCount <= 3 && day.SwitchCount <= 2 && day.Fragmentation <= 0.45 } func buildHealthImprovementSignal( rhythm analyzeRhythmMetrics, tightness analyzeTightnessMetrics, scope *analyzeProblemScope, operation string, profile analyzeSemanticProfileMetrics, feasibility analyzeFeasibility, ) string { // 1. 这里故意不写具体 day_index,避免“问题只是从第 3 天漂到第 2 天”时被误判成有进展。 // 2. 信号只保留主动优化真正关心的局部形态:问题域大小、可修空间、全局节奏代价。 // 3. execute 节点会用这个信号判断“连续两轮是否实质停滞”,因此格式要稳定。 problemDays := 0 if scope != nil { problemDays = len(scope.DayRange) } return fmt.Sprintf( "problem_days=%d|heavy_adjacent_days=%d|max_switch_count=%d|same_type_ratio=%.2f|non_forced_heavy_days=%d|local_moves=%d|swap_options=%d|tightness=%s|operation=%s|missing_profile=%d|capacity_gap=%d", problemDays, rhythm.Overview.HeavyAdjacentDays, rhythm.Overview.MaxSwitchCount, rhythm.Overview.SameTypeTransitionRatio, maxInt(rhythm.Overview.HeavyAdjacentDays-tightness.ForcedHeavyAdjacentDays, 0), tightness.LocallyMovableTaskCount, tightness.CrossClassSwapOptions, tightness.TightnessLevel, strings.TrimSpace(operation), profile.MissingCompleteProfileCount, feasibility.CapacityGap, ) } func computeHealthFeasibilityV2(state *ScheduleState) analyzeFeasibility { required := 0 feasible := 0 for _, task := range state.Tasks { if IsPendingTask(task) { required += maxInt(task.Duration, 0) } } for day := 1; day <= state.Window.TotalDays; day++ { for _, gap := range findFreeRangesOnDay(state, day) { feasible += gap.slotEnd - gap.slotStart + 1 } } capacityGap := required - feasible if capacityGap <= 0 { return analyzeFeasibility{IsFeasible: true, CapacityGap: 0, ReasonCode: "enough_capacity"} } return analyzeFeasibility{IsFeasible: false, CapacityGap: capacityGap, ReasonCode: "capacity_insufficient"} } func buildContextDaySequenceV2(state *ScheduleState, day int) []string { sequence := make([]string, 0) for slot := 1; slot <= 12; slot++ { category := subjectAtSlotV2(state, day, slot) if category == "" { continue } sequence = append(sequence, category) } return sequence } func subjectAtSlotV2(state *ScheduleState, day, slot int) string { best := "" bestPriority := -1 for _, task := range state.Tasks { if len(task.Slots) == 0 || isCourseScheduleTask(task) { continue } for _, ts := range task.Slots { if ts.Day != day || slot < ts.SlotStart || slot > ts.SlotEnd { continue } priority := 1 if task.Source == "task_item" { priority = 2 } if IsSuggestedTask(task) { priority = 3 } if priority > bestPriority { bestPriority = priority best = strings.TrimSpace(task.Category) } } } return best } func findTaskClassMetaByName(state *ScheduleState, name string) *TaskClassMeta { if state == nil { return nil } for i := range state.TaskClasses { if strings.TrimSpace(state.TaskClasses[i].Name) == strings.TrimSpace(name) { return &state.TaskClasses[i] } } return nil } func metaValue(meta *TaskClassMeta, getter func(*TaskClassMeta) string) string { if meta == nil || getter == nil { return "" } return strings.TrimSpace(getter(meta)) } func isHighIntensityMeta(meta TaskClassMeta) bool { return strings.EqualFold(strings.TrimSpace(meta.CognitiveIntensity), "high") || strings.EqualFold(strings.TrimSpace(meta.DifficultyLevel), "high") } func isHighIntensitySubject(subject analyzeSubjectItem) bool { return strings.EqualFold(strings.TrimSpace(subject.CognitiveIntensity), "high") || strings.EqualFold(strings.TrimSpace(subject.DifficultyLevel), "high") } func sameSemanticType(left, right string) bool { left = strings.TrimSpace(strings.ToLower(left)) right = strings.TrimSpace(strings.ToLower(right)) if left == "" || right == "" { return false } return left == right } func rhythmMetricSchemaV2() map[string]analyzeMetricSchemaItem { return map[string]analyzeMetricSchemaItem{ "overview.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, "overview.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, "overview.longest_same_subject_run": {Description: "单日最长连续同科块长度", Unit: "slots", Direction: "higher_is_more_monotone"}, "overview.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, "overview.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, "overview.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, "days.switch_count": {Description: "单日切换次数", Unit: "count", Direction: "higher_is_more_switching"}, "days.fragmentation": {Description: "单日碎片化程度", Unit: "0-1", Direction: "higher_is_more_fragmented"}, "days.max_block": {Description: "单日最长连续块", Unit: "slots", Direction: "higher_is_more_monotone"}, "days.heavy_adjacent": {Description: "该天是否存在高强度相邻", Direction: "true_is_worse"}, } } func healthMetricSchemaV2() map[string]analyzeMetricSchemaItem { return map[string]analyzeMetricSchemaItem{ "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, "rhythm.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, "slack.movable_task_count": {Description: "仍有候选落点的任务数", Unit: "count", Direction: "higher_is_more_adjustable"}, "slack.cross_class_swap_options": {Description: "跨任务类可交换机会数", Unit: "count", Direction: "higher_is_more_adjustable"}, "slack.adjustability_level": {Description: "当前可调整空间等级", Direction: "high_is_looser"}, "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, } } func healthMetricSchemaV3() map[string]analyzeMetricSchemaItem { return map[string]analyzeMetricSchemaItem{ "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, "rhythm.heavy_adjacent_days": {Description: "存在高强度相邻的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, "slack.movable_task_count": {Description: "仍有候选落点的任务数", Unit: "count", Direction: "higher_is_more_adjustable"}, "slack.cross_class_swap_options": {Description: "跨任务类可交换机会数", Unit: "count", Direction: "higher_is_more_adjustable"}, "slack.adjustability_level": {Description: "当前可调整空间等级", Direction: "high_is_looser"}, "profile.missing_subject_type_count": {Description: "缺少 subject_type 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_difficulty_count": {Description: "缺少 difficulty_level 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_cognitive_count": {Description: "缺少 cognitive_intensity 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_complete_profile_count": {Description: "语义画像不完整的科目数", Unit: "count", Direction: "higher_is_worse"}, "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, } } func normalizeHealthDimensionsV2(raw []string) []string { if len(raw) == 0 { return []string{"rhythm"} } allowed := map[string]struct{}{ "rhythm": {}, } out := make([]string, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) for _, item := range raw { key := strings.ToLower(strings.TrimSpace(item)) if _, ok := allowed[key]; !ok { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, key) } return out } func parseAnalyzeStringSlice(raw any) []string { switch typed := raw.(type) { case []string: out := make([]string, 0, len(typed)) for _, item := range typed { if strings.TrimSpace(item) != "" { out = append(out, strings.TrimSpace(item)) } } return out case []any: out := make([]string, 0, len(typed)) for _, item := range typed { if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { out = append(out, strings.TrimSpace(text)) } } return out case string: if strings.TrimSpace(typed) == "" { return nil } return []string{strings.TrimSpace(typed)} default: return nil } } func analyzeSeverityRank(level string) int { switch level { case analyzeSeverityCritical: return 0 case analyzeSeverityWarning: return 1 default: return 2 } } func maxInt(a, b int) int { if a >= b { return a } return b } func safeDivideFloat(numerator, denominator float64) float64 { if denominator == 0 { return 0 } return numerator / denominator } func deduplicateAndSortActions(actions []analyzeNextAction) []analyzeNextAction { if len(actions) == 0 { return actions } seen := make(map[string]struct{}, len(actions)) out := make([]analyzeNextAction, 0, len(actions)) for _, action := range actions { key := action.IntentCode + "::" + action.ActionID if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, action) } sort.SliceStable(out, func(i, j int) bool { if out[i].Priority == out[j].Priority { return out[i].ActionID < out[j].ActionID } return out[i].Priority < out[j].Priority }) return out } func healthMetricSchemaV4() map[string]analyzeMetricSchemaItem { return map[string]analyzeMetricSchemaItem{ "rhythm.block_balance": {Description: "认知块平衡度;大于 0 更偏碎,小于 0 更偏连续或偏压缩", Unit: "score", Direction: "positive_is_more_fragmented_negative_is_more_compressed"}, "rhythm.compressed_run_count": {Description: "偏连续或偏压缩的天数", Unit: "days", Direction: "higher_is_more_compressed"}, "rhythm.fragmented_count": {Description: "偏碎的天数", Unit: "days", Direction: "higher_is_more_fragmented"}, "rhythm.avg_switches_per_day": {Description: "平均每天切换次数", Unit: "count", Direction: "higher_is_more_switching"}, "rhythm.max_switch_count": {Description: "单日最大切换次数", Unit: "count", Direction: "higher_is_worse"}, "rhythm.heavy_adjacent_days": {Description: "存在高认知相邻的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.long_high_intensity_days": {Description: "高强度连续过长的天数", Unit: "days", Direction: "higher_is_worse"}, "rhythm.same_type_transition_ratio": {Description: "同类型切换占比", Unit: "0-1", Direction: "higher_is_smoother"}, "tightness.locally_movable_task_count": {Description: "仍有近距离合法调整空间的任务数", Unit: "count", Direction: "higher_is_looser"}, "tightness.avg_local_alternative_slots": {Description: "局部候选落点均值", Unit: "count", Direction: "higher_is_looser"}, "tightness.cross_class_swap_options": {Description: "局部跨任务类可交换机会数", Unit: "count", Direction: "higher_is_looser"}, "tightness.forced_heavy_adjacent_days": {Description: "更像被迫保留的高认知相邻天数", Unit: "days", Direction: "higher_is_more_forced"}, "tightness.tightness_level": {Description: "当前优化空间等级", Direction: "loose_to_locked"}, "profile.missing_subject_type_count": {Description: "缺少 subject_type 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_difficulty_count": {Description: "缺少 difficulty_level 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_cognitive_count": {Description: "缺少 cognitive_intensity 的科目数", Unit: "count", Direction: "higher_is_worse"}, "profile.missing_complete_profile_count": {Description: "语义画像不完整的科目数", Unit: "count", Direction: "higher_is_worse"}, "decision.should_continue_optimize": {Description: "当前是否还值得继续主动优化", Direction: "true_is_continue"}, "decision.is_forced_imperfection": {Description: "剩余问题是否更像约束代价", Direction: "true_is_forced"}, "decision.recommended_operation": {Description: "推荐优先考虑的动作类型", Direction: "swap_move_close"}, "can_close": {Description: "当前是否可收口", Direction: "true_is_ready"}, "feasibility.is_feasible": {Description: "当前约束下是否可行", Direction: "true_is_feasible"}, } } func normalizeHealthDimensionsV3(raw []string) []string { if len(raw) == 0 { return []string{"rhythm", "tightness", "semantic_profile"} } allowed := map[string]struct{}{ "rhythm": {}, "tightness": {}, "semantic_profile": {}, } out := make([]string, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) for _, item := range raw { key := strings.ToLower(strings.TrimSpace(item)) if _, ok := allowed[key]; !ok { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, key) } return out } func mustEncodeAnalyzeEnvelope(envelope analyzeEnvelope) string { raw, err := json.Marshal(envelope) if err != nil { return fmt.Sprintf(`{"tool":"%s","success":false,"metric_schema":{},"metrics":{},"issues":[],"next_actions":[],"error":"encode analyze result failed","error_code":"encode_failed"}`, envelope.Tool) } return string(raw) } func encodeAnalyzeFailure(tool, code, errText string) string { return mustEncodeAnalyzeEnvelope(analyzeEnvelope{ Tool: tool, Success: false, MetricSchema: map[string]analyzeMetricSchemaItem{}, Metrics: map[string]any{}, Issues: []analyzeIssueItem{}, NextActions: []analyzeNextAction{}, Error: errText, ErrorCode: code, }) }