后端: 1. analyze_health 候选复诊改为轻量 after brief 口径,候选模拟执行后只展示基础诊断结论,不再递归触发全局候选枚举,避免 score-only 评估阶段重复扫描和误判继续优化。 前端: 2. thinking_summary 支持同一条回复内多轮思考块——按 backendKey 维护独立阶段状态,final 后再次收到摘要会新建 reasoning block,避免多轮思考被合并、去重状态互相吞掉。 3. timeline 历史恢复改为复用后端事件 seq,正文、工具、状态、思考块都按原始顺序回放,减少刷新后消息块错位、插队和布局跳变。 4. 思考流式态下沉到当前活跃 reasoning block,只让正在输出的思考块闪光、显示游标和接收逐字追加,旧思考块完成后稳定折叠,不再被新一轮思考“复活”。 5. 清理助手消息重置逻辑,补齐 reasoning block、短摘要、耗时、折叠态和流式队列的状态回收,降低连续会话/重发时的残留干扰。
1156 lines
36 KiB
Go
1156 lines
36 KiB
Go
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 -> %d,max_switch_count %d -> %d,same_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
|
||
}
|