Files
smartmate/backend/logic/refine_compound_ops.go
LoveLosita f4ef6fb256 Version: 0.7.5.dev.260324
🐛 fix(agent/schedulerefine): 修复复合微调分支链路问题,并将 MinContextSwitch 重构为固定坑位重排语义

- 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题
-  保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果
- 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch
- 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效
- ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变”
- 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突
- 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题
- 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败

📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程

♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent

- 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构
- 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责
- 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象
- 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力
- 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖
- 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路
- ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法
- 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件
- 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定
- 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档

♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt
2026-03-24 21:35:22 +08:00

474 lines
15 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 logic
import (
"fmt"
"sort"
"strings"
)
// RefineTaskCandidate 表示复合工具规划阶段可移动的任务候选。
//
// 职责边界:
// 1. 只承载“任务当前坐标 + 规划所需标签”;
// 2. 不承载冲突判断、窗口判断等执行期逻辑;
// 3. 由调用方保证 task_item_id 唯一且为正数。
type RefineTaskCandidate struct {
TaskItemID int
Week int
DayOfWeek int
SectionFrom int
SectionTo int
Name string
ContextTag string
OriginRank int
}
// RefineSlotCandidate 表示复合工具可选落点(坑位)。
//
// 职责边界:
// 1. 只描述可候选的时段坐标;
// 2. 不描述“为什么可用”,可用性由调用方预先筛好;
// 3. Span 由 SectionFrom/SectionTo 推导,不单独存储。
type RefineSlotCandidate struct {
Week int
DayOfWeek int
SectionFrom int
SectionTo int
}
// RefineMovePlanItem 表示“任务 -> 目标坑位”的确定性规划结果。
type RefineMovePlanItem struct {
TaskItemID int
ToWeek int
ToDay int
ToSectionFrom int
ToSectionTo int
}
// RefineCompositePlanOptions 是复合规划器的可选辅助输入。
//
// 说明:
// 1. ExistingDayLoad 用于提供“目标范围内的既有负载基线”,用于均匀铺开打分;
// 2. key 约定为 "week-day",例如 "16-3"
// 3. 未提供时,规划器按 0 基线处理。
type RefineCompositePlanOptions struct {
ExistingDayLoad map[string]int
}
// PlanEvenSpreadMoves 规划“均匀铺开”的确定性移动方案。
//
// 步骤化说明:
// 1. 先按稳定顺序归一化任务与坑位,保证同输入必同输出;
// 2. 逐任务选择“投放后日负载最小”的坑位,主目标是降低日负载离散度;
// 3. 同分时按时间更早优先,进一步保证确定性;
// 4. 若某任务不存在同跨度坑位,直接失败并返回明确错误。
func PlanEvenSpreadMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, options RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
normalizedTasks, err := normalizeRefineTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizeRefineSlots(slots)
if err != nil {
return nil, err
}
if len(normalizedSlots) < len(normalizedTasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
}
// 1. dayLoad 记录“当前已占 + 本次规划已分配”的日负载。
// 2. 这里先写入调用方提供的既有基线,再在循环中动态递增。
dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
for key, value := range options.ExistingDayLoad {
if value <= 0 {
continue
}
dayLoad[strings.TrimSpace(key)] = value
}
used := make([]bool, len(normalizedSlots))
moves := make([]RefineMovePlanItem, 0, len(normalizedTasks))
selectedSlots := make([]RefineSlotCandidate, 0, len(normalizedTasks))
for _, task := range normalizedTasks {
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
bestIdx := -1
bestScore := int(^uint(0) >> 1) // max int
for idx, slot := range normalizedSlots {
if used[idx] {
continue
}
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
continue
}
if slotOverlapsAny(slot, selectedSlots) {
continue
}
dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
projectedLoad := dayLoad[dayKey] + 1
// 1. projectedLoad 是主目标(越小越均衡);
// 2. idx 是次级目标(越早的坑位越优先,保证稳定)。
score := projectedLoad*10000 + idx
if score < bestScore {
bestScore = score
bestIdx = idx
}
}
if bestIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
}
chosen := normalizedSlots[bestIdx]
used[bestIdx] = true
selectedSlots = append(selectedSlots, chosen)
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
moves = append(moves, RefineMovePlanItem{
TaskItemID: task.TaskItemID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
// PlanMinContextSwitchMoves 规划“同科目上下文切换最少”的确定性移动方案。
//
// 步骤化说明:
// 1. 先把任务按 context_tag 分组,目标是让同组任务尽量连续;
// 2. 分组顺序按“组大小降序 + 最早 origin_rank + 标签字典序”稳定排序;
// 3. 组内按任务稳定顺序排,再顺序填入时间上最早可用同跨度坑位;
// 4. 若某任务不存在同跨度坑位,立即失败并返回明确错误。
func PlanMinContextSwitchMoves(tasks []RefineTaskCandidate, slots []RefineSlotCandidate, _ RefineCompositePlanOptions) ([]RefineMovePlanItem, error) {
normalizedTasks, err := normalizeRefineTasks(tasks)
if err != nil {
return nil, err
}
normalizedSlots, err := normalizeRefineSlots(slots)
if err != nil {
return nil, err
}
if len(normalizedSlots) < len(normalizedTasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
}
type taskGroup struct {
ContextKey string
Tasks []RefineTaskCandidate
MinRank int
}
groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
groupMap := make(map[string]*taskGroup)
groupOrder := make([]string, 0, len(normalizedTasks))
for _, task := range normalizedTasks {
key := groupingKeys[task.TaskItemID]
group, exists := groupMap[key]
if !exists {
group = &taskGroup{
ContextKey: key,
MinRank: normalizedOriginRank(task),
}
groupMap[key] = group
groupOrder = append(groupOrder, key)
}
group.Tasks = append(group.Tasks, task)
if rank := normalizedOriginRank(task); rank < group.MinRank {
group.MinRank = rank
}
}
groups := make([]taskGroup, 0, len(groupMap))
for _, key := range groupOrder {
group := groupMap[key]
sort.SliceStable(group.Tasks, func(i, j int) bool {
return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
})
groups = append(groups, *group)
}
sort.SliceStable(groups, func(i, j int) bool {
if len(groups[i].Tasks) != len(groups[j].Tasks) {
return len(groups[i].Tasks) > len(groups[j].Tasks)
}
if groups[i].MinRank != groups[j].MinRank {
return groups[i].MinRank < groups[j].MinRank
}
return groups[i].ContextKey < groups[j].ContextKey
})
orderedTasks := make([]RefineTaskCandidate, 0, len(normalizedTasks))
for _, group := range groups {
orderedTasks = append(orderedTasks, group.Tasks...)
}
used := make([]bool, len(normalizedSlots))
moves := make([]RefineMovePlanItem, 0, len(orderedTasks))
selectedSlots := make([]RefineSlotCandidate, 0, len(orderedTasks))
for _, task := range orderedTasks {
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
chosenIdx := -1
for idx, slot := range normalizedSlots {
if used[idx] {
continue
}
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
continue
}
if slotOverlapsAny(slot, selectedSlots) {
continue
}
chosenIdx = idx
break
}
if chosenIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskItemID)
}
chosen := normalizedSlots[chosenIdx]
used[chosenIdx] = true
selectedSlots = append(selectedSlots, chosen)
moves = append(moves, RefineMovePlanItem{
TaskItemID: task.TaskItemID,
ToWeek: chosen.Week,
ToDay: chosen.DayOfWeek,
ToSectionFrom: chosen.SectionFrom,
ToSectionTo: chosen.SectionTo,
})
}
return moves, nil
}
func normalizeRefineTasks(tasks []RefineTaskCandidate) ([]RefineTaskCandidate, error) {
if len(tasks) == 0 {
return nil, fmt.Errorf("任务列表为空")
}
normalized := make([]RefineTaskCandidate, 0, len(tasks))
seen := make(map[int]struct{}, len(tasks))
for _, task := range tasks {
if task.TaskItemID <= 0 {
return nil, fmt.Errorf("存在非法 task_item_id=%d", task.TaskItemID)
}
if _, exists := seen[task.TaskItemID]; exists {
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskItemID)
}
if !isValidDay(task.DayOfWeek) {
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskItemID, task.DayOfWeek)
}
if !isValidSection(task.SectionFrom, task.SectionTo) {
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskItemID, task.SectionFrom, task.SectionTo)
}
seen[task.TaskItemID] = struct{}{}
normalized = append(normalized, task)
}
sort.SliceStable(normalized, func(i, j int) bool {
return compareTaskOrder(normalized[i], normalized[j]) < 0
})
return normalized, nil
}
func normalizeRefineSlots(slots []RefineSlotCandidate) ([]RefineSlotCandidate, error) {
if len(slots) == 0 {
return nil, fmt.Errorf("可用坑位为空")
}
normalized := make([]RefineSlotCandidate, 0, len(slots))
seen := make(map[string]struct{}, len(slots))
for _, slot := range slots {
if slot.Week <= 0 {
return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
}
if !isValidDay(slot.DayOfWeek) {
return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
}
if !isValidSection(slot.SectionFrom, slot.SectionTo) {
return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
}
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, slot)
}
sort.SliceStable(normalized, func(i, j int) bool {
if normalized[i].Week != normalized[j].Week {
return normalized[i].Week < normalized[j].Week
}
if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
return normalized[i].DayOfWeek < normalized[j].DayOfWeek
}
if normalized[i].SectionFrom != normalized[j].SectionFrom {
return normalized[i].SectionFrom < normalized[j].SectionFrom
}
return normalized[i].SectionTo < normalized[j].SectionTo
})
return normalized, nil
}
func compareTaskOrder(a, b RefineTaskCandidate) int {
rankA := normalizedOriginRank(a)
rankB := normalizedOriginRank(b)
if rankA != rankB {
return rankA - rankB
}
if a.Week != b.Week {
return a.Week - b.Week
}
if a.DayOfWeek != b.DayOfWeek {
return a.DayOfWeek - b.DayOfWeek
}
if a.SectionFrom != b.SectionFrom {
return a.SectionFrom - b.SectionFrom
}
if a.SectionTo != b.SectionTo {
return a.SectionTo - b.SectionTo
}
return a.TaskItemID - b.TaskItemID
}
func normalizedOriginRank(task RefineTaskCandidate) int {
if task.OriginRank > 0 {
return task.OriginRank
}
// 1. 无 origin_rank 时回退到较大稳定值,避免把“未知顺序”抢到前面。
// 2. 叠加 task_id 作为细粒度稳定因子,保证排序可复现。
return 1_000_000 + task.TaskItemID
}
func normalizeContextKey(tag string) string {
text := strings.TrimSpace(tag)
if text == "" {
return "General"
}
return text
}
// buildMinContextGroupingKeys 为 MinContextSwitch 生成“实际用于聚类”的分组键。
//
// 步骤化说明:
// 1. 先优先使用现有 ContextTag避免影响已稳定的显式标签链路
// 2. 若整批任务只剩一个粗粒度标签(例如全是 General/High-Logic说明标签对“同科目连续”帮助不足
// 3. 此时再基于任务名做学科关键词兜底,只在确实能拉开分组时启用;
// 4. 若任务名也无法识别,则继续回落到原 ContextTag保证行为可预测。
func buildMinContextGroupingKeys(tasks []RefineTaskCandidate) map[int]string {
keys := make(map[int]string, len(tasks))
distinctExplicit := make(map[string]struct{}, len(tasks))
distinctNonCoarse := make(map[string]struct{}, len(tasks))
for _, task := range tasks {
key := normalizeContextKey(task.ContextTag)
keys[task.TaskItemID] = key
distinctExplicit[key] = struct{}{}
if !isCoarseContextKey(key) {
distinctNonCoarse[key] = struct{}{}
}
}
// 1. 当显式标签已经至少区分出两类“非粗标签”时,直接尊重上游语义;
// 2. 避免把已稳定的 context_tag 分组再改写成名称启发式结果。
if len(distinctNonCoarse) >= 2 {
return keys
}
// 1. 若显式标签本来就有 2 类及以上,且不全是粗标签,也继续沿用;
// 2. 只有“整批退化到同一个粗标签”时,才值得尝试名称兜底。
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
return keys
}
inferredKeys := make(map[int]string, len(tasks))
distinctInferred := make(map[string]struct{}, len(tasks))
for _, task := range tasks {
inferred := inferSubjectContextKeyFromTaskName(task.Name)
if inferred == "" {
inferred = keys[task.TaskItemID]
}
inferredKeys[task.TaskItemID] = inferred
distinctInferred[inferred] = struct{}{}
}
if len(distinctInferred) >= 2 {
return inferredKeys
}
return keys
}
func isCoarseContextKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "", "general", "high-logic", "high_logic", "memory", "review":
return true
default:
return false
}
}
func inferSubjectContextKeyFromTaskName(name string) string {
text := strings.ToLower(strings.TrimSpace(name))
if text == "" {
return ""
}
subjectKeywordGroups := []struct {
keywords []string
groupKey string
}{
{
keywords: []string{
"概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
"分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
},
groupKey: "subject:probability",
},
{
keywords: []string{
"数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
"数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
},
groupKey: "subject:digital_logic",
},
{
keywords: []string{
"命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
"图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
},
groupKey: "subject:discrete_math",
},
}
for _, group := range subjectKeywordGroups {
for _, keyword := range group.keywords {
if strings.Contains(text, keyword) {
return group.groupKey
}
}
}
return ""
}
func composeDayKey(week, day int) string {
return fmt.Sprintf("%d-%d", week, day)
}
func sectionSpan(from, to int) int {
return to - from + 1
}
func isValidDay(day int) bool {
return day >= 1 && day <= 7
}
func isValidSection(from, to int) bool {
if from < 1 || to > 12 {
return false
}
return from <= to
}
func slotOverlapsAny(candidate RefineSlotCandidate, selected []RefineSlotCandidate) bool {
for _, current := range selected {
if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
continue
}
if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
return true
}
}
return false
}