Files
smartmate/backend/newAgent/tools/schedule/analyze_health_candidates.go
Losita d5b52b35ac Version: 0.9.55.dev.260429
后端:
1. analyze_health 候选复诊改为轻量 after brief 口径,候选模拟执行后只展示基础诊断结论,不再递归触发全局候选枚举,避免 score-only 评估阶段重复扫描和误判继续优化。

前端:
2. thinking_summary 支持同一条回复内多轮思考块——按 backendKey 维护独立阶段状态,final 后再次收到摘要会新建 reasoning block,避免多轮思考被合并、去重状态互相吞掉。
3. timeline 历史恢复改为复用后端事件 seq,正文、工具、状态、思考块都按原始顺序回放,减少刷新后消息块错位、插队和布局跳变。
4. 思考流式态下沉到当前活跃 reasoning block,只让正在输出的思考块闪光、显示游标和接收逐字追加,旧思考块完成后稳定折叠,不再被新一轮思考“复活”。
5. 清理助手消息重置逻辑,补齐 reasoning block、短摘要、耗时、折叠态和流式队列的状态回收,降低连续会话/重发时的残留干扰。
2026-04-29 15:23:22 +08:00

1156 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package schedule
import (
"fmt"
"sort"
"strings"
)
const (
healthProblemNone = ""
healthProblemHeavyAdjacent = "heavy_adjacent"
analyzeHealthMinBenefitScore = 1
healthCandidateEffectImprove = "improve"
healthCandidateEffectPartialImprove = "partial_improve"
healthCandidateEffectShift = "shift"
healthCandidateEffectNoGain = "no_gain"
healthCandidateEffectRegress = "regress"
healthCandidateEffectClose = "close"
)
type analyzeHealthCandidate struct {
CandidateID string `json:"candidate_id"`
Tool string `json:"tool,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
Summary string `json:"summary"`
Effect string `json:"effect"`
After analyzeHealthCandidateAfter `json:"after"`
}
type analyzeHealthCandidateAfter struct {
CanClose bool `json:"can_close"`
PrimaryProblem string `json:"primary_problem"`
RecommendedOperation string `json:"recommended_operation,omitempty"`
HeavyAdjacentDays int `json:"heavy_adjacent_days"`
MaxSwitchCount int `json:"max_switch_count"`
SameTypeRatio float64 `json:"same_type_transition_ratio"`
}
type analyzeHealthSnapshot struct {
Subjects []analyzeSubjectItem
Days []analyzeContextDay
Rhythm analyzeRhythmMetrics
Tightness analyzeTightnessMetrics
Profile analyzeSemanticProfileMetrics
Feasibility analyzeFeasibility
}
type analyzeHealthProblem struct {
Kind string
DayIndex int
Summary string
Scope *analyzeProblemScope
Pair *analyzeHeavyAdjacentPair
PreferSwap bool
}
type analyzeHealthDecisionBase struct {
ShouldContinueOptimize bool
PrimaryProblem string
ProblemScope *analyzeProblemScope
IsForcedImperfection bool
RecommendedOperation string
}
type analyzeHealthCandidateRanked struct {
Candidate analyzeHealthCandidate
Score int
}
type analyzeHealthProblemScanResult struct {
Problem analyzeHealthProblem
Candidates []analyzeHealthCandidate
BestScore int
BestCandidateID string
PriorityScore int
}
type analyzeHealthProblemScoreOnlyResult struct {
Problem analyzeHealthProblem
BestScore int
BestOperation string
PriorityScore int
}
type analyzeHeavyAdjacentPair struct {
DayIndex int
Left ScheduleTask
Right ScheduleTask
}
// buildAnalyzeHealthSnapshotFromState 统一计算 analyze_health 需要复用的核心快照。
//
// 职责边界:
// 1. 这里只负责“从 state 派生节奏/紧度/画像/可行性”;
// 2. 不直接生成 issues / 候选动作,避免分析层和决策层耦死;
// 3. 候选模拟复诊时也复用这条路径,保证前后口径一致。
func buildAnalyzeHealthSnapshotFromState(state *ScheduleState) analyzeHealthSnapshot {
subjects := computeAnalyzeSubjectMetricsV2(state, true, "")
days := computeAnalyzeContextDaysV2(state)
rhythmOverview := computeAnalyzeRhythmOverviewV2(subjects, days)
rhythm := analyzeRhythmMetrics{
Overview: rhythmOverview,
Subjects: subjects,
Days: days,
}
return analyzeHealthSnapshot{
Subjects: subjects,
Days: days,
Rhythm: rhythm,
Tightness: computeAnalyzeTightnessMetrics(state, rhythm),
Profile: computeSemanticProfileMetrics(subjects),
Feasibility: computeHealthFeasibilityV2(state),
}
}
// buildAnalyzeHealthDecisionBase 生成“不带候选动作”的基础裁决结果。
//
// 说明:
// 1. 这层只回答“当前是否还有值得继续修的主问题”,不负责枚举具体 move/swap 参数;
// 2. 主分析与候选模拟都会复用这层,避免出现“主诊断口径”和“候选复诊口径”不一致;
// 3. 当前 P1 只把 heavy_adjacent 作为可进入候选枚举的问题,其它问题仅保留指标展示,不驱动继续优化。
func buildAnalyzeHealthDecisionBase(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
decision := analyzeHealthDecisionBase{
PrimaryProblem: "当前没有发现值得继续处理的局部认知问题",
RecommendedOperation: "close",
}
if !snapshot.Feasibility.IsFeasible {
decision.PrimaryProblem = fmt.Sprintf("当前时间窗容量不足,还缺 %d 节可用容量", snapshot.Feasibility.CapacityGap)
decision.IsForcedImperfection = true
decision.RecommendedOperation = "ask_user"
return decision
}
if snapshot.Profile.MissingCompleteProfileCount > 0 {
decision.PrimaryProblem = fmt.Sprintf("仍有 %d 门任务类缺少完整语义画像,先补齐再谈主动优化", snapshot.Profile.MissingCompleteProfileCount)
decision.RecommendedOperation = "ask_user"
return decision
}
problem, ok := pickPrimaryHealthProblem(state, snapshot)
if !ok {
return decision
}
decision.PrimaryProblem = problem.Summary
decision.ProblemScope = problem.Scope
// P1 只支持“高认知相邻”进入候选求解;其他问题先只做观测,不驱动自动挪动。
if problem.Kind != healthProblemHeavyAdjacent {
return decision
}
if snapshot.Tightness.TightnessLevel == "locked" {
decision.IsForcedImperfection = true
return decision
}
decision.RecommendedOperation = "swap"
decision.ShouldContinueOptimize = true
return decision
}
// buildAnalyzeHealthFinalDecisionBrief 基于当前 active 扫描器语义,生成候选 after 视图所需的最小判定。
//
// 职责边界:
// 1. 这里只产出 should_continue_optimize / primary_problem / recommended_operation / is_forced_imperfection。
// 2. 这里通过 score-only 扫描层复用“全局扫描 + 最小收益阈值”的收口规则,但不构造 candidates / summary / after。
// 3. score-only 扫描只能读取 state/snapshot 并计算最佳收益,绝不能回流到 simulate 后再调用 brief避免递归闭环。
func buildAnalyzeHealthFinalDecisionBrief(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecisionBase{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
}
if !shouldEnterHealthCandidateLoop(base) {
decision.ShouldContinueOptimize = false
return decision
}
bestScoreOnly, ok := findBestHealthProblemScoreOnly(state, snapshot)
if !ok || bestScoreOnly.Problem.Kind != healthProblemHeavyAdjacent || bestScoreOnly.Problem.Pair == nil {
decision.ShouldContinueOptimize = false
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
decision.ProblemScope = nil
decision.RecommendedOperation = "close"
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
decision.IsForcedImperfection = true
}
return decision
}
decision.ShouldContinueOptimize = true
decision.PrimaryProblem = bestScoreOnly.Problem.Summary
decision.ProblemScope = bestScoreOnly.Problem.Scope
decision.RecommendedOperation = strings.TrimSpace(bestScoreOnly.BestOperation)
return decision
}
// buildAnalyzeHealthCandidateAfterBrief 生成候选执行后的轻量复诊摘要。
//
// 职责边界:
// 1. 只负责为 candidate.after / candidate.summary 提供“执行后看起来如何”的展示结论;
// 2. 不负责再次枚举 move/swap 候选,也不决定顶层 analyze_health 是否继续优化;
// 3. 输入是已经模拟执行后的 state/snapshot输出沿用 analyzeHealthDecisionBase 的字段语义。
func buildAnalyzeHealthCandidateAfterBrief(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecisionBase{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
}
if !shouldEnterHealthCandidateLoop(base) {
decision.ShouldContinueOptimize = false
return decision
}
// 1. candidate.after 位于候选模拟内层,不能再跑全局候选枚举。
// 2. 顶层 buildAnalyzeHealthDecisionV2 已经负责严谨筛选“是否值得继续优化”;
// 这里保留基础诊断即可,避免每个候选递归触发 score-only 全局扫描。
// 3. 若仍存在基础诊断认为可优化的问题,则如实展示给前端和 LLM下一轮会再次调用
// analyze_health 做正式复诊,作为真正的收口依据。
return decision
}
// pickPrimaryHealthProblem 选择当前最值得处理的局部问题。
func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) {
best := analyzeHealthProblem{}
bestScore := -1
bestBalanceAlignment := -1
for _, day := range snapshot.Rhythm.Days {
if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) {
continue
}
pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex)
if !ok {
continue
}
score := scoreHeavyAdjacentProblem(day, pair)
balanceAlignment := scoreHeavyAdjacentBalanceAlignment(snapshot.Rhythm.Overview.BlockBalance, day)
if score < bestScore {
continue
}
if score == bestScore && balanceAlignment <= bestBalanceAlignment {
continue
}
bestScore = score
bestBalanceAlignment = balanceAlignment
best = analyzeHealthProblem{
Kind: healthProblemHeavyAdjacent,
DayIndex: day.DayIndex,
Summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex),
Scope: &analyzeProblemScope{
DayRange: []int{day.DayIndex},
TaskIDs: []int{pair.Left.StateID, pair.Right.StateID},
},
Pair: pair,
PreferSwap: true,
}
}
if best.Kind == "" {
return analyzeHealthProblem{}, false
}
return best, true
}
// collectRepairableHeavyAdjacentProblems 收集当前所有仍值得扫描的 heavy_adjacent 问题天。
//
// 职责边界:
// 1. 这里只负责把可扫描的问题天列出来,不负责最终选哪一天。
// 2. 仍然只收集 heavy_adjacent继续复用当前“可接受相邻天”放宽语义。
// 3. 若当天找不到对应相邻任务对,则直接跳过,避免把不完整问题送入候选试算。
func collectRepairableHeavyAdjacentProblems(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) []analyzeHealthProblem {
if state == nil {
return nil
}
out := make([]analyzeHealthProblem, 0, len(snapshot.Rhythm.Days))
for _, day := range snapshot.Rhythm.Days {
if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) {
continue
}
pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex)
if !ok {
continue
}
out = append(out, analyzeHealthProblem{
Kind: healthProblemHeavyAdjacent,
DayIndex: day.DayIndex,
Summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex),
Scope: &analyzeProblemScope{
DayRange: []int{day.DayIndex},
TaskIDs: []int{pair.Left.StateID, pair.Right.StateID},
},
Pair: pair,
PreferSwap: true,
})
}
return out
}
// scoreHeavyAdjacentBalanceAlignment 在 heavy_adjacent 同类问题里提供极轻的节奏倾向参考。
//
// 职责边界:
// 1. 这里只在 heavy_adjacent 候选内部做同分细排,不改变“只处理 heavy_adjacent”的主链路边界。
// 2. 当整体更偏碎时,优先选择同样更碎的 heavy_adjacent 天;当整体更偏压缩时,优先选择块更长的 heavy_adjacent 天。
// 3. 若整体 block_balance 接近 0则返回 0保持原有排序语义不变。
func scoreHeavyAdjacentBalanceAlignment(blockBalance int, day analyzeContextDay) int {
switch {
case blockBalance > 0:
return int(day.Fragmentation * 100)
case blockBalance < 0:
return day.MaxBlock*10 - day.SwitchCount
default:
return 0
}
}
func scoreHeavyAdjacentProblem(day analyzeContextDay, pair *analyzeHeavyAdjacentPair) int {
score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20)
if pair != nil && pair.Left.TaskClassID > 0 && pair.Right.TaskClassID > 0 && pair.Left.TaskClassID != pair.Right.TaskClassID {
score += 15
}
return score
}
func scoreHeavyAdjacentProblemDay(rhythm analyzeRhythmMetrics, dayIndex int) int {
for _, day := range rhythm.Days {
if day.DayIndex != dayIndex {
continue
}
return scoreHeavyAdjacentProblem(day, nil)
}
return 0
}
// findBestHealthProblemScoreOnly 扫描当前所有 heavy_adjacent 问题天,并只返回“是否存在过阈值候选”所需的最小信息。
//
// 步骤化说明:
// 1. 这里只做 score-only 扫描:复用合法 move/swap 枚举与收益计算但不构造候选文案、after 视图或 summary。
// 2. 返回值只关心最值得修的问题天、最佳收益和最佳操作类型,供 after brief / 其他只读裁决器复用。
// 3. 一旦最佳收益未过阈值,就直接返回 false让上层按正式扫描器语义收口。
func findBestHealthProblemScoreOnly(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) (analyzeHealthProblemScoreOnlyResult, bool) {
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
if len(problems) == 0 {
return analyzeHealthProblemScoreOnlyResult{}, false
}
results := make([]analyzeHealthProblemScoreOnlyResult, 0, len(problems))
for _, problem := range problems {
result, ok := buildHealthProblemScoreOnly(state, snapshot, problem)
if !ok {
continue
}
results = append(results, result)
}
return selectBestHealthProblemScoreOnly(results)
}
// buildHealthProblemScoreOnly 只计算单个问题天的最佳候选收益,不构造任何候选展示字段。
//
// 职责边界:
// 1. 这里仍然只允许 move / swap并复用现有合法性过滤与收益口径。
// 2. 这里不产出 candidate.after / candidate.summary也不调用 brief helper避免递归。
// 3. 这条路径存在的唯一目的,是回答“这个问题天还有没有过阈值收益的候选”。
func buildHealthProblemScoreOnly(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
problem analyzeHealthProblem,
) (analyzeHealthProblemScoreOnlyResult, bool) {
if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
return analyzeHealthProblemScoreOnlyResult{}, false
}
bestScore := 0
bestOperation := ""
found := false
pool := collectSuggestedTaskItems(state)
movable := extractSuggestedProblemTasks(problem.Pair)
if len(movable) == 0 {
return analyzeHealthProblemScoreOnlyResult{}, false
}
checked := 0
for _, anchor := range movable {
for _, other := range pool {
if anchor.StateID == other.StateID {
continue
}
if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID {
continue
}
if taskDuration(anchor) != taskDuration(other) {
continue
}
checked++
score, ok := simulateHealthSwapScoreOnly(state, snapshot, problem, anchor, other)
if ok {
bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "swap", bestScore, bestOperation, found)
}
if checked >= 48 {
goto movePhase
}
}
}
movePhase:
for _, task := range movable {
placements := enumerateLegalMovePlacements(state, task, 24)
for _, target := range placements {
score, ok := simulateHealthMoveScoreOnly(state, snapshot, problem, task, target)
if ok {
bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "move", bestScore, bestOperation, found)
}
}
}
if !found {
return analyzeHealthProblemScoreOnlyResult{}, false
}
return analyzeHealthProblemScoreOnlyResult{
Problem: problem,
BestScore: bestScore,
BestOperation: bestOperation,
PriorityScore: scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex),
}, true
}
func simulateHealthSwapScoreOnly(
state *ScheduleState,
baseline analyzeHealthSnapshot,
problem analyzeHealthProblem,
anchor ScheduleTask,
other ScheduleTask,
) (int, bool) {
clone := state.Clone()
if clone == nil {
return 0, false
}
result := Swap(clone, anchor.StateID, other.StateID)
if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") {
return 0, false
}
after := buildAnalyzeHealthSnapshotFromState(clone)
_, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "swap", 4)
if !ok {
return 0, false
}
return applyHealthCandidateRankingBias("swap", score), true
}
func simulateHealthMoveScoreOnly(
state *ScheduleState,
baseline analyzeHealthSnapshot,
problem analyzeHealthProblem,
task ScheduleTask,
target TaskSlot,
) (int, bool) {
clone := state.Clone()
if clone == nil {
return 0, false
}
result := Move(clone, task.StateID, target.Day, target.SlotStart)
if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") {
return 0, false
}
after := buildAnalyzeHealthSnapshotFromState(clone)
moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart)
_, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "move", moveCost)
if !ok {
return 0, false
}
return applyHealthCandidateRankingBias("move", score), true
}
func selectBestHealthProblemScoreOnly(
results []analyzeHealthProblemScoreOnlyResult,
) (analyzeHealthProblemScoreOnlyResult, bool) {
if len(results) == 0 {
return analyzeHealthProblemScoreOnlyResult{}, false
}
best := analyzeHealthProblemScoreOnlyResult{}
bestSet := false
for _, item := range results {
if !meetsHealthCandidateBenefitThreshold(item.BestScore) {
continue
}
if !bestSet || shouldPreferHealthScoreCandidate(
item.BestScore,
item.PriorityScore,
item.Problem.DayIndex,
best.BestScore,
best.PriorityScore,
best.Problem.DayIndex,
) {
best = item
bestSet = true
}
}
if !bestSet {
return analyzeHealthProblemScoreOnlyResult{}, false
}
return best, true
}
func updateBestHealthScoreOnly(
score int,
operation string,
bestScore int,
bestOperation string,
found bool,
) (int, string, bool) {
if !found {
return score, operation, true
}
if score > bestScore {
return score, operation, true
}
if score == bestScore && strings.TrimSpace(operation) < strings.TrimSpace(bestOperation) {
return score, operation, true
}
return bestScore, bestOperation, true
}
// applyHealthCandidateRankingBias 统一补齐候选最终排序分里的轻量工具偏置。
//
// 职责边界:
// 1. 这里只负责把“基础收益分”映射成“最终排序分”,避免正式扫描器和 score-only 扫描口径漂移。
// 2. 当前只有 swap 额外加分move 保持原分不变。
// 3. 该函数不参与候选合法性判断,只参与“谁更值得优先处理”的排序。
func applyHealthCandidateRankingBias(operation string, baseScore int) int {
score := baseScore
if strings.EqualFold(strings.TrimSpace(operation), "swap") {
score += 12
}
return score
}
// selectBestHealthProblemScanResult 从所有单天扫描结果中挑出本轮最值得修的一天。
//
// 职责边界:
// 1. 这里只比较“各天最佳候选”的收益,不拼接多天候选,也不改变候选内容。
// 2. 先按 benefit_score 选天;若收益相同,再退回到原有问题优先级与天序做稳定 tie-break。
// 3. 若第一名收益不足最小阈值,则返回 false交给上层直接收口。
func selectBestHealthProblemScanResult(
results []analyzeHealthProblemScanResult,
) (analyzeHealthProblemScanResult, bool) {
if len(results) == 0 {
return analyzeHealthProblemScanResult{}, false
}
best := analyzeHealthProblemScanResult{}
bestSet := false
for _, item := range results {
if !meetsHealthCandidateBenefitThreshold(item.BestScore) {
continue
}
if !bestSet || shouldPreferHealthScoreCandidate(
item.BestScore,
item.PriorityScore,
item.Problem.DayIndex,
best.BestScore,
best.PriorityScore,
best.Problem.DayIndex,
) {
best = item
bestSet = true
}
}
if !bestSet {
return analyzeHealthProblemScanResult{}, false
}
return best, true
}
func meetsHealthCandidateBenefitThreshold(score int) bool {
return score >= analyzeHealthMinBenefitScore
}
func shouldPreferHealthScoreCandidate(
leftScore int,
leftPriority int,
leftDay int,
rightScore int,
rightPriority int,
rightDay int,
) bool {
switch {
case leftScore > rightScore:
return true
case leftScore < rightScore:
return false
case leftPriority > rightPriority:
return true
case leftPriority < rightPriority:
return false
default:
return leftDay < rightDay
}
}
// buildHealthCandidatesForProblem 为主问题生成“可直接抄去调用写工具”的合法候选。
//
// 设计说明:
// 1. 候选空间完全由现有写工具的合法性约束定义,尤其复用“前驱/后继顺序边界”;
// 2. 后端会先在内存里模拟,再做一次 analyze_health 同口径复诊;
// 3. 只保留真正减轻问题的 top5过滤掉平移、无增益和恶化候选。
func buildHealthCandidatesForProblem(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
problem analyzeHealthProblem,
) []analyzeHealthCandidate {
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
if !ok {
return nil
}
return scan.Candidates
}
// buildHealthProblemScanResult 生成单个 heavy_adjacent 问题天的候选扫描结果。
//
// 步骤化说明:
// 1. 先复用现有 move / swap 枚举与复诊过滤,保证只比较“原本就合法且确实变好”的候选。
// 2. 再按现有 score 规则排序并截取该天 top5 候选,保持返回给 LLM 的候选语义不变。
// 3. 额外保留该天最佳候选的 score供上层做“先选天再返回该天候选集”的全局比较。
func buildHealthProblemScanResult(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
problem analyzeHealthProblem,
) (analyzeHealthProblemScanResult, bool) {
if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
return analyzeHealthProblemScanResult{}, false
}
ranked := make([]analyzeHealthCandidateRanked, 0, 16)
ranked = append(ranked, enumerateSwapCandidates(state, snapshot, problem)...)
ranked = append(ranked, enumerateMoveCandidates(state, snapshot, problem)...)
if len(ranked) == 0 {
return analyzeHealthProblemScanResult{}, false
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].Score != ranked[j].Score {
return ranked[i].Score > ranked[j].Score
}
leftTool := strings.TrimSpace(ranked[i].Candidate.Tool)
rightTool := strings.TrimSpace(ranked[j].Candidate.Tool)
if leftTool != rightTool {
return leftTool < rightTool
}
return ranked[i].Candidate.CandidateID < ranked[j].Candidate.CandidateID
})
out := make([]analyzeHealthCandidate, 0, minInt(len(ranked), 5))
seen := make(map[string]struct{}, len(ranked))
for _, item := range ranked {
key := item.Candidate.Tool + "::" + marshalCandidateArgumentsKey(item.Candidate.Arguments)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item.Candidate)
if len(out) >= 5 {
break
}
}
if len(out) == 0 {
return analyzeHealthProblemScanResult{}, false
}
return analyzeHealthProblemScanResult{
Problem: problem,
Candidates: out,
BestScore: ranked[0].Score,
BestCandidateID: ranked[0].Candidate.CandidateID,
PriorityScore: scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex),
}, true
}
func enumerateSwapCandidates(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
problem analyzeHealthProblem,
) []analyzeHealthCandidateRanked {
pair := problem.Pair
if pair == nil {
return nil
}
pool := collectSuggestedTaskItems(state)
movable := extractSuggestedProblemTasks(pair)
if len(movable) == 0 || len(pool) == 0 {
return nil
}
out := make([]analyzeHealthCandidateRanked, 0, 12)
checked := 0
for _, anchor := range movable {
for _, other := range pool {
if anchor.StateID == other.StateID {
continue
}
if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID {
continue
}
if taskDuration(anchor) != taskDuration(other) {
continue
}
checked++
candidate, score, ok := simulateHealthSwapCandidate(state, snapshot, problem, anchor, other)
if ok {
out = append(out, analyzeHealthCandidateRanked{
Candidate: candidate,
Score: applyHealthCandidateRankingBias("swap", score),
})
}
if checked >= 48 {
return out
}
}
}
return out
}
func enumerateMoveCandidates(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
problem analyzeHealthProblem,
) []analyzeHealthCandidateRanked {
pair := problem.Pair
if pair == nil {
return nil
}
movable := extractSuggestedProblemTasks(pair)
if len(movable) == 0 {
return nil
}
out := make([]analyzeHealthCandidateRanked, 0, 20)
for _, task := range movable {
placements := enumerateLegalMovePlacements(state, task, 24)
for _, target := range placements {
candidate, score, ok := simulateHealthMoveCandidate(state, snapshot, problem, task, target)
if !ok {
continue
}
out = append(out, analyzeHealthCandidateRanked{Candidate: candidate, Score: score})
}
}
return out
}
func simulateHealthSwapCandidate(
state *ScheduleState,
baseline analyzeHealthSnapshot,
problem analyzeHealthProblem,
anchor ScheduleTask,
other ScheduleTask,
) (analyzeHealthCandidate, int, bool) {
clone := state.Clone()
if clone == nil {
return analyzeHealthCandidate{}, 0, false
}
result := Swap(clone, anchor.StateID, other.StateID)
if strings.Contains(result, "失败") {
return analyzeHealthCandidate{}, 0, false
}
after := buildAnalyzeHealthSnapshotFromState(clone)
effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "swap", 4)
if !ok {
return analyzeHealthCandidate{}, 0, false
}
candidate := analyzeHealthCandidate{
CandidateID: fmt.Sprintf("swap_%d_%d", anchor.StateID, other.StateID),
Tool: "swap",
Arguments: map[string]any{
"task_a": anchor.StateID,
"task_b": other.StateID,
},
Summary: buildHealthCandidateSummary(
fmt.Sprintf("交换 [%d]%s 与 [%d]%s", anchor.StateID, anchor.Name, other.StateID, other.Name),
baseline,
after,
afterDecision,
),
Effect: effect,
After: buildHealthCandidateAfter(after, afterDecision),
}
return candidate, score, true
}
func simulateHealthMoveCandidate(
state *ScheduleState,
baseline analyzeHealthSnapshot,
problem analyzeHealthProblem,
task ScheduleTask,
target TaskSlot,
) (analyzeHealthCandidate, int, bool) {
clone := state.Clone()
if clone == nil {
return analyzeHealthCandidate{}, 0, false
}
result := Move(clone, task.StateID, target.Day, target.SlotStart)
if strings.Contains(result, "失败") {
return analyzeHealthCandidate{}, 0, false
}
after := buildAnalyzeHealthSnapshotFromState(clone)
moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart)
effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "move", moveCost)
if !ok {
return analyzeHealthCandidate{}, 0, false
}
candidate := analyzeHealthCandidate{
CandidateID: fmt.Sprintf("move_%d_%d_%d", task.StateID, target.Day, target.SlotStart),
Tool: "move",
Arguments: map[string]any{
"task_id": task.StateID,
"new_day": target.Day,
"new_slot_start": target.SlotStart,
},
Summary: buildHealthCandidateSummary(
fmt.Sprintf("移动 [%d]%s 到第 %d 天第 %d 节", task.StateID, task.Name, target.Day, target.SlotStart),
baseline,
after,
afterDecision,
),
Effect: effect,
After: buildHealthCandidateAfter(after, afterDecision),
}
return candidate, score, true
}
func enumerateLegalMovePlacements(state *ScheduleState, task ScheduleTask, limit int) []TaskSlot {
if state == nil || len(task.Slots) == 0 || limit <= 0 {
return nil
}
duration := taskDuration(task)
if duration <= 0 {
return nil
}
out := make([]TaskSlot, 0, limit)
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, []TaskSlot{target}) {
continue
}
if err := validateLocalOrderForSinglePlacement(state, task.StateID, []TaskSlot{target}); err != nil {
continue
}
if conflict := findConflict(state, target.Day, target.SlotStart, target.SlotEnd, task.StateID); conflict != nil {
continue
}
out = append(out, target)
if len(out) >= limit {
return out
}
}
}
}
return out
}
func evaluateHealthCandidateOutcome(
baseline analyzeHealthSnapshot,
after analyzeHealthSnapshot,
afterState *ScheduleState,
problem analyzeHealthProblem,
operation string,
moveCost int,
) (string, int, analyzeHealthDecisionBase, bool) {
afterDecision := buildAnalyzeHealthCandidateAfterBrief(afterState, after)
effect, score, ok := evaluateHealthCandidateScoreOnly(
baseline,
after,
problem,
operation,
moveCost,
)
return effect, score, afterDecision, ok
}
// evaluateHealthCandidateScoreOnly 只计算候选的收益与是否保留,不构造任何 after 视图。
//
// 职责边界:
// 1. 这里复用当前 active 候选过滤口径:只保留真正改善的候选,并返回排序所需 score。
// 2. 这里绝不依赖 afterDecision / brief helper供 score-only 扫描层安全复用。
// 3. 这样正式扫描器与 after brief 都能共享同一套收益定义,同时避免递归闭环。
func evaluateHealthCandidateScoreOnly(
baseline analyzeHealthSnapshot,
after analyzeHealthSnapshot,
problem analyzeHealthProblem,
operation string,
moveCost int,
) (string, int, bool) {
baselineRepairable := countRepairableHeavyAdjacentDays(baseline.Rhythm)
afterRepairable := countRepairableHeavyAdjacentDays(after.Rhythm)
afterProblemStill := isRepairableHeavyAdjacentDay(after.Rhythm, problem.DayIndex)
effect := healthCandidateEffectNoGain
switch {
case afterRepairable < baselineRepairable:
if afterRepairable > 0 {
effect = healthCandidateEffectPartialImprove
} else {
effect = healthCandidateEffectImprove
}
case afterRepairable > baselineRepairable:
effect = healthCandidateEffectRegress
case afterProblemStill:
effect = healthCandidateEffectNoGain
default:
effect = healthCandidateEffectShift
}
if effect != healthCandidateEffectImprove && effect != healthCandidateEffectPartialImprove {
return effect, 0, false
}
score := (baselineRepairable-afterRepairable)*1000 +
(baseline.Rhythm.Overview.MaxSwitchCount-after.Rhythm.Overview.MaxSwitchCount)*20 +
int((after.Rhythm.Overview.SameTypeTransitionRatio-baseline.Rhythm.Overview.SameTypeTransitionRatio)*100) -
moveCost
if strings.EqualFold(strings.TrimSpace(operation), "swap") {
score += 10
}
return effect, score, true
}
func buildHealthCandidateSummary(
actionText string,
baseline analyzeHealthSnapshot,
after analyzeHealthSnapshot,
afterDecision analyzeHealthDecisionBase,
) string {
return fmt.Sprintf(
"%s可修复的高认知相邻天数 %d -> %dmax_switch_count %d -> %dsame_type_ratio %.2f -> %.2f;复诊后主问题:%s",
actionText,
countRepairableHeavyAdjacentDays(baseline.Rhythm),
countRepairableHeavyAdjacentDays(after.Rhythm),
baseline.Rhythm.Overview.MaxSwitchCount,
after.Rhythm.Overview.MaxSwitchCount,
baseline.Rhythm.Overview.SameTypeTransitionRatio,
after.Rhythm.Overview.SameTypeTransitionRatio,
fallbackHealthProblemText(afterDecision.PrimaryProblem),
)
}
func buildHealthCandidateAfter(after analyzeHealthSnapshot, decision analyzeHealthDecisionBase) analyzeHealthCandidateAfter {
return analyzeHealthCandidateAfter{
CanClose: !decision.ShouldContinueOptimize,
PrimaryProblem: decision.PrimaryProblem,
RecommendedOperation: decision.RecommendedOperation,
HeavyAdjacentDays: after.Rhythm.Overview.HeavyAdjacentDays,
MaxSwitchCount: after.Rhythm.Overview.MaxSwitchCount,
SameTypeRatio: after.Rhythm.Overview.SameTypeTransitionRatio,
}
}
func buildHealthCloseCandidate(summary string, baseline analyzeHealthSnapshot, decisionBase analyzeHealthDecisionBase) analyzeHealthCandidate {
return analyzeHealthCandidate{
CandidateID: "close",
Summary: summary,
Effect: healthCandidateEffectClose,
After: buildHealthCandidateAfter(baseline, decisionBase),
}
}
func countRepairableHeavyAdjacentDays(rhythm analyzeRhythmMetrics) int {
count := 0
for _, day := range rhythm.Days {
if !day.HeavyAdjacent {
continue
}
if shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) {
continue
}
count++
}
return count
}
func isRepairableHeavyAdjacentDay(rhythm analyzeRhythmMetrics, dayIndex int) bool {
for _, day := range rhythm.Days {
if day.DayIndex != dayIndex {
continue
}
return day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day)
}
return false
}
func findHeavyAdjacentPairOnDay(state *ScheduleState, dayIndex int) (*analyzeHeavyAdjacentPair, bool) {
if state == nil || dayIndex <= 0 {
return nil, false
}
tasks := collectPlacedTaskBlocksOnDay(state, dayIndex)
if len(tasks) < 2 {
return nil, false
}
for i := 1; i < len(tasks); i++ {
left := tasks[i-1]
right := tasks[i]
if strings.TrimSpace(left.Category) == "" || strings.TrimSpace(right.Category) == "" {
continue
}
if strings.TrimSpace(left.Category) == strings.TrimSpace(right.Category) {
continue
}
if !isHighIntensityTaskForHealth(state, left) || !isHighIntensityTaskForHealth(state, right) {
continue
}
pair := analyzeHeavyAdjacentPair{
DayIndex: dayIndex,
Left: left,
Right: right,
}
return &pair, true
}
return nil, false
}
func collectPlacedTaskBlocksOnDay(state *ScheduleState, dayIndex int) []ScheduleTask {
if state == nil || dayIndex <= 0 {
return nil
}
out := make([]ScheduleTask, 0)
for _, task := range state.Tasks {
if len(task.Slots) == 0 || isCourseScheduleTask(task) {
continue
}
for _, slot := range task.Slots {
if slot.Day != dayIndex {
continue
}
out = append(out, task)
break
}
}
sort.SliceStable(out, func(i, j int) bool {
left := out[i].Slots[0]
right := out[j].Slots[0]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.SlotStart != right.SlotStart {
return left.SlotStart < right.SlotStart
}
if left.SlotEnd != right.SlotEnd {
return left.SlotEnd < right.SlotEnd
}
return out[i].StateID < out[j].StateID
})
return out
}
func isHighIntensityTaskForHealth(state *ScheduleState, task ScheduleTask) bool {
meta := findTaskClassMetaForTask(state, task)
if meta == nil {
return false
}
return isHighIntensityMeta(*meta)
}
func findTaskClassMetaForTask(state *ScheduleState, task ScheduleTask) *TaskClassMeta {
if state == nil {
return nil
}
if task.TaskClassID > 0 {
for i := range state.TaskClasses {
if state.TaskClasses[i].ID == task.TaskClassID {
return &state.TaskClasses[i]
}
}
}
return findTaskClassMetaByName(state, task.Category)
}
func extractSuggestedProblemTasks(pair *analyzeHeavyAdjacentPair) []ScheduleTask {
if pair == nil {
return nil
}
out := make([]ScheduleTask, 0, 2)
if IsSuggestedTask(pair.Left) {
out = append(out, pair.Left)
}
if IsSuggestedTask(pair.Right) {
out = append(out, pair.Right)
}
return out
}
func fallbackHealthProblemText(value string) string {
text := strings.TrimSpace(value)
if text == "" {
return "可直接收口"
}
return text
}
func marshalCandidateArgumentsKey(args map[string]any) string {
if len(args) == 0 {
return ""
}
parts := make([]string, 0, len(args))
for key, value := range args {
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
}
sort.Strings(parts)
return strings.Join(parts, "|")
}
func minInt(a, b int) int {
if a <= b {
return a
}
return b
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}