Version: 0.9.45.dev.260427

后端:
1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜
2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写
3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态
4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分
5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定

前端:
6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作

仓库:
7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件

PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
This commit is contained in:
Losita
2026-04-27 01:09:37 +08:00
parent 04b5836b39
commit 66c06eed0a
60 changed files with 9163 additions and 1819 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
package schedule
import "strings"
// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。
//
// 职责边界:
// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。
// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。
// 3. 若没有真正改善的候选,则明确返回 close避免把 LLM 推回开放式全窗搜索。
func buildAnalyzeHealthDecisionV2(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecision {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecision{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
ImprovementSignal: buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
base.ProblemScope,
base.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
),
}
if !shouldEnterHealthCandidateLoop(base) {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base),
}
decision.ShouldContinueOptimize = false
return decision
}
bestScan, ok := findBestHealthProblemScanResult(state, snapshot)
if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base),
}
decision.ShouldContinueOptimize = false
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
decision.ProblemScope = nil
decision.RecommendedOperation = "close"
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
decision.IsForcedImperfection = true
}
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
decision.PrimaryProblem = bestScan.Problem.Summary
decision.ProblemScope = bestScan.Problem.Scope
decision.Candidates = append(decision.Candidates, bestScan.Candidates...)
decision.Candidates = append(decision.Candidates,
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
)
decision.ShouldContinueOptimize = true
decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool)
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。
//
// 步骤化说明:
// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。
// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。
// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。
func findBestHealthProblemScanResult(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) (analyzeHealthProblemScanResult, bool) {
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
if len(problems) == 0 {
return analyzeHealthProblemScanResult{}, false
}
results := make([]analyzeHealthProblemScanResult, 0, len(problems))
for _, problem := range problems {
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
if !ok {
continue
}
results = append(results, scan)
}
return selectBestHealthProblemScanResult(results)
}
// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。
//
// 说明:
// 1. 只有 base 已判定“值得继续优化”时才放行。
// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。
// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。
func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool {
if !base.ShouldContinueOptimize {
return false
}
switch strings.TrimSpace(base.RecommendedOperation) {
case "move", "swap":
return true
default:
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
)
}
}
minContextProposals := make(map[int][]TaskSlot, len(afterByID))
for taskID, after := range afterByID {
minContextProposals[taskID] = []TaskSlot{after.Slot}
}
if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 4. 全量通过后再原子提交,避免半成品状态。
clone := state.Clone()
@@ -256,6 +263,13 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
)
}
}
spreadEvenProposals := make(map[int][]TaskSlot, len(afterByID))
for taskID, after := range afterByID {
spreadEvenProposals[taskID] = []TaskSlot{after.Slot}
}
if err := validateLocalOrderBatchPlacement(state, spreadEvenProposals); err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
clone := state.Clone()
for taskID, after := range afterByID {

View File

@@ -0,0 +1,184 @@
package schedule
import "fmt"
// validateLocalOrderForSinglePlacement 校验单个任务落到目标时段后,是否仍满足同任务类内部顺序约束。
//
// 职责边界:
// 1. 只负责“同任务类内部顺序”这一条规则,不负责冲突、锁定、范围合法性;
// 2. 采用“克隆态 + 假设落位”方式校验,避免直接污染真实 state
// 3. 若任务不属于 task_item / 缺少 task_order / 当前无边界约束,直接放行。
func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targetSlots []TaskSlot) error {
if len(targetSlots) == 0 {
return nil
}
return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
taskID: cloneScheduleTaskSlots(targetSlots),
})
}
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
//
// 职责边界:
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
if state == nil || len(proposals) == 0 {
return nil
}
clone := state.Clone()
for taskID, slots := range proposals {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
task.Slots = cloneScheduleTaskSlots(slots)
}
for taskID := range proposals {
if err := validateTaskLocalOrderOnState(clone, taskID); err != nil {
return err
}
}
return nil
}
// validateTaskLocalOrderOnState 判断某个任务在当前假设态下,是否仍处于同任务类前驱/后继之间。
func validateTaskLocalOrderOnState(state *ScheduleState, taskID int) error {
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Errorf("顺序约束校验失败任务ID %d 不存在", taskID)
}
if !shouldEnforceTaskLocalOrder(*task) || len(task.Slots) == 0 {
return nil
}
prevTask, nextTask := findTaskClassNeighbors(state, *task)
targetStartDay, targetStartSlot, _ := earliestScheduleTaskSlot(task.Slots)
targetEndDay, _, targetEndSlot := latestScheduleTaskSlot(task.Slots)
if prevTask != nil && len(prevTask.Slots) > 0 {
prevEndDay, _, prevEndSlot := latestScheduleTaskSlot(prevTask.Slots)
if !isStrictlyAfter(targetStartDay, targetStartSlot, prevEndDay, prevEndSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须晚于同任务类前一个任务 %s 的结束位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*prevTask),
formatTaskSlotsBriefWithState(state, prevTask.Slots),
)
}
}
if nextTask != nil && len(nextTask.Slots) > 0 {
nextStartDay, nextStartSlot, _ := earliestScheduleTaskSlot(nextTask.Slots)
if !isStrictlyBefore(targetEndDay, targetEndSlot, nextStartDay, nextStartSlot) {
return fmt.Errorf(
"顺序约束不满足:[%d]%s 不能放到%s。它必须早于同任务类后一个任务 %s 的开始位置(%s。",
task.StateID,
task.Name,
formatTaskSlotsBriefWithState(state, task.Slots),
formatTaskLabel(*nextTask),
formatTaskSlotsBriefWithState(state, nextTask.Slots),
)
}
}
return nil
}
// shouldEnforceTaskLocalOrder 判断任务是否需要参与“同任务类内部顺序”约束。
func shouldEnforceTaskLocalOrder(task ScheduleTask) bool {
return task.Source == "task_item" && task.TaskClassID > 0 && task.TaskOrder > 0
}
// findTaskClassNeighbors 查找同任务类中 order 紧邻当前任务的前驱与后继。
func findTaskClassNeighbors(state *ScheduleState, task ScheduleTask) (prevTask *ScheduleTask, nextTask *ScheduleTask) {
if state == nil || !shouldEnforceTaskLocalOrder(task) {
return nil, nil
}
for i := range state.Tasks {
candidate := &state.Tasks[i]
if candidate.StateID == task.StateID {
continue
}
if !shouldEnforceTaskLocalOrder(*candidate) {
continue
}
if candidate.TaskClassID != task.TaskClassID {
continue
}
if candidate.TaskOrder < task.TaskOrder {
if prevTask == nil || candidate.TaskOrder > prevTask.TaskOrder {
prevTask = candidate
}
continue
}
if candidate.TaskOrder > task.TaskOrder {
if nextTask == nil || candidate.TaskOrder < nextTask.TaskOrder {
nextTask = candidate
}
}
}
return prevTask, nextTask
}
func earliestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day < best.Day ||
(current.Day == best.Day && current.SlotStart < best.SlotStart) ||
(current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func latestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
if len(slots) == 0 {
return 0, 0, 0
}
best := slots[0]
for i := 1; i < len(slots); i++ {
current := slots[i]
if current.Day > best.Day ||
(current.Day == best.Day && current.SlotEnd > best.SlotEnd) ||
(current.Day == best.Day && current.SlotEnd == best.SlotEnd && current.SlotStart > best.SlotStart) {
best = current
}
}
return best.Day, best.SlotStart, best.SlotEnd
}
func isStrictlyAfter(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA > dayB
}
return slotA > slotB
}
func isStrictlyBefore(dayA, slotA, dayB, slotB int) bool {
if dayA != dayB {
return dayA < dayB
}
return slotA < slotB
}
func cloneScheduleTaskSlots(src []TaskSlot) []TaskSlot {
if len(src) == 0 {
return nil
}
dst := make([]TaskSlot, len(src))
copy(dst, src)
return dst
}

View File

@@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
//
// 步骤化说明:
// 1. 默认 enqueue=true让 LLM 优先走“逐项处理”而不是一次性批量组合
// 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
// 3. 入队仅保存 task_id不复制任务全文避免队列状态膨胀。
queueInfo := (*queryTargetQueueInfo)(nil)
@@ -566,7 +566,7 @@ func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTa
Limit: limit,
TaskIDSet: intSliceToSet(taskIDs),
Category: strings.TrimSpace(readStringAny(args, "category", "")),
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
Enqueue: readBoolAnyWithDefault(args, false, "enqueue"),
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
}, nil
}

View File

@@ -92,6 +92,13 @@ func GetOverview(state *ScheduleState) string {
}
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
}
if len(tc.ExcludedDaysOfWeek) > 0 {
parts := make([]string, len(tc.ExcludedDaysOfWeek))
for i, d := range tc.ExcludedDaysOfWeek {
parts[i] = fmt.Sprintf("%d", d)
}
line += fmt.Sprintf(" 排除星期=[%s]", strings.Join(parts, ","))
}
sb.WriteString(line + "\n")
}
}

View File

@@ -20,17 +20,25 @@ type TaskSlot struct {
SlotEnd int `json:"slot_end"`
}
// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考
// 只记录影响排课决策的字段,不暴露数据库内部细节。
// TaskClassMeta 是任务类级别的调度与认知画像元数据
//
// 职责边界:
// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段;
// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据;
// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。
type TaskClassMeta struct {
ID int `json:"id"`
Name string `json:"name"`
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
StartDate string `json:"start_date,omitempty"` // 排程起始日期YYYY-MM-DD
EndDate string `json:"end_date,omitempty"` // 排程截止日期YYYY-MM-DD
ID int `json:"id"`
Name string `json:"name"`
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几1-7空=无限制
StartDate string `json:"start_date,omitempty"` // 排程起始日期YYYY-MM-DD
EndDate string `json:"end_date,omitempty"` // 排程截止日期YYYY-MM-DD
SubjectType string `json:"subject_type,omitempty"` // "quantitative" | "memory" | "reading" | "mixed"
DifficultyLevel string `json:"difficulty_level,omitempty"`
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
}
// ScheduleTask is a unified task representation in the tool state.
@@ -51,6 +59,9 @@ type ScheduleTask struct {
Duration int `json:"duration,omitempty"`
// source=task_item only: TaskClass.ID用于反查任务类约束。
TaskClassID int `json:"task_class_id,omitempty"`
// source=task_item only: 任务在所属任务类内的稳定顺序。
// 该字段只用于写工具层的“同任务类内部顺序约束”,不直接暴露给 LLM 做决策。
TaskOrder int `json:"task_order,omitempty"`
// source=task_item only: TaskClass.ID for category lookup (internal alias).
CategoryID int `json:"category_id,omitempty"`
// source=event only: whether this slot allows embedding other tasks.
@@ -68,7 +79,7 @@ type ScheduleTask struct {
type ScheduleState struct {
Window ScheduleWindow `json:"window"`
Tasks []ScheduleTask `json:"tasks"`
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
//
// 职责边界:

View File

@@ -56,6 +56,9 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
if err := validateSlotRange(slotStart, slotEnd); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error())
}
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}); err != nil {
return fmt.Sprintf("放置失败:%s", err.Error())
}
// 4. 冲突检测。
conflict := findConflict(state, day, slotStart, slotEnd)
@@ -136,6 +139,9 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}); err != nil {
return fmt.Sprintf("移动失败:%s", err.Error())
}
// 5. 冲突检测(排除自身)。
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
@@ -213,6 +219,12 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
copy(oldSlotsA, taskA.Slots)
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
copy(oldSlotsB, taskB.Slots)
if err := validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
taskAID: cloneScheduleTaskSlots(oldSlotsB),
taskBID: cloneScheduleTaskSlots(oldSlotsA),
}); err != nil {
return fmt.Sprintf("交换失败:%s", err.Error())
}
// 6. 交换 Slots。
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
@@ -303,6 +315,22 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s第%d条移动请求", err.Error(), i+1)
}
}
proposals := make(map[int][]TaskSlot, len(moves))
for _, m := range moves {
task := state.TaskByStateID(m.TaskID)
if task == nil {
continue
}
duration := taskDuration(*task)
proposals[m.TaskID] = []TaskSlot{{
Day: m.NewDay,
SlotStart: m.NewSlotStart,
SlotEnd: m.NewSlotStart + duration - 1,
}}
}
if err := validateLocalOrderBatchPlacement(state, proposals); err != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s", err.Error())
}
// 2. 克隆 state在克隆上执行。
clone := state.Clone()