后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
1301 lines
45 KiB
Go
1301 lines
45 KiB
Go
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,
|
||
})
|
||
}
|