Version: 0.9.46.dev.260427

后端:
1. taskclass 执行闭环继续收紧——Plan / Execute 全面切到“最小工具闭环”视角,明确学习目标/总节数/禁排时段/排除星期默认停留 taskclass 域;未给日期范围时禁止擅自补 start_date/end_date,upsert_task_class 重试前先做写前检查并区分“内部表示修正”与“必须追问用户”的关键时间事实
2. QuickTask / TaskQuery 轻量链路继续收敛——新增 model/taskquery_contract.go 统一查询协议,QuickTaskDeps / start.go 改用 model 层参数;删除 query_tasks / quick_note_create 旧工具实现,避免任务查询与随口记再回流 execute 工具链
3. schedule 微调工具继续瘦身——下线 spread_even / min_context_switch 及其复合规划逻辑,清理 analyze_load / analyze_subjects / analyze_context / analyze_tolerance 等历史能力;execute 顺序策略收敛为局部 move / swap,提示词与工具目录仅暴露当前真实可用工具
4. 执行与时间线体验补齐——execute 为流式 speak 补发归一化尾部,避免 deliver 文案黏连;前端时间线新增 interrupt / status 协议识别、工具事件归并与状态过滤,减少 ToolTrace 重复和会话重建误判
前端:
5. AssistantPanel 适配新版 timeline extra 事件——schedule_agent.ts 补齐 interrupt / status kind,工具调用与结果按摘要/参数/工具名合并,恢复历史时不再把协议事件误判成用户消息
This commit is contained in:
LoveLosita
2026-04-27 12:20:17 +08:00
parent 66c06eed0a
commit 736ba0cff3
25 changed files with 425 additions and 2173 deletions

View File

@@ -288,7 +288,7 @@ func Start() {
}
return created.ID, nil
},
QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) {
QueryTasks: func(ctx context.Context, userID int, params newagentmodel.TaskQueryParams) ([]newagentmodel.TaskQueryResult, error) {
req := newagentmodel.TaskQueryRequest{
UserID: userID,
Quadrant: params.Quadrant,
@@ -304,13 +304,13 @@ func Start() {
if err != nil {
return nil, err
}
results := make([]newagenttools.TaskQueryResult, 0, len(records))
results := make([]newagentmodel.TaskQueryResult, 0, len(records))
for _, r := range records {
deadlineStr := ""
if r.DeadlineAt != nil {
deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04")
}
results = append(results, newagenttools.TaskQueryResult{
results = append(results, newagentmodel.TaskQueryResult{
ID: r.ID,
Title: r.Title,
PriorityGroup: r.PriorityGroup,

View File

@@ -1,473 +0,0 @@
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
}

View File

@@ -1,131 +0,0 @@
package logic
import (
"sort"
"testing"
)
func TestPlanEvenSpreadMovesPrefersLowerLoadDay(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 101, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, OriginRank: 1},
{TaskItemID: 102, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, OriginRank: 2},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 2, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 3, SectionFrom: 1, SectionTo: 2},
}
moves, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{
ExistingDayLoad: map[string]int{
composeDayKey(12, 1): 5,
composeDayKey(12, 2): 1,
composeDayKey(12, 3): 0,
},
})
if err != nil {
t.Fatalf("PlanEvenSpreadMoves 返回错误: %v", err)
}
if len(moves) != 2 {
t.Fatalf("期望移动 2 条,实际=%d", len(moves))
}
// 1. 低负载日(周三)应优先被填充;
// 2. 第二条应落在次低负载日(周二),而不是高负载日(周一)。
weekDayByID := make(map[int][2]int, len(moves))
for _, move := range moves {
weekDayByID[move.TaskItemID] = [2]int{move.ToWeek, move.ToDay}
}
if got := weekDayByID[101]; got != [2]int{12, 3} {
t.Fatalf("任务101应优先落到 W12D3实际=%v", got)
}
if got := weekDayByID[102]; got != [2]int{12, 2} {
t.Fatalf("任务102应落到 W12D2实际=%v", got)
}
}
func TestPlanMinContextSwitchMovesGroupsSameContext(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 201, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, ContextTag: "数学", OriginRank: 1},
{TaskItemID: 202, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, ContextTag: "算法", OriginRank: 2},
{TaskItemID: 203, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, ContextTag: "数学", OriginRank: 3},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
}
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
if err != nil {
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
}
if len(moves) != 3 {
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
}
// 1. “数学”有 2 条,分组后应先连续落在最早两个坑位;
// 2. 因此 201 与 203 对应的目标节次应是 1-2 与 3-4顺序由 origin_rank 决定)。
sort.SliceStable(moves, func(i, j int) bool {
if moves[i].ToWeek != moves[j].ToWeek {
return moves[i].ToWeek < moves[j].ToWeek
}
if moves[i].ToDay != moves[j].ToDay {
return moves[i].ToDay < moves[j].ToDay
}
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
})
if moves[0].TaskItemID != 201 || moves[1].TaskItemID != 203 {
t.Fatalf("期望前两个坑位由同上下文任务占据,实际=%+v", moves)
}
if moves[2].TaskItemID != 202 {
t.Fatalf("期望最后一个坑位为算法任务,实际=%+v", moves[2])
}
}
func TestPlanMinContextSwitchMovesFallsBackToTaskNameWhenAllGeneral(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2, Name: "随机事件与概率基础概念复习", ContextTag: "General", OriginRank: 1},
{TaskItemID: 302, Week: 16, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4, Name: "数制、码制与逻辑代数基础", ContextTag: "General", OriginRank: 2},
{TaskItemID: 303, Week: 16, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6, Name: "第二章 条件概率与全概率公式", ContextTag: "General", OriginRank: 3},
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2},
{Week: 12, DayOfWeek: 1, SectionFrom: 3, SectionTo: 4},
{Week: 12, DayOfWeek: 1, SectionFrom: 5, SectionTo: 6},
}
moves, err := PlanMinContextSwitchMoves(tasks, slots, RefineCompositePlanOptions{})
if err != nil {
t.Fatalf("PlanMinContextSwitchMoves 返回错误: %v", err)
}
if len(moves) != 3 {
t.Fatalf("期望移动 3 条,实际=%d", len(moves))
}
sort.SliceStable(moves, func(i, j int) bool {
if moves[i].ToWeek != moves[j].ToWeek {
return moves[i].ToWeek < moves[j].ToWeek
}
if moves[i].ToDay != moves[j].ToDay {
return moves[i].ToDay < moves[j].ToDay
}
return moves[i].ToSectionFrom < moves[j].ToSectionFrom
})
if moves[0].TaskItemID != 301 || moves[1].TaskItemID != 303 {
t.Fatalf("期望概率任务通过名称兜底连续聚类,实际=%+v", moves)
}
if moves[2].TaskItemID != 302 {
t.Fatalf("期望数电任务落在最后一个坑位,实际=%+v", moves[2])
}
}
func TestPlanEvenSpreadMovesReturnsErrorWhenSpanNotMatched(t *testing.T) {
tasks := []RefineTaskCandidate{
{TaskItemID: 301, Week: 16, DayOfWeek: 1, SectionFrom: 1, SectionTo: 3, OriginRank: 1}, // span=3
}
slots := []RefineSlotCandidate{
{Week: 12, DayOfWeek: 1, SectionFrom: 1, SectionTo: 2}, // span=2
}
_, err := PlanEvenSpreadMoves(tasks, slots, RefineCompositePlanOptions{})
if err == nil {
t.Fatalf("期望 span 不匹配时报错,实际 err=nil")
}
}

View File

@@ -173,7 +173,7 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla
if err != nil {
return nil, err
}
//3.把这些时间通过DTO函数回填到涉<EFBFBD><EFBFBD>周的 UserWeekSchedule 结构中,供前端展示
// 3. 把这些时间通过 DTO 函数回填到涉周的 UserWeekSchedule 结构中,供前端展示
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
}

View File

@@ -189,7 +189,7 @@ type CommonState struct {
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的:为 prompt/收口层提供“本轮是否真的动过日程写工具”的运行态信号。
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具
// UsedQuickNote 标记本轮是否走过“快捷随口记任务”路径
// 调用目的graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
UsedQuickNote bool `json:"used_quick_note,omitempty"`
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。

View File

@@ -105,12 +105,12 @@ type AgentGraphDeps struct {
//
// 职责边界:
// 1. QuickTask 节点直接调这些函数,不经过 ToolRegistry不走 ReAct 循环;
// 2. CreateTask 和 QueryTasks 的签名与 tools 包的 QuickNoteDeps / TaskQueryDeps 一致
// 2. 这里只保留“创建任务 / 查询任务”两类轻量能力,避免再回退到已下线的孤立工具链
type QuickTaskDeps struct {
// CreateTask 创建一条四象限任务,返回 task_id。
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time, urgencyThresholdAt *time.Time) (taskID int, err error)
// QueryTasks 按条件查询用户任务列表。
QueryTasks func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error)
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
}
// --- 记忆 pinned block 常量(供 agentsvc 和 node 层共享) ---

View File

@@ -0,0 +1,35 @@
package model
import "time"
// TaskQueryParams 描述快捷任务查询路径传给业务层的内部查询参数。
//
// 职责边界:
// 1. 这里只承载“查询条件”本身,不负责 args 解析、默认值填充和错误提示;
// 2. 所有字段均为轻量筛选语义,便于 quick_task 节点和 service 层直接复用;
// 3. 不承担 LLM 工具协议,因为 query_tasks 工具链已下线。
type TaskQueryParams struct {
Quadrant *int
SortBy string // deadline | priority | id
Order string // asc | desc
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryResult 描述快捷任务查询返回给上层的轻量任务视图。
//
// 职责边界:
// 1. 这里只保留展示所需字段,避免把底层任务模型直接暴露给 newAgent 节点;
// 2. 结果既可用于 quick_task 节点文本回复,也可供 service 装配其他轻量输出;
// 3. 不负责序列化策略和文案渲染。
type TaskQueryResult struct {
ID int `json:"id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
IsCompleted bool `json:"is_completed"`
DeadlineAt string `json:"deadline_at,omitempty"`
}

View File

@@ -27,7 +27,6 @@ const (
executeStatusBlockID = "execute.status"
executeSpeakBlockID = "execute.speak"
executePinnedKey = "execution_context"
toolMinContextSwitch = "min_context_switch"
toolAnalyzeHealth = "analyze_health"
executeHistoryKindKey = "newagent_history_kind"
executeHistoryKindStepAdvanced = "execute_step_advanced"
@@ -419,7 +418,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
// 非写工具的 confirm 动作自动降级为 continue。
// 调用目的:quick_note_create 等非写工具不应走确认卡片流程;
// 调用目的:快捷随口记这类非日程写工具不应走确认卡片流程;
// 即使 LLM 误输出 action=confirm也在此处强制修正
// 确保 speak 正常推流和持久化,不会因 confirm 卡片跳过 persistVisibleAssistantMessage。
if decision.Action == newagentmodel.ExecuteActionConfirm &&
@@ -454,6 +453,25 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
firstChunk = false
}
// 1. execute 正文若已经在流式阶段推给前端normalizeSpeak 新补出来的尾部(最常见是末尾 \n
// 不会自动回流到前端,只会留在 history / persist 中。
// 2. 这会导致下一跳 deliver 首条正文直接接在 execute 最后一段后面,前端表现成两段文本黏连。
// 3. 这里只补发“归一化后新增的尾巴”,不重发整段正文,也不改写中间内容,避免误伤已有流式体验。
if speakStreamed {
streamedText := fullText.String()
if tail := buildExecuteNormalizedSpeakTail(streamedText, decision.Speak); tail != "" {
if emitErr := emitter.EmitAssistantText(
executeSpeakBlockID,
executeStageName,
tail,
firstChunk,
); emitErr != nil {
return fmt.Errorf("执行文案尾部补发失败: %w", emitErr)
}
firstChunk = false
}
}
// 自省校验(仅 Plan 模式next_plan / done 必须附带 goal_check否则不推进追加修正让 LLM 重试。
//
// 1. ReAct无预定义步骤下不强制 goal_check避免 done 被错误拦截后进入循环;
@@ -514,7 +532,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
// 继续当前步骤的 ReAct 循环。
// 若有工具调用意图,则执行工具并记录证据。
if decision.ToolCall != nil {
// 1. 写工具必须走 confirmcontinue 只允许读工具。
// 1. 所有写工具必须走 confirmcontinue 只允许读工具。
// 2. 若模型误输出 continue+写工具,这里先做纠偏,不直接执行写操作。
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
flowState.ConsecutiveCorrections++
@@ -533,7 +551,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
executeStatusBlockID,
executeStageName,
"executing",
fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue日程修改工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
fmt.Sprintf("执行校验:写工具 %q 未执行。原因:模型输出了 action=continue所有写工具必须使用 action=confirm。", strings.TrimSpace(decision.ToolCall.Name)),
false,
)
llmOutput := decision.Speak
@@ -544,7 +562,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
conversationContext,
llmOutput,
fmt.Sprintf("你输出了 action=continue但工具 %q 属于写操作。", decision.ToolCall.Name),
"写操作必须输出 action=confirm并附带同一个 tool_callcontinue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
"所有写操作必须输出 action=confirm并附带同一个 tool_callcontinue 仅用于读工具。这次写操作没有执行,请直接重发 confirm。",
)
return nil
}
@@ -1699,28 +1717,6 @@ func executeToolCall(
}
// 2. 执行工具。
// 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch并写回工具观察结果。
if shouldBlockMinContextSwitch(flowState, toolName) {
blockedResult := "已拒绝执行 min_context_switch当前未授权打乱顺序。如需使用该工具请先由用户明确说明“允许打乱顺序”。"
log.Printf(
"[WARN] execute tool blocked chat=%s round=%d tool=%s allow_reorder=%v",
flowState.ConversationID,
flowState.RoundUsed,
toolName,
flowState.AllowReorder,
)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
toolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
)
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
return nil
}
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
@@ -1845,19 +1841,6 @@ func buildTemporarilyDisabledToolResult(toolName string) string {
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等基础微调工具。", strings.TrimSpace(toolName))
}
// shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。
//
// 说明:
// 1. 仅当工具名为 min_context_switch 且未授权打乱顺序时返回 true
// 2. 其余场景统一放行;
// 3. nil flowState 视为未命中拦截条件,避免因状态缺失导致误阻断。
func shouldBlockMinContextSwitch(flowState *newagentmodel.CommonState, toolName string) bool {
if flowState == nil {
return false
}
return !flowState.AllowReorder && strings.EqualFold(strings.TrimSpace(toolName), toolMinContextSwitch)
}
// executePendingTool 执行用户已确认的写工具。
//
// 职责边界:
@@ -1920,22 +1903,6 @@ func executePendingTool(
return nil
}
// 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。
if shouldBlockMinContextSwitch(flowState, pending.ToolName) {
blockedResult := "已拒绝执行 min_context_switch当前未授权打乱顺序。如需使用该工具请先由用户明确说明“允许打乱顺序”。"
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
pending.ToolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(args),
false,
)
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
runtimeState.PendingConfirmTool = nil
return nil
}
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
blockedResult := buildInfeasibleBlockedResult(flowState)
_ = emitter.EmitToolCallResult(
@@ -2060,6 +2027,24 @@ func normalizeSpeak(speak string) string {
return speak + "\n"
}
// buildExecuteNormalizedSpeakTail 计算“归一化后新增、但前端尚未收到”的 execute 文案尾巴。
//
// 职责边界:
// 1. 只处理“streamed 原文是 normalized 的前缀”这一保守场景,典型就是只缺末尾换行;
// 2. 不尝试回放中间格式差异,避免把整段已流式输出的正文再推一遍;
// 3. 若无法安全判断差额,则返回空串,交给现有行为继续执行。
func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
streamed = strings.ReplaceAll(streamed, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
if streamed == "" || normalized == "" {
return ""
}
if !strings.HasPrefix(normalized, streamed) {
return ""
}
return normalized[len(streamed):]
}
// truncateText 截断文本到指定长度。
//
// 用于状态推送时避免超长文本影响前端展示。
@@ -2397,15 +2382,12 @@ func resolveToolDisplayNameCN(toolName string) string {
"get_task_info": "查看任务详情",
"analyze_health": "综合体检",
"analyze_rhythm": "分析学习节奏",
"analyze_tolerance": "分析容错空间",
"web_search": "网页搜索",
"web_fetch": "网页抓取",
"move": "移动任务",
"place": "放置任务",
"swap": "交换任务",
"batch_move": "批量移动任务",
"spread_even": "均匀分散任务",
"min_context_switch": "减少上下文切换",
"unplace": "移除任务安排",
"upsert_task_class": "写入任务类",
"context_tools_add": "激活工具域",

View File

@@ -14,7 +14,6 @@ import (
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/cloudwego/eino/schema"
)
@@ -272,7 +271,7 @@ func handleQuickTaskQuery(
decision *quickTaskDecision,
flowState *newagentmodel.CommonState,
) string {
params := newagenttools.TaskQueryParams{
params := newagentmodel.TaskQueryParams{
SortBy: "deadline",
Order: "asc",
Limit: 5,
@@ -316,7 +315,7 @@ func handleQuickTaskQuery(
return sb.String()
}
// quickNoteFallbackPriority 根据截止时间推断默认优先级,与 tools/quicknote.go 保持一致
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
func quickNoteFallbackPriority(deadline *time.Time) int {
if deadline != nil {
if time.Until(*deadline) <= 48*time.Hour {

View File

@@ -90,7 +90,7 @@ func buildExecutePromptWithFormatGuard(base string) string {
输出协议硬约束:
1. 只输出当前 action 真正需要的字段;不要输出空字符串、空对象、空数组或 null 占位。
2. tool_call 只能是 {"name":"工具名","arguments":{...}};不能写 parameters也不能一次输出多个 tool_call。
3. action=ask_user / confirm 时标签后必须有自然语言正文action=continue 可为空。
3. action=ask_user / confirm 时标签后必须有自然语言正文action=continue 可为空,但只允许配合读工具或纯思考,不能携带任何写工具
4. action=done 时不要携带 tool_callaction=next_plan / done 时goal_check 必须是字符串。
5. 只有 action=abort 时才允许输出 abort 字段。
6. <SMARTFLOW_DECISION> 标签内只放 JSON不要放自然语言。
@@ -111,14 +111,27 @@ func buildExecuteStrictJSONUserPrompt() string {
执行提醒:
- JSON 中不要包含 speak 字段;给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
- 不要在 <SMARTFLOW_DECISION> 标签之前输出任何文字;哪怕只有一句“我先看下”也不行
- 日程写工具place/move/swap/batch_move/unplace一律走 action=confirm
- 任何写工具都一律走 action=confirm包括 upsert_task_class 与日程写工具place/move/swap/batch_move/unplace哪怕只是“按 validation.issues 重试一次”,也不能输出 continue + 写工具
- 若当前处于粗排后主动优化专用模式,先调 analyze_health再直接从 decision.candidates 里选一个合法候选去执行;不要自行发明新的全窗搜索步骤
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
- 不要连续两轮调用“同一读工具 + 等价 arguments”上一轮已成功返回时下一轮必须换工具、进入 confirm或明确说明阻塞
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
- web_search 仅用于通用学习资料补充不可用于考试时间、DDL、个人时段等时间字段填充
- 任何写工具在真正输出 action=confirm 前,都必须先做一次“写前检查”:确认参数已齐全、格式合法、业务前提已满足;若尚未通过检查,就先补齐/归一/生成/ask_user不要把 validation 失败当成正常探索路径
- upsert_task_class 若返回 validation.ok=false必须先按 validation.issues 补齐,再重试;禁止直接 done
- 对 upsert_task_class写前至少检查mode=auto 时日期边界是否已满足subject_type / difficulty_level / cognitive_intensity 是否齐difficulty_level 是否已映射到合法枚举items 是否非空且顺序内容已生成config 中已知约束字段是否已落到合法格式
- 若像 items 这种内容本就由当前轮模型负责生成,就应先把内容生成齐、顺序排好,再写入;不要先写一个 items 为空的 taskclass 去让 validation 提醒你补内容
- 处理 validation.issues 时先分类:若是用户关键信息确实缺失,才 action=ask_user若是 schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射等内部表示问题,应静默改参后直接重试,不要把底层表示教学抛给用户
- 像 config.excluded_slots 的半天块索引映射默认属于内部表示修正你应自己把“第1-2节 / 第11-12节”换算成合法块索引不要为此 ask_user不要长篇解释底层表示
- 当前时间锚点只用于解析用户已经明确说出的相对时间(如“今天开始”“两周内”“下周一前”),不能反过来把“现在是今天”当成用户已经同意从今天开始,更不能据此默认生成 start_date / end_date
- 像 auto 模式缺 start_date/end_date 这类问题,先检查当前对话、历史、记忆、已知工具结果里是否已经出现可用日期;若已出现就静默补齐并重试,只有在上下文里确实没有时再 ask_user
- 若当前是首次创建/修正 taskclass且上下文里并没有用户明确给出的开始日期、结束日期、日期范围、完成期限、或可直接换算出的相对时间承诺就不要擅自写 start_date / end_date此时若工具闭环确实要求这些字段必须 ask_user
- 对 taskclass 来说,以下属于必须 ask_user 的关键信息start_date、end_date、明确的日期范围、明确的开始时间承诺、明确的完成期限这些会决定任务类的真实时间边界不能由模型自行拍板
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填;优先静默推断,只有确实无法判断时再 ask_user
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这些默认都只是 taskclass 语义;不要因为信息完整就自动切进 schedule
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许 context_tools_add domain="schedule"、触发 rough_build或继续 schedule 链路
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应先生成或更新 taskclass而不是主动排进日程
- 仅 upsert_task_class 成功不代表已开始排程;若未触发 rough_build 且未调用任何日程修改工具,禁止承诺“接下来会自动排程”
- 当前轮目标若是创建/修正 taskclass就优先把 taskclass 静默闭环;除非真缺用户关键信息,否则不要把主要篇幅花在解释工具内部约束上
`)
}

View File

@@ -231,9 +231,9 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
if state != nil {
if state.AllowReorder {
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,但当前主链不再提供顺序重排工具,请优先使用 move/swap 做局部调整。")
} else {
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,仅做局部 move/swap 调整。")
}
}
@@ -269,7 +269,7 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
//
// 1. 这里只给模型最低必要的参数和返回值感知,不重复塞完整 schema JSON。
// 2. 对复杂工具额外给一条调用示例,降低“参数字段写错”的概率。
// 3. P1 阶段隐藏 min_context_switch避免模型误用已禁能力
// 3. 这里只展示当前真实可用工具,避免历史残留能力继续污染工具面
func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, state *newagentmodel.CommonState) string {
if ctx == nil {
return ""
@@ -286,10 +286,6 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext, sta
if name == "" {
continue
}
if shouldHideMinContextSwitchForP1(state, name) {
continue
}
index++
desc := strings.TrimSpace(schemaItem.Desc)
if desc == "" {
@@ -329,7 +325,6 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
"web_fetch",
"analyze_health",
"analyze_rhythm",
"analyze_tolerance",
"upsert_task_class":
return true
default:
@@ -340,7 +335,7 @@ func shouldRenderExecuteToolReturnSample(toolName string) bool {
func renderExecuteToolCallHint(toolName string) string {
switch strings.ToLower(strings.TrimSpace(toolName)) {
case "upsert_task_class":
return `{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,11],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
return `仅当用户或上下文已明确给出日期范围时,才允许写入 start_date/end_date写前先检查 difficulty_level 已归一为 low/medium/highitems 已非空且内容顺序已生成完成:{"name":"upsert_task_class","arguments":{"task_class":{"name":"线性代数复习","mode":"auto","start_date":"2026-06-01","end_date":"2026-06-20","subject_type":"quantitative","difficulty_level":"high","cognitive_intensity":"high","config":{"total_slots":8,"strategy":"steady","allow_filler_course":false,"excluded_slots":[1,6],"excluded_days_of_week":[6,7]},"items":[{"order":1,"content":"行列式定义与基础计算"},{"order":2,"content":"矩阵及其运算规则"},{"order":3,"content":"逆矩阵与矩阵的秩"}]}}}`
default:
return ""
}
@@ -375,10 +370,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return returnType, "交换完成:[35]... ↔ [36]..."
case "batch_move":
return returnType, "批量移动完成2 个任务全部成功。"
case "spread_even":
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
case "min_context_switch":
return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
case "unplace":
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
case "web_search":
@@ -389,8 +380,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
return "stringJSON字符串", `{"tool":"analyze_health","success":true,"metrics":{"rhythm":{"avg_switches_per_day":1.1,"max_switch_count":4,"heavy_adjacent_days":2,"same_type_transition_ratio":0.58,"block_balance":0,"fragmented_count":0,"compressed_run_count":0},"tightness":{"locally_movable_task_count":3,"avg_local_alternative_slots":1.7,"cross_class_swap_options":1,"forced_heavy_adjacent_days":0,"tightness_level":"tight"},"can_close":false},"decision":{"should_continue_optimize":true,"recommended_operation":"swap","primary_problem":"第4天存在高认知背靠背","candidates":[{"candidate_id":"swap_35_44","tool":"swap","arguments":{"task_a":35,"task_b":44}}]}}`
case "analyze_rhythm":
return "stringJSON字符串", `{"tool":"analyze_rhythm","success":true,"metrics":{"overview":{"avg_switches_per_day":3.4,"max_switch_day":4,"max_switch_count":5,"heavy_adjacent_days":2,"long_high_intensity_days":1,"same_type_transition_ratio":0.42}}}`
case "analyze_tolerance":
return "stringJSON字符串", `{"tool":"analyze_tolerance","success":true,"metrics":{"overall":{"fragmentation_rate":0.52,"days_without_buffer":1}}}`
case "upsert_task_class":
return "stringJSON字符串", `{"tool":"upsert_task_class","success":true,"task_class_id":123,"created":true,"validation":{"ok":true,"issues":[]},"error":"","error_code":""}`
default:
@@ -564,9 +553,8 @@ func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
func renderExecuteLatestAnalyzeSummary(ctx *newagentmodel.ConversationContext) string {
record, ok := findExecuteLatestToolRecord(ctx, map[string]struct{}{
"analyze_health": {},
"analyze_rhythm": {},
"analyze_tolerance": {},
"analyze_health": {},
"analyze_rhythm": {},
})
if !ok {
return ""
@@ -582,8 +570,6 @@ func renderExecuteLatestMutationSummary(ctx *newagentmodel.ConversationContext)
"batch_move": {},
"unplace": {},
"queue_apply_head_move": {},
"spread_even": {},
"min_context_switch": {},
})
if !ok {
return ""
@@ -790,14 +776,13 @@ func renderTaskClassUpsertRuntime(state *newagentmodel.CommonState) string {
}
}
if !state.TaskClassUpsertLastSuccess {
lines = append(lines, "- 写前最少检查项mode=auto 的 start_date/end_date、subject_type/difficulty_level/cognitive_intensity、difficulty_level 合法枚举、items 非空且内容已生成、config 约束字段合法。")
lines = append(lines, "- 先判断当前 issues 属于哪一类:若是 schema 字段名、字段位置、半天块索引、枚举值、日期格式、工具语义映射等内部表示问题,直接静默改参重试。")
lines = append(lines, "- 若 issue 指向 start_date/end_date 等字段,先检查当前对话、历史、记忆、最近工具结果里是否已出现可用值;只有确实没有时再 ask_user。")
lines = append(lines, "- 若缺的是 start_date/end_date/日期范围/开始日期承诺/完成期限,而这些值并未在上下文中出现,就必须 ask_user不能把当前日期或默认周期当成用户已同意的时间边界。")
lines = append(lines, "- 若 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,就先在本轮静默归一/补齐/生成,再 confirm 重试;不要把 validation 当试错器。")
lines = append(lines, "- 若再次调用 upsert_task_class动作必须是 confirm不能输出 continue + tool_call。")
lines = append(lines, "- 在 issues 处理完之前,不要用 done 收口。")
}
return strings.Join(lines, "\n")
}
func shouldHideMinContextSwitchForP1(state *newagentmodel.CommonState, toolName string) bool {
if strings.TrimSpace(toolName) != "min_context_switch" {
return false
}
return true
}

View File

@@ -78,7 +78,7 @@ func renderExecuteNextStepHintV2(
if roughBuildDone {
return `先激活 schedule 业务域;当前是粗排后的微调场景,通常至少需要 mutation+analyze。若要按统一条件逐个处理一批任务再加 packs=["queue"]。`
}
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。`
return `先判断当前任务属于哪个业务域,再用 context_tools_add 激活对应工具。若用户只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认先走 taskclass只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才切 schedule。`
}
if activeDomain == "schedule" &&
@@ -97,7 +97,7 @@ func renderExecuteNextStepHintV2(
if activeDomain == "taskclass" &&
state.TaskClassUpsertLastTried &&
!state.TaskClassUpsertLastSuccess {
return `根据 validation.issues 补齐缺失字段,再重试 upsert_task_class不要直接收口。`
return `判断 validation.issues 是“用户缺信息”还是“内部表示修正”;能从上下文补的先静默补齐,再用 confirm 重试 upsert_task_class不要继续解释底层约束,更不要直接收口。`
}
return ""

View File

@@ -178,9 +178,11 @@ func buildExecuteCoreMinPack() executeRulePack {
Name: executeRulePackCoreMin,
Content: strings.TrimSpace(fmt.Sprintf(`
- 当前时间锚点:%s。涉及“今天/明天/本周”等相对时间时,先按该锚点换算。
- 用户意图优先:只推进用户当前明确要求;未明确部分先 ask_user。
- 用户意图优先:只推进用户当前明确要求;未明确部分先看能否从当前对话、历史、记忆、已知工具结果里静默补齐,只有补不出来时再 ask_user。
- 域切换要克制:用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass不要主动切到 schedule。
- 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许进入 schedule 或触发粗排。
- 先事实后动作:优先读工具补齐事实,再决定下一步。
- 只要决定调用 place/move/swap/batch_move/unplace 这类写工具,就必须输出 action=confirmcontinue + 写工具无效。
- 只要决定调用任何写工具,就必须输出 action=confirmcontinue + 写工具无效。这个纪律同样适用于 upsert_task_class 的每一次重试。
- 输出格式固定:先 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,再输出用户可见正文。`,
buildExecuteNowAnchorLine())),
}
@@ -197,9 +199,9 @@ func buildExecuteSafetyHardPack() executeRulePack {
Name: executeRulePackSafetyHard,
Content: strings.TrimSpace(`
- 严禁伪造工具结果;若新结果与既有事实冲突,先重查一次再决定。
- P1 阶段禁止调用 min_context_switch。
- 工具参数必须严格使用 schema 字段名,禁止自造别名。
- JSON 只保留当前 action 必需字段;不要输出空字符串、空对象、空数组或 null 占位。
- P1 阶段禁止调用 min_context_switch。
- 连续两轮同类读查询后,必须转执行 / ask_user / 明确说明阻塞,不能无限空转。`),
}
}
@@ -210,6 +212,7 @@ func buildExecuteContextProtocolPack() executeRulePack {
Content: strings.TrimSpace(`
- msg0 动态区初始仅保留 context_tools_add / context_tools_remove。
- 需要业务工具前先 context_tools_add排程用 domain="schedule",任务类写入用 domain="taskclass"。
- 切 schedule 前先判断用户是否明确提出排程诉求;若只是描述任务类内容与排程偏好,先留在 taskclass。
- schedule 可选 packs=["mutation","analyze","detail_read","deep_analyze","queue","web"]core 固定注入,不要显式传 core。
- 只在业务方向切换时再 removedone 后的动态区清理由系统自动完成,不必手动 remove。
- 如果目标工具当前不在可用列表,先 add 对应 domain / packs再继续执行。`),
@@ -232,7 +235,7 @@ func buildExecuteModeReActPack() executeRulePack {
Name: executeRulePackModeReAct,
Content: strings.TrimSpace(`
- 当前为自由执行ReAct模式可自主决定 continue / confirm / ask_user / done / abort。
- 如果关键事实无法通过工具补齐,优先 ask_user不做猜测落库
- 如果关键事实无法通过工具补齐,也无法从当前对话、历史、记忆中补齐,才 ask_user不要把本可静默修正的内部表示问题转嫁给用户
- 自主推进时要小步快跑,优先闭合当前局部问题,不要发散成大范围开放搜索。`),
}
}
@@ -242,6 +245,8 @@ func buildExecuteSchedulePack() executeRulePack {
Name: executeRulePackDomainSchedule,
Content: strings.TrimSpace(`
- 当前业务域为 schedule只处理当前目标任务类不重排无关内容。
- 只有用户已明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才应停留或切入 schedule。
- 单纯看到总节数、难度、节次偏好、禁排时段、排除星期,不足以进入 schedule这些默认仍属于 taskclass 约束。
- existing 只作事实参考;真正可调对象优先看 suggested。
- 同任务类内部顺序必须保持,任何越过前驱/后继边界的移动都会被写工具拒绝。`),
}
@@ -281,8 +286,28 @@ func buildExecuteTaskClassPack() executeRulePack {
Name: executeRulePackDomainTaskClass,
Content: strings.TrimSpace(`
- taskclass 域只负责生成或修正任务类,不代表已经开始排程。
- 学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,默认都先落在 taskclass 语义中。
- 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——应进入或停留 taskclass而不是主动切 schedule也通常不需要 ask_user。
- 在真正调用 upsert_task_class 前,必须先做一轮写前检查;只有当参数已齐全、格式合法、业务前提已满足时,才允许输出 confirm。
- 不要把 validation 失败当成正常试错器validation 只用于兜底发现漏项,不应成为“先乱写一次看看后端报什么”的主流程。
- upsert_task_class 写前最少检查项:
1. mode=auto 时task_class 顶层 start_date/end_date 是否已经满足。
2. subject_type / difficulty_level / cognitive_intensity 是否齐全。
3. difficulty_level 是否已归一到合法枚举 low/medium/high。
4. items 是否非空,且顺序与内容是否已在当前轮生成完成。
5. config 中已知约束字段是否已是合法格式,例如 excluded_slots 半天块索引、excluded_days_of_week 取值范围、total_slots/strategy 等。
- 若像 items 这种内容本就由当前轮模型负责生成,就应先生成齐再写,不要把空 items 提交给 validation 去提醒你补课表内容。
- upsert_task_class 若返回 validation.ok=false必须先处理 validation.issues再考虑重试或 ask_user。
- 先区分 issue 类型schema 字段名、字段位置、内部索引、枚举值、日期格式、工具语义映射,属于内部表示修正,应静默改参后直接重试;真正缺少用户关键信息时,才 ask_user。
- taskclass 里的“关键信息缺失”要收窄定义:真正必须 ask_user 的,是会决定任务类真实时间边界/时间承诺的字段,而不是内部表示问题。
- 必须 ask_user 的时间参数/条件包括start_date、end_date、明确日期范围、明确开始日期承诺、明确完成期限如果这些信息在当前对话、历史、记忆里都不存在就不能由你自行拍板。
- 当前时间锚点只能用来解析用户已经说出的相对时间;若用户没说“今天开始 / 本周内 / 两周内 / 下周前”这类时间承诺,不能因为“今天是 2026-04-27”就默认 start_date=今天,也不能默认补一个 end_date。
- 禁排时段、排除星期、总节数、难度、内容拆分授权,不等于用户已经给出了日期范围;这些信息再完整,也不能单独推出 start_date/end_date。
- config.excluded_slots 使用 1~6 的半天块索引像“第1-2节”应映射到 1“第11-12节”应映射到 6。这类换算由你内部处理不要把底层表示解释成主要回复内容。
- 若 validation 指出 auto 模式缺 start_date/end_date先检查当前对话、历史、记忆里是否已有日期范围已有就静默补齐并重试只有确实没有时再 ask_user。
- subject_type / difficulty_level / cognitive_intensity 是任务类语义画像必填项;优先静默推断,只有确实无法判断时再 ask_user。
- 只要再次调用 upsert_task_class无论是首次写入还是失败后的重试都必须走 action=confirm。
- 当前轮目标若是创建/修正 taskclass应优先追求静默闭环不要把主要篇幅花在教育用户理解工具内部约束上。
- excluded_slots 取值应与系统节次定义一致excluded_days_of_week 使用 1~7 表示周一到周日。`),
}
}
@@ -301,6 +326,13 @@ func buildExecuteTaskClassRetryMicroPack() executeRulePack {
Name: executeRulePackMicroTaskRetry,
Content: strings.TrimSpace(`
- 最近一次 upsert_task_class 失败时,优先围绕 validation.issues 修补。
- 先回到“写前检查”再决定是否重试:确认 mode=auto 的日期边界、difficulty_level 合法枚举、subject_type/difficulty_level/cognitive_intensity 齐全、items 非空且已生成、config 约束字段合法。
- 先判断 issue 是“用户关键信息缺失”还是“内部表示/工具语义修正”:前者才 ask_user后者直接静默改参重试。
- 如果 issue 最终落到 start_date / end_date / 日期范围 / 开始日期承诺 / 完成期限,而这些值在当前对话、历史、记忆、最近工具结果里都没有出现,就必须 ask_user不要再拿当前时间锚点去替用户补。
- 若用户只给了禁排时段、排除星期、总节数、难度、内容拆分授权,这仍不构成日期范围;不要把这类偏好误判成已经拿到了可写入的 start_date/end_date。
- 如果 issue 像 difficulty_level 非法、items 为空、约束字段格式不合法,这都属于“写前本应整理好”的问题:应先在本轮静默归一/补齐/生成,再 confirm 重试,不要继续拿 validation 探路。
- 若 issue 所需字段已在当前对话、历史、记忆或最近工具结果里出现,优先静默补齐,不要多轮解释后再写。
- 重试 upsert_task_class 时仍然必须输出 action=confirm不要输出 continue + tool_call。
- 问题未解决前,不要用 done 假装收口;要么重试,要么 ask_user 补关键信息。`),
}
}

View File

@@ -13,15 +13,24 @@ const planSystemPromptCore = `
最高优先级规则:
1. 意图边界:只规划用户当前明确要求,禁止擅自扩展后续动作。
2. 事实边界:禁止伪造工具调用和执行结
2. 事实边界:禁止伪造工具调用、工具结果、外部事实和执行结
3. 规划视角:先判断“最小工具闭环”再写步骤;不要先写抽象语义步骤,再让 execute 自己猜该怎么落工具。
规划规则:
1. 每轮只做一次决策continue / ask_user / plan_done
2. 信息足够时优先 plan_done信息不足时才 ask_user且只问最小必要问题。
3. action=plan_done 时必须返回完整 plan_steps不是增量
4. plan_steps 使用自然语言描述目标与完成判定,不写执行结果
5. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids
6. 可在 plan_done 时附加 context_hook执行阶段注入建议规划阶段禁止调用 context_tools_add/remove。`
4. plan_steps 必须优先按“工具闭环”拆步,而不是按抽象语义拆步
5. 若一个目标可由单个工具闭环完成,优先生成单步计划;禁止把本可直接执行的工具动作,拆成“先分析、再设计、再确认、再执行”这类抽象多步
6. 每个 step 的 done_when 都应尽量贴近可观察证据,优先锚定工具回执、校验结果、查询 observation而不是“方案完整”“分析完成”“用户应该满意”这类抽象描述。
7. 只有单工具无法闭环,或当前步骤天然依赖上一步 observation / 用户补充信息时,才允许拆成多步。
8. 先判断为完成目标“首个可执行闭环”最小需要的 domain / packs再围绕这些工具写 steps最后再产出 context_hook。context_hook 不是顺手填空,而是计划的自然推导结果。
9. context_hook 只有一份,供 execute 首轮激活工具域使用;它应对齐“第一个可执行 step”的最小工具需求而不是试图一次覆盖整份计划的所有后续能力。
10. 用户若只是在描述学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认仍是 taskclass 语义,不等于已经要求排进日程。
11. 只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”时,才允许把目标规划为 schedule否则优先停留在 taskclass。
12. 若意图满足批量排程识别条件,可在 plan_done 时附加 needs_rough_build 与 task_class_ids但仅当用户明确提出排程请求时才允许这样做。
13. 可在 plan_done 时附加 context_hook执行阶段注入建议若用户尚未明确要求排程则 context_hook.domain 不得写 schedule。规划阶段禁止调用 context_tools_add/remove。
14. 例:“我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节,周末也不想学,每节课内容你自己来”——这应判定为 taskclass 设计planner 应优先理解为 taskclass 域可闭环的请求,通常单步或极少步即可,不应抽象拆成多轮。`
// BuildPlanSystemPrompt 返回规划阶段系统提示词。
func BuildPlanSystemPrompt() string {
@@ -33,10 +42,6 @@ func BuildPlanSystemPrompt() string {
}
// BuildPlanMessages 组装规划阶段的 messages。
//
// 1. 规划阶段只保留 Planner 专用规则,跳过通用人格底座,避免角色指令冲突。
// 2. msg1 展示真实对话msg2 展示规划工作区msg3 仅给最小执行指令与用户本轮输入。
// 3. 工具目录使用轻量版,仅提供“有什么工具”,不注入执行态大段参数示例。
func BuildPlanMessages(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, userInput string) []*schema.Message {
return buildUnifiedStageMessages(
ctx,
@@ -58,6 +63,9 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
sb.WriteString("请继续当前任务规划,只输出一组 SMARTFLOW_DECISION 决策。\n")
sb.WriteString("请基于最近对话与规划工作区推进,不要重复已有计划内容。\n")
sb.WriteString("请先判断最小工具闭环,再决定是否需要拆步;能单步就单步。\n")
sb.WriteString("若需要 context_hook请先根据第一个可执行 step 所需的最小 domain / packs 推导,再写入 hook。\n")
sb.WriteString("禁止把本可直接落工具的动作,抽象写成“完成设计 / 确认方案 / 整理思路”之类空步骤。\n")
sb.WriteString("输出格式与字段约束严格按 msg0 协议执行。\n")
trimmedInput := strings.TrimSpace(userInput)
@@ -72,28 +80,38 @@ func BuildPlanUserPrompt(state *newagentmodel.CommonState, userInput string) str
// BuildPlanDecisionContractText 返回规划阶段的输出协议说明。
func BuildPlanDecisionContractText() string {
return strings.TrimSpace(fmt.Sprintf(`
输出协议(唯一口径):
1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>
2. 再输出:给用户看的自然语言正文
JSON 字段:
- action只能是 %s / %s / %s
- reason给后端和日志看的简短说明
- complexity只能是 simple / moderate / complex
- plan_steps仅当 action=%s 时允许返回,且必须是完整计划
- plan_steps[].content步骤正文必填
- plan_steps[].done_when可选,建议写完成判定
- needs_rough_build仅满足粗排识别条件时为 true否则省略
- task_class_idsneeds_rough_build=true 时必填,从上下文读取
- context_hook可选仅用于给 execute 阶段提供注入建议
- context_hook.domainschedule / taskclass
- context_hook.packsstring 数组可选core 固定注入,不要填 core
- context_hook.reason可选说明为何建议该注入
注意:
- JSON 中不要包含 speak 字段
- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove`,
return strings.TrimSpace(fmt.Sprintf(strings.Join([]string{
"输出协议(唯一口径):",
"1. 先输出:<SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>",
"2. 再输出:给用户看的自然语言正文",
"",
"JSON 字段:",
"- action只能是 %s / %s / %s",
"- reason给后端和日志看的简短说明",
"- complexity只能是 simple / moderate / complex",
"- plan_steps仅当 action=%s 时允许返回,且必须是完整计划",
"- plan_steps[].content步骤正文必填",
"- plan_steps[].done_when可选;若提供,必须尽量写成 observation / 工具回执可直接证明的完成判定",
"- needs_rough_build仅满足粗排识别条件时为 true否则省略",
"- task_class_idsneeds_rough_build=true 时必填,从上下文读取",
"- context_hook可选仅用于给 execute 阶段提供注入建议",
"- context_hook.domainschedule / taskclass",
"- context_hook.packsstring 数组可选core 固定注入,不要填 core",
"- context_hook.reason可选说明为何建议该注入",
"",
"注意:",
"- JSON 中不要包含 speak 字段",
"- 不要在 planning 阶段调用任何工具(包括 context_tools_add/remove",
"- 写 plan_steps 前,先判断当前目标能否由单个工具或单个紧凑工具闭环完成;若能,优先输出单步计划",
"- 禁止把本可直接执行的工具动作,拆成抽象语义步骤,例如“先分析需求”“完成设计”“确认方案完整”",
"- 多步计划只应用于:上一步 observation 决定下一步;或确实需要先问用户补关键事实;或目标天然跨域",
"- context_hook 必须从 plan_steps 自然推导:优先对齐第一个可执行 step 的最小 domain / packs不要脱离步骤单独拍脑袋生成",
"- 若用户只给出学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权,这默认属于 taskclass 设计;不要因此写 needs_rough_build=true也不要把 context_hook.domain 设为 schedule",
"- 只有用户明确要求\"排进日程 / 给出具体时间安排 / 现在就排一版\"时,才允许输出 needs_rough_build=true 或 context_hook.domain=schedule",
"- 若首步本质上是任务类写入或修正context_hook 通常应对齐 taskclass若首步需要 schedule 查询/分析/修改,再按最小 packs 推导 schedule hook",
"- step 的 done_when 应优先锚定查询结果已返回、validation 已通过、写工具已成功回执、粗排标记已产生、分析结论已可直接支撑下一步",
"- 例:\"我要复习离散数学,基础较差,大概学 8 节课,不要早上第 1-2 节和晚上第 11-12 节学习,周末也不想学,每节课内容你自己来\"——应规划为 taskclass而不是 schedule也通常不需要 ask_user",
}, "\n"),
newagentmodel.PlanActionContinue,
newagentmodel.PlanActionAskUser,
newagentmodel.PlanActionDone,

View File

@@ -16,13 +16,14 @@ func buildPlanConversationMessage(ctx *newagentmodel.ConversationContext) string
// buildPlanWorkspace 渲染 plan 节点自己的工作区。
//
// 设计说明:
// 1. 这里保留“规划真正需要知道的东西”已有计划、当前步骤、task_class_ids、任务类约束
// 2. 不再复用通用胖状态摘要,避免把 execute / deliver 无关状态一起塞给 plan
// 3. 若当前没有正式计划,则明确告诉模型“从零开始规划”,避免继续误沿用旧上下文
// 1. 这里保留“当前已有计划/任务类约束”,也显式补充“规划视角的工具摘要”
// 2. planner 需要先理解工具边界,才能把步骤收敛到最小闭环,而不是按抽象语义乱拆
// 3. 工具摘要不展开全量 schema只提供规划真正需要的负责什么、不负责什么、常见闭环、完成证据、域切换条件
func buildPlanWorkspace(state *newagentmodel.CommonState) string {
lines := []string{"规划工作区:"}
if state == nil {
lines = append(lines, "- 当前缺少流程状态,请主要依据最近对话与本轮输入继续规划。")
lines = append(lines, buildPlanToolPlanningSummary())
return strings.Join(lines, "\n")
}
@@ -43,6 +44,7 @@ func buildPlanWorkspace(state *newagentmodel.CommonState) string {
lines = append(lines, taskClassMeta)
}
lines = append(lines, buildPlanToolPlanningSummary())
return strings.Join(lines, "\n")
}
@@ -142,6 +144,76 @@ func renderPlanTaskClassMeta(state *newagentmodel.CommonState) string {
return strings.Join(lines, "\n")
}
// buildPlanToolPlanningSummary 生成“规划视角的工具摘要”。
//
// 步骤化说明:
// 1. 先讲 domain让 planner 先判断目标应该停留在哪个业务域;
// 2. 再讲 schedule packs让 planner 知道若进入 schedule该选最小哪组能力
// 3. 最后讲 hook 推导规则:因为 context_hook 只有一份,必须和“首个可执行闭环”对齐。
func buildPlanToolPlanningSummary() string {
sections := []string{
"规划视角的工具摘要:",
buildPlanToolDomainTaskClassSummary(),
buildPlanToolDomainScheduleSummary(),
buildPlanToolPackSummary(),
buildPlanContextHookSummary(),
}
return strings.Join(sections, "\n")
}
func buildPlanToolDomainTaskClassSummary() string {
lines := []string{
"1. taskclass 域:",
"- 负责什么:创建 / 更新任务类,沉淀学习目标、总节数、难度、节次偏好、禁排时段、排除星期、内容拆分授权、任务项结构。",
"- 不负责什么:不给出具体日期/节次落位,不负责把任务真正排进日程。",
"- 常见一步闭环:任务类设计或修正通常可由 taskclass 域单步闭环,核心写入动作为 upsert_task_class。",
"- 何时停留在本域:用户仍在描述目标、偏好、约束、拆分方式,而不是要求现在排进日程。",
"- 何时切到下一个域:只有用户明确要求“排进日程 / 给出具体时间安排 / 现在就排一版”,或当前目标本身已变成排程执行。",
"- done_when 证据偏好:优先锚定 upsert_task_class 成功回执、validation.ok=true、validation.issues 已清空。",
}
return strings.Join(lines, "\n")
}
func buildPlanToolDomainScheduleSummary() string {
lines := []string{
"2. schedule 域:",
"- 负责什么:查询日程现状、粗排、具体落位、局部移动/交换、批量同规则调整、排程健康分析。",
"- 不负责什么不凭空补考试时间、DDL、个人空闲、外部时间事实这类信息拿不到时应 ask_user。",
"- 常见一步闭环:单次查询通常一个读工具即可闭环;单次移动/交换/放置通常一个写工具即可闭环;局部分析通常一个 analyze 工具即可闭环。",
"- 何时停留在本域:用户明确要求查询、安排、调整、优化当前日程。",
"- 何时先回 taskclass如果用户还在定义“学什么、学多少、怎么拆、哪些时段不要学”而不是要求立刻排程应先停留在 taskclass。",
"- done_when 证据偏好:优先锚定查询 observation、写工具成功回执、rough_build_done 标记、analyze observation 已能直接支撑下一步。",
}
return strings.Join(lines, "\n")
}
func buildPlanToolPackSummary() string {
lines := []string{
"3. schedule packs 选择参考:",
"- detail_read查看总览、查询区间、看任务详情适合“先读事实再决定”的首步。",
"- mutationplace / move / swap / batch_move / unplace适合真正落日程或调日程。",
"- analyzeanalyze_health / analyze_rhythm适合先判断是否还有优化空间、该往哪里动。",
"- queue适合“按同一规则逐个处理一批任务”的计划不必把整批任务细节都堆进 steps。",
"- web仅补通用学习资料或通识信息不用于补个人时间事实。",
"- deep_analyze适合确实需要更深一层 schedule 分析时再加,默认不要为了“看起来完整”就提前注入。",
"- 选 pack 原则:只选首个可执行 step 真的需要的最小 packs不要为了保险一次全带上。",
}
return strings.Join(lines, "\n")
}
func buildPlanContextHookSummary() string {
lines := []string{
"4. context_hook 推导规则:",
"- 先确定 steps再看第一个可执行 step 需要哪个 domain / 哪组最小 packs最后才写 hook。",
"- 若第一个可执行 step 本质上是任务类写入或修正hook 通常应为 taskclass且一般不需要 packs。",
"- 若第一个可执行 step 是 schedule 查询hook 应为 schedule并优先只带 detail_read。",
"- 若第一个可执行 step 是 schedule 分析hook 应为 schedule并优先带 analyze若分析后立刻要落写再补 mutation。",
"- 若第一个可执行 step 是批量同规则处理hook 应在 schedule 基础上按需加 queue。",
"- hook 只有一份不要求提前覆盖整份计划的所有后续能力execute 可以在后续按计划再切域或补 packs。",
}
return strings.Join(lines, "\n")
}
func planSemanticValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {

View File

@@ -1,139 +0,0 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
"time"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// QuickNoteDeps 描述随口记工具所需的外部依赖。
//
// 职责边界:
// 1. CreateTask 负责真正写库,工具层不直接依赖 DAO
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
type QuickNoteDeps struct {
// CreateTask 将解析后的任务字段写入数据库。
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (taskID int, err error)
}
// QuickNoteCreateResult 是 quick_note_create 工具的结构化返回。
type QuickNoteCreateResult struct {
TaskID int `json:"task_id"`
Title string `json:"title"`
PriorityLabel string `json:"priority_label"`
DeadlineAt string `json:"deadline_at,omitempty"`
Message string `json:"message"`
}
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
//
// 推断规则:
// 1. 有截止时间且距今 ≤48h → 1重要且紧急
// 2. 有截止时间且距今 >48h → 2重要不紧急
// 3. 无截止时间 → 3简单不重要
func quickNoteFallbackPriority(deadline *time.Time) int {
if deadline != nil {
if time.Until(*deadline) <= 48*time.Hour {
return newagentshared.QuickNotePriorityImportantUrgent
}
return newagentshared.QuickNotePriorityImportantNotUrgent
}
return newagentshared.QuickNotePrioritySimpleNotImportant
}
// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。
//
// 职责边界:
// 1. 负责参数校验、时间解析、优先级推断、调 deps 写库、组装返回;
// 2. 不负责 LLM 交互和会话管理。
// 3. state 参数忽略——随口记不需要 ScheduleState已注册到 scheduleFreeTools。
func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
// 1. 提取 _user_id由 execute 节点在调用前注入)。
userID := 0
if uid, ok := args["_user_id"].(int); ok {
userID = uid
}
if userID <= 0 {
return "工具调用失败:无法识别用户身份。"
}
// 2. 提取必填参数 title。
title := ""
if t, ok := args["title"].(string); ok {
title = strings.TrimSpace(t)
}
if title == "" {
return "工具调用失败:缺少必填参数 title任务标题。"
}
// 3. 提取可选参数 deadline_at复用旧链路时间解析能力。
var deadline *time.Time
if raw, ok := args["deadline_at"].(string); ok {
raw = strings.TrimSpace(raw)
if raw != "" {
// 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。
parsed, err := newagentshared.ParseOptionalDeadline(raw)
if err != nil {
return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s。支持格式2026-04-20 18:00、明天下午3点、下周一上午9点。", err)
}
deadline = parsed
}
}
// 4. 提取可选参数 priority_group未提供时按截止时间自动推断。
priorityGroup := 0
if pg, ok := args["priority_group"].(float64); ok {
priorityGroup = int(pg)
}
if !newagentshared.IsValidTaskPriority(priorityGroup) {
priorityGroup = quickNoteFallbackPriority(deadline)
}
// 5. 调用依赖写库。
taskID, err := deps.CreateTask(userID, title, priorityGroup, deadline)
if err != nil {
return fmt.Sprintf("工具调用失败:写入任务时出错(%s。", err)
}
if taskID <= 0 {
return "工具调用失败:写入任务后未返回有效 task_id。"
}
// 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。
priorityLabel := newagentshared.PriorityLabelCN(priorityGroup)
deadlineStr := ""
if deadline != nil {
deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
}
result := QuickNoteCreateResult{
TaskID: taskID,
Title: title,
PriorityLabel: priorityLabel,
DeadlineAt: deadlineStr,
}
// 6.1 成功事实 + banter 提示:通过工具返回值引导 ReAct LLM 在 speak 中自然加入轻松跟进。
if deadlineStr != "" {
result.Message = fmt.Sprintf("已记录:%s%s截止 %s。回复时请用轻松友好的语气加一句与任务内容相关的俏皮话不超过30字。",
title, priorityLabel, deadlineStr)
} else {
result.Message = fmt.Sprintf("已记录:%s%s。回复时请用轻松友好的语气加一句与任务内容相关的俏皮话不超过30字。",
title, priorityLabel)
}
jsonBytes, marshalErr := json.Marshal(result)
if marshalErr != nil {
// 6.2 JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
return result.Message
}
return string(jsonBytes)
}
}

View File

@@ -52,14 +52,7 @@ type ToolRegistry struct {
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
var temporaryDisabledTools = map[string]bool{
"min_context_switch": true,
"spread_even": true,
"analyze_load": true,
"analyze_subjects": true,
"analyze_context": true,
"analyze_tolerance": true,
}
var temporaryDisabledTools = map[string]bool{}
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
func IsTemporarilyDisabledTool(name string) bool {
@@ -232,8 +225,6 @@ var writeTools = map[string]bool{
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
"spread_even": true,
"min_context_switch": true,
"unplace": true,
"upsert_task_class": true,
}
@@ -244,8 +235,6 @@ var scheduleMutationTools = map[string]bool{
"swap": true,
"batch_move": true,
"queue_apply_head_move": true,
"spread_even": true,
"min_context_switch": true,
"unplace": true,
}
@@ -368,30 +357,6 @@ func registerScheduleReadTools(r *ToolRegistry) {
}
func registerScheduleAnalyzeTools(r *ToolRegistry) {
r.Register(
"analyze_load",
"分析整体负载分布(当前阶段已临时禁用,仅保留定义)。",
`{"name":"analyze_load","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"granularity":{"type":"string","enum":["day","week","time_of_day"]},"detail":{"type":"string","enum":["summary","full"]}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeLoad(state, args)
},
)
r.Register(
"analyze_subjects",
"分析学科分布与连贯性(当前阶段已临时禁用,仅保留定义)。",
`{"name":"analyze_subjects","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeSubjects(state, args)
},
)
r.Register(
"analyze_context",
"分析上下文切换与相邻关系(当前阶段已临时禁用,仅保留定义)。",
`{"name":"analyze_context","parameters":{"day_from":{"type":"int"},"day_to":{"type":"int"},"detail":{"type":"string","enum":["summary","day_detail"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeContext(state, args)
},
)
r.Register(
"analyze_rhythm",
"分析学习节奏与切换情况。",
@@ -400,14 +365,6 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
return schedule.AnalyzeRhythm(state, args)
},
)
r.Register(
"analyze_tolerance",
"分析局部容错与调整空间。",
`{"name":"analyze_tolerance","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"min_usable_size":{"type":"int"},"min_daily_buffer":{"type":"int"},"detail":{"type":"string","enum":["summary","full"]}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeTolerance(state, args)
},
)
r.Register(
"analyze_health",
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness判断当前是否还值得继续优化并给出候选。",
@@ -503,30 +460,6 @@ func registerScheduleMutationTools(r *ToolRegistry) {
return schedule.QueueSkipHead(state, args)
},
)
r.Register(
"min_context_switch",
"在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。",
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
return schedule.MinContextSwitch(state, taskIDs)
},
)
r.Register(
"spread_even",
"在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。",
`{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
return schedule.SpreadEven(state, taskIDs, args)
},
)
r.Register(
"unplace",
"将一个已落位任务移除恢复为待安排状态。task_id 必填。",

View File

@@ -165,26 +165,6 @@ type analyzeHealthMetrics struct {
CanClose bool `json:"can_close"`
}
// AnalyzeLoad 已退出主动优化主链路。
func AnalyzeLoad(state *ScheduleState, args map[string]any) string {
return encodeAnalyzeFailure("analyze_load", "deprecated", "analyze_load 已退出主动优化链路")
}
// AnalyzeSubjects 已被 analyze_rhythm 吸收。
func AnalyzeSubjects(state *ScheduleState, args map[string]any) string {
return encodeAnalyzeFailure("analyze_subjects", "deprecated", "analyze_subjects 已被 analyze_rhythm 吸收")
}
// AnalyzeContext 已被 analyze_rhythm 吸收。
func AnalyzeContext(state *ScheduleState, args map[string]any) string {
return encodeAnalyzeFailure("analyze_context", "deprecated", "analyze_context 已被 analyze_rhythm 吸收")
}
// AnalyzeTolerance 已退出主动优化主链路。
func AnalyzeTolerance(state *ScheduleState, args map[string]any) string {
return encodeAnalyzeFailure("analyze_tolerance", "deprecated", "analyze_tolerance 已退出主动优化链路")
}
// AnalyzeRhythm 输出认知节奏层面的结构化观察。
func AnalyzeRhythm(state *ScheduleState, args map[string]any) string {
if state == nil {
@@ -958,164 +938,6 @@ func buildSemanticProfileIssues(metrics analyzeSemanticProfileMetrics) []analyze
}}
}
func buildAnalyzeHealthDecision(
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,
),
}
// 1. 只有“高认知相邻”这类当前 P1 真正能靠确定性候选修复的问题,才进入候选枚举。
// 2. 若所有合法候选都只是平移/无增益/恶化,则直接回到 close避免把 LLM 逼成苦力工。
// 3. close 永远保留为兜底选项,让 LLM 可以自然收口,而不是为了完成任务感继续乱挪。
problem, ok := pickPrimaryHealthProblem(state, snapshot)
if !ok || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前没有可继续处理的候选认知问题。", snapshot, base),
}
decision.ShouldContinueOptimize = false
decision.RecommendedOperation = "close"
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
beneficial := buildHealthCandidatesForProblem(state, snapshot, problem)
if len(beneficial) == 0 {
decision.Candidates = []analyzeHealthCandidate{
buildHealthCloseCandidate("保持当前安排并收口:当前所有合法 move / swap 都只会平移、无增益或恶化问题。", snapshot, base),
}
decision.ShouldContinueOptimize = false
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.Candidates = append(decision.Candidates, beneficial...)
decision.Candidates = append(decision.Candidates,
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
)
decision.ShouldContinueOptimize = true
decision.RecommendedOperation = strings.TrimSpace(beneficial[0].Tool)
decision.ImprovementSignal = buildHealthImprovementSignal(
snapshot.Rhythm,
snapshot.Tightness,
decision.ProblemScope,
decision.RecommendedOperation,
snapshot.Profile,
snapshot.Feasibility,
)
return decision
}
func pickPrimaryRhythmProblem(
rhythm analyzeRhythmMetrics,
tightness analyzeTightnessMetrics,
) (summary string, scope *analyzeProblemScope, operation string, ok bool) {
type rhythmCandidate struct {
score int
summary string
scope *analyzeProblemScope
preferSwap bool
}
candidates := make([]rhythmCandidate, 0, len(rhythm.Days)*2)
for _, day := range rhythm.Days {
if day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) {
score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20)
candidates = append(candidates, rhythmCandidate{
score: score,
summary: fmt.Sprintf("第 %d 天存在高认知强度任务相邻,学起来会发紧", day.DayIndex),
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
preferSwap: true,
})
}
if day.SwitchCount >= 5 && day.Fragmentation >= 0.75 {
score := 220 + day.SwitchCount*10 + int(day.Fragmentation*100)
candidates = append(candidates, rhythmCandidate{
score: score,
summary: fmt.Sprintf("第 %d 天切换次数偏多,学习节奏明显发碎", day.DayIndex),
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
preferSwap: false,
})
}
if day.MaxBlock >= 5 {
score := 140 + day.MaxBlock*10
candidates = append(candidates, rhythmCandidate{
score: score,
summary: fmt.Sprintf("第 %d 天连续同科目学习块过长,节奏略显单一", day.DayIndex),
scope: &analyzeProblemScope{DayRange: []int{day.DayIndex}},
preferSwap: false,
})
}
}
if len(candidates) == 0 {
return "", nil, "close", false
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].score != candidates[j].score {
return candidates[i].score > candidates[j].score
}
leftDay := 1 << 30
rightDay := 1 << 30
if candidates[i].scope != nil && len(candidates[i].scope.DayRange) > 0 {
leftDay = candidates[i].scope.DayRange[0]
}
if candidates[j].scope != nil && len(candidates[j].scope.DayRange) > 0 {
rightDay = candidates[j].scope.DayRange[0]
}
return leftDay < rightDay
})
best := candidates[0]
operation = chooseHealthOperation(tightness, best.preferSwap)
return best.summary, best.scope, operation, true
}
func chooseHealthOperation(tightness analyzeTightnessMetrics, preferSwap bool) string {
switch {
case tightness.TightnessLevel == "locked":
return "close"
case preferSwap && tightness.CrossClassSwapOptions > 0:
return "swap"
case tightness.LocallyMovableTaskCount > 0:
return "move"
case tightness.CrossClassSwapOptions > 0:
return "swap"
default:
return "close"
}
}
func shouldTreatHeavyAdjacencyAsAcceptable(rhythm analyzeRhythmMetrics, day analyzeContextDay) bool {
// 1. 若整体切换本来就少、同类型切换占比很高,说明当前节奏更像“同类硬课顺着学”,
// 这类情况不该因为“高认知相邻”四个字就被反复优化。

View File

@@ -1,707 +0,0 @@
package schedule
import (
"encoding/json"
"fmt"
"sort"
"strings"
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
)
var spreadEvenAllowedArgs = []string{
"task_ids",
"task_id",
"limit",
"allow_embed",
"day",
"day_start",
"day_end",
"day_scope",
"day_of_week",
"week",
"week_filter",
"week_from",
"week_to",
"slot_type",
"slot_types",
"exclude_sections",
"after_section",
"before_section",
}
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
type minContextSnapshot struct {
StateID int
Name string
ContextTag string
Slot TaskSlot
}
// refineTaskCandidate 是复合规划器使用的任务输入。
type refineTaskCandidate struct {
TaskID int
Week int
DayOfWeek int
SectionFrom int
SectionTo int
Name string
ContextTag string
OriginRank int
}
// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。
//
// 说明:
// 1. 当前阶段使用等值映射logicID=stateID保证行为不变
// 2. 保留独立适配层,后续若切到真实 task_item_id只需改这里
// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
type compositeIDMapper struct {
stateToLogic map[int]int
logicToState map[int]int
}
// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。
func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
mapper := &compositeIDMapper{
stateToLogic: make(map[int]int, len(stateIDs)),
logicToState: make(map[int]int, len(stateIDs)),
}
for _, stateID := range stateIDs {
if stateID <= 0 {
return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
}
if _, exists := mapper.stateToLogic[stateID]; exists {
return nil, fmt.Errorf("state_id=%d 重复", stateID)
}
// 当前迁移阶段采用等值映射,先把“映射机制”跑通。
logicID := stateID
mapper.stateToLogic[stateID] = logicID
mapper.logicToState[logicID] = stateID
}
return mapper, nil
}
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
//
// 职责边界:
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
// 3. 采用原子提交:任一校验失败则整体不生效。
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
if state == nil {
return "减少上下文切换失败:日程状态为空。"
}
// 1. 收集任务并做前置校验,确保规划输入可用。
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
if err != nil {
return err.Error()
}
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
// 3. 映射回工具态坐标并在提交前做完整校验。
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
for taskID, after := range afterByID {
before := beforeByID[taskID]
if err := validateDay(state, after.Slot.Day); err != nil {
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
}
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
}
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
return fmt.Sprintf(
"减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
before.StateID,
before.Name,
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
conflict.StateID,
conflict.Name,
)
}
}
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()
for taskID, after := range afterByID {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("减少上下文切换失败任务ID %d 在提交阶段不存在。", taskID)
}
task.Slots = []TaskSlot{after.Slot}
}
state.Tasks = clone.Tasks
beforeOrdered := sortMinContextSnapshots(beforeByID)
afterOrdered := sortMinContextSnapshots(afterByID)
beforeSwitches := countMinContextSwitches(beforeOrdered)
afterSwitches := countMinContextSwitches(afterOrdered)
changedLines := make([]string, 0, len(beforeOrdered))
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
for _, before := range beforeOrdered {
after := afterByID[before.StateID]
if sameTaskSlot(before.Slot, after.Slot) {
continue
}
changedLines = append(changedLines, fmt.Sprintf(
" [%d]%s%s -> %s",
before.StateID,
before.Name,
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
))
affectedDays[before.Slot.Day] = true
affectedDays[after.Slot.Day] = true
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n",
len(beforeByID), beforeSwitches, afterSwitches,
))
if len(changedLines) == 0 {
sb.WriteString("当前任务顺序已是较优结果,无需调整。")
return sb.String()
}
sb.WriteString("本次调整:\n")
for _, line := range changedLines {
sb.WriteString(line + "\n")
}
for _, day := range sortedKeys(affectedDays) {
sb.WriteString(formatDayOccupancy(state, day) + "\n")
}
return strings.TrimSpace(sb.String())
}
// SpreadEven 在给定任务集合内执行“均匀化铺开”。
//
// 职责边界:
// 1. 仅处理 suggested 且已落位任务;
// 2. 先按筛选条件收集候选坑位,再调用确定性规划器;
// 3. 通过统一校验后原子提交,失败不落地。
func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string {
if state == nil {
return "均匀化调整失败:日程状态为空。"
}
// 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。
if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
if err != nil {
return err.Error()
}
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
spanNeed := make(map[int]int, len(logicTasks))
for _, task := range logicTasks {
spanNeed[task.SectionTo-task.SectionFrom+1]++
}
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
ExistingDayLoad: dayLoadBaseline,
})
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
// 4. 回填 + 校验 + 原子提交。
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
for taskID, after := range afterByID {
before := beforeByID[taskID]
if err := validateDay(state, after.Slot.Day); err != nil {
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
}
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
}
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
return fmt.Sprintf(
"均匀化调整失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
before.StateID,
before.Name,
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
conflict.StateID,
conflict.Name,
)
}
}
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 {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("均匀化调整失败任务ID %d 在提交阶段不存在。", taskID)
}
task.Slots = []TaskSlot{after.Slot}
}
state.Tasks = clone.Tasks
beforeOrdered := sortMinContextSnapshots(beforeByID)
changedLines := make([]string, 0, len(beforeOrdered))
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
for _, before := range beforeOrdered {
after := afterByID[before.StateID]
if sameTaskSlot(before.Slot, after.Slot) {
continue
}
changedLines = append(changedLines, fmt.Sprintf(
" [%d]%s%s -> %s",
before.StateID,
before.Name,
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
))
affectedDays[before.Slot.Day] = true
affectedDays[after.Slot.Day] = true
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"均匀化调整完成:共处理 %d 个任务,候选坑位 %d 个。\n",
len(beforeByID), len(candidateSlots),
))
if len(changedLines) == 0 {
sb.WriteString("规划结果与当前落位一致,无需调整。")
return sb.String()
}
sb.WriteString("本次调整:\n")
for _, line := range changedLines {
sb.WriteString(line + "\n")
}
for _, day := range sortedKeys(affectedDays) {
sb.WriteString(formatDayOccupancy(state, day) + "\n")
}
return strings.TrimSpace(sb.String())
}
func ParseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
return ParseCompositeTaskIDs(args)
}
func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
return ParseCompositeTaskIDs(args)
}
func ParseCompositeTaskIDs(args map[string]any) ([]int, error) {
if ids, ok := ArgsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
return ids, nil
}
if id, ok := ArgsInt(args, "task_id"); ok {
return []int{id}, nil
}
return nil, fmt.Errorf("缺少必填参数 task_ids兼容单值 task_id")
}
// collectCompositePlannerTasks 统一收集复合工具输入任务,并做“可移动 suggested”校验。
func collectCompositePlannerTasks(
state *ScheduleState,
taskIDs []int,
toolLabel string,
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
normalizedIDs := uniquePositiveInts(taskIDs)
if len(normalizedIDs) < 2 {
return nil, nil, nil, nil, fmt.Errorf("%s失败task_ids 至少需要 2 个有效任务 ID", toolLabel)
}
idMapper, err := buildCompositeIDMapper(normalizedIDs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败ID 映射构建失败:%s", toolLabel, err.Error())
}
plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs))
beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs))
excludeIDs := make([]int, 0, len(normalizedIDs))
for rank, taskID := range normalizedIDs {
task := state.TaskByStateID(taskID)
if task == nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败任务ID %d 不存在", toolLabel, taskID)
}
if !IsSuggestedTask(*task) {
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
}
if err := checkLocked(*task); err != nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败%s", toolLabel, err.Error())
}
if len(task.Slots) != 1 {
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
}
slot := task.Slots[0]
if err := validateDay(state, slot.Day); err != nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
}
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
}
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
return nil, nil, nil, nil, fmt.Errorf("%s失败[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
}
contextTag := normalizeMinContextTag(*task)
beforeByID[task.StateID] = minContextSnapshot{
StateID: task.StateID,
Name: task.Name,
ContextTag: contextTag,
Slot: slot,
}
excludeIDs = append(excludeIDs, task.StateID)
plannerTasks = append(plannerTasks, refineTaskCandidate{
TaskID: task.StateID,
Week: week,
DayOfWeek: dayOfWeek,
SectionFrom: slot.SlotStart,
SectionTo: slot.SlotEnd,
Name: strings.TrimSpace(task.Name),
ContextTag: contextTag,
OriginRank: rank + 1,
})
}
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
}
// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
if len(tasks) == 0 {
return nil, fmt.Errorf("任务列表为空")
}
if idMapper == nil {
return nil, fmt.Errorf("ID 映射为空")
}
result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
for _, task := range tasks {
logicID, ok := idMapper.stateToLogic[task.TaskID]
if !ok {
return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
}
result = append(result, compositelogic.RefineTaskCandidate{
TaskItemID: logicID,
Week: task.Week,
DayOfWeek: task.DayOfWeek,
SectionFrom: task.SectionFrom,
SectionTo: task.SectionTo,
Name: task.Name,
ContextTag: task.ContextTag,
OriginRank: task.OriginRank,
})
}
return result, nil
}
func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
for _, task := range tasks {
slots = append(slots, compositelogic.RefineSlotCandidate{
Week: task.Week,
DayOfWeek: task.DayOfWeek,
SectionFrom: task.SectionFrom,
SectionTo: task.SectionTo,
})
}
return slots
}
func buildAfterSnapshotsFromPlannedMoves(
state *ScheduleState,
beforeByID map[int]minContextSnapshot,
plannedMoves []compositelogic.RefineMovePlanItem,
idMapper *compositeIDMapper,
) (map[int]minContextSnapshot, error) {
if len(plannedMoves) == 0 {
return nil, fmt.Errorf("规划结果为空")
}
if idMapper == nil {
return nil, fmt.Errorf("ID 映射为空")
}
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
for _, move := range plannedMoves {
stateID, ok := idMapper.logicToState[move.TaskItemID]
if !ok {
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
}
if _, exists := moveByID[stateID]; exists {
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
}
moveByID[stateID] = move
}
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
for taskID, before := range beforeByID {
move, ok := moveByID[taskID]
if !ok {
return nil, fmt.Errorf("规划结果不完整:缺少任务 id=%d", taskID)
}
day, ok := state.WeekDayToDay(move.ToWeek, move.ToDay)
if !ok {
return nil, fmt.Errorf("任务 id=%d 目标 week/day 无法映射到 day_indexW%dD%d", taskID, move.ToWeek, move.ToDay)
}
afterByID[taskID] = minContextSnapshot{
StateID: before.StateID,
Name: before.Name,
ContextTag: before.ContextTag,
Slot: TaskSlot{
Day: day,
SlotStart: move.ToSectionFrom,
SlotEnd: move.ToSectionTo,
},
}
}
return afterByID, nil
}
func collectSpreadEvenCandidateSlotsBySpan(
state *ScheduleState,
args map[string]any,
spanNeed map[int]int,
) ([]compositelogic.RefineSlotCandidate, error) {
if len(spanNeed) == 0 {
return nil, fmt.Errorf("未识别到任务跨度需求")
}
spans := make([]int, 0, len(spanNeed))
for span := range spanNeed {
spans = append(spans, span)
}
sort.Ints(spans)
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
seen := make(map[string]struct{}, 64)
for _, span := range spans {
required := spanNeed[span]
queryArgs := buildSpreadEvenSlotQueryArgs(args, span, required)
raw := QueryAvailableSlots(state, queryArgs)
var failed struct {
Error string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &failed)
if strings.TrimSpace(failed.Error) != "" {
return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, strings.TrimSpace(failed.Error))
}
var payload queryAvailableSlotsResult
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("解析跨度=%d 的候选坑位结果失败:%v", span, err)
}
if len(payload.Slots) < required {
return nil, fmt.Errorf("跨度=%d 可用坑位不足required=%d, got=%d", span, required, len(payload.Slots))
}
for _, slot := range payload.Slots {
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SlotStart, slot.SlotEnd)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
SectionFrom: slot.SlotStart,
SectionTo: slot.SlotEnd,
})
}
}
return allSlots, nil
}
func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) map[string]any {
query := make(map[string]any, 16)
query["span"] = span
limit := required * 6
if limit < required {
limit = required
}
if customLimit, ok := readIntAny(args, "limit"); ok && customLimit > limit {
limit = customLimit
}
query["limit"] = limit
query["allow_embed"] = readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
for _, key := range []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
if value, ok := args[key]; ok {
query[key] = value
}
}
if week, ok := readIntAny(args, "to_week", "target_week", "new_week"); ok {
query["week"] = week
}
if day, ok := readIntAny(args, "to_day", "target_day", "target_day_of_week", "new_day"); ok {
query["day_of_week"] = []int{day}
}
if values := uniquePositiveInts(readIntSliceAny(args, "week_filter", "weeks")); len(values) > 0 {
query["week_filter"] = values
}
if values := uniqueInts(readIntSliceAny(args, "day_of_week", "days", "day_filter")); len(values) > 0 {
query["day_of_week"] = values
}
if values := uniqueInts(readIntSliceAny(args, "exclude_sections", "exclude_section")); len(values) > 0 {
query["exclude_sections"] = values
}
return query
}
func buildSpreadEvenDayLoadBaseline(
state *ScheduleState,
excludeTaskIDs []int,
slots []compositelogic.RefineSlotCandidate,
) map[string]int {
if len(slots) == 0 {
return nil
}
targetDays := make(map[string]struct{}, len(slots))
for _, slot := range slots {
targetDays[composeDayKey(slot.Week, slot.DayOfWeek)] = struct{}{}
}
if len(targetDays) == 0 {
return nil
}
excludeSet := make(map[int]struct{}, len(excludeTaskIDs))
for _, id := range excludeTaskIDs {
excludeSet[id] = struct{}{}
}
load := make(map[string]int, len(targetDays))
for _, task := range state.Tasks {
if !IsSuggestedTask(task) {
continue
}
if _, excluded := excludeSet[task.StateID]; excluded {
continue
}
for _, slot := range task.Slots {
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
if !ok {
continue
}
key := composeDayKey(week, dayOfWeek)
if _, inTarget := targetDays[key]; !inTarget {
continue
}
load[key]++
}
}
return load
}
func composeDayKey(week, day int) string {
return fmt.Sprintf("%d-%d", week, day)
}
func uniquePositiveInts(values []int) []int {
seen := make(map[int]struct{}, len(values))
result := make([]int, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func normalizeMinContextTag(task ScheduleTask) string {
if tag := strings.TrimSpace(task.Category); tag != "" {
return tag
}
if tag := strings.TrimSpace(task.Name); tag != "" {
return tag
}
return "General"
}
func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot {
items := make([]minContextSnapshot, 0, len(snapshotByID))
for _, item := range snapshotByID {
items = append(items, item)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Slot.Day != items[j].Slot.Day {
return items[i].Slot.Day < items[j].Slot.Day
}
if items[i].Slot.SlotStart != items[j].Slot.SlotStart {
return items[i].Slot.SlotStart < items[j].Slot.SlotStart
}
if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd {
return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd
}
return items[i].StateID < items[j].StateID
})
return items
}
func countMinContextSwitches(ordered []minContextSnapshot) int {
if len(ordered) < 2 {
return 0
}
switches := 0
prevTag := strings.TrimSpace(ordered[0].ContextTag)
for i := 1; i < len(ordered); i++ {
currentTag := strings.TrimSpace(ordered[i].ContextTag)
if currentTag != prevTag {
switches++
}
prevTag = currentTag
}
return switches
}
func sameTaskSlot(a, b TaskSlot) bool {
return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd
}

View File

@@ -20,7 +20,7 @@ func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targ
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
//
// 职责边界:
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免批量局部调整时出现伪冲突;
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {

View File

@@ -1,320 +0,0 @@
package newagenttools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// ==================== 常量 ====================
const (
// defaultTaskQueryLimit 是任务查询默认返回条数。
defaultTaskQueryLimit = 5
// maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。
maxTaskQueryLimit = 20
)
// ==================== 优先级中文映射 ====================
// taskQueryPriorityLabelCN 将象限编号转为中文标签。
//
// 职责边界:
// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。
// 2. 不依赖旧链路 agentmodel.PriorityLabelCN保持新工具自包含。
func taskQueryPriorityLabelCN(priority int) string {
switch priority {
case 1:
return "重要且紧急"
case 2:
return "重要不紧急"
case 3:
return "简单不重要"
case 4:
return "复杂不重要"
default:
return "未知"
}
}
// ==================== 类型定义 ====================
// TaskQueryDeps 描述任务查询工具所需的外部依赖。
//
// 职责边界:
// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
type TaskQueryDeps struct {
// QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error)
}
// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。
//
// 输入输出语义:
// 1. 所有筛选条件均为可选Quadrant 为 nil 表示不限象限。
// 2. 时间边界为 nil 表示不限时间范围。
type TaskQueryParams struct {
Quadrant *int
SortBy string // deadline | priority | id
Order string // asc | desc
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。
//
// 职责边界:
// 1. 只承载展示所需字段,避免暴露底层数据库结构。
// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。
type TaskQueryResult struct {
ID int `json:"id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
IsCompleted bool `json:"is_completed"`
DeadlineAt string `json:"deadline_at,omitempty"`
}
// ==================== 时间解析 ====================
// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。
var taskQueryTimeLayouts = []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
// parseTaskQueryBoundaryTime 解析截止时间上下界。
//
// 职责边界:
// 1. isUpper=true 时,纯日期补到当天 23:59:59。
// 2. isUpper=false 时,纯日期补到当天 00:00:00。
// 3. 不支持的格式直接返回错误,由调用方决定是否回退。
func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, nil
}
loc := time.Local
for _, layout := range taskQueryTimeLayouts {
var (
parsed time.Time
err error
)
if layout == time.RFC3339 {
parsed, err = time.Parse(layout, text)
if err == nil {
parsed = parsed.In(loc)
}
} else {
parsed, err = time.ParseInLocation(layout, text, loc)
}
if err != nil {
continue
}
// 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。
// 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束"
// 所以补 23:59:59作为下界则补 00:00:00。
if layout == "2006-01-02" {
if isUpper {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc)
} else {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc)
}
}
return &parsed, nil
}
return nil, fmt.Errorf("时间格式不支持: %s", text)
}
// formatTaskQueryTime 将内部时间格式化为给模型展示的分钟级文本。
func formatTaskQueryTime(value *time.Time) string {
if value == nil {
return ""
}
return value.In(time.Local).Format("2006-01-02 15:04")
}
// ==================== 工具 Handler ====================
// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。
//
// 职责边界:
// 1. 负责参数校验、时间解析、调 deps 查库、组装返回;
// 2. 不负责 LLM 交互和会话管理。
// 3. state 参数忽略——任务查询不需要 ScheduleState已注册到 scheduleFreeTools。
func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
// 1. 提取 _user_id由 execute 节点在调用前注入)。
userID := 0
if uid, ok := args["_user_id"].(int); ok {
userID = uid
}
if userID <= 0 {
return "工具调用失败:无法识别用户身份。"
}
// 2. 提取并校验查询参数。
params, err := extractTaskQueryParams(args)
if err != nil {
return fmt.Sprintf("工具调用失败:%s", err)
}
// 3. 调用依赖查库。
results, err := deps.QueryTasks(context.Background(), userID, params)
if err != nil {
return fmt.Sprintf("工具调用失败:查询任务时出错(%s。", err)
}
// 4. 为每条结果填充优先级中文标签。
for i := range results {
results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup)
}
// 5. 返回结构化 JSON。
if len(results) == 0 {
return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}`
}
output := struct {
Total int `json:"total"`
Items []TaskQueryResult `json:"items"`
Message string `json:"message"`
}{
Total: len(results),
Items: results,
Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)),
}
jsonBytes, marshalErr := json.Marshal(output)
if marshalErr != nil {
// JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
return fmt.Sprintf("找到 %d 条匹配任务。", len(results))
}
return string(jsonBytes)
}
}
// extractTaskQueryParams 从 args 提取并校验任务查询参数。
//
// 步骤说明:
// 1. 先准备默认值,保证空参数也能执行一次合理查询。
// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。
// 3. 若上下界冲突,则直接返回错误。
func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) {
params := TaskQueryParams{
SortBy: "deadline",
Order: "asc",
Limit: defaultTaskQueryLimit,
IncludeCompleted: false,
}
// 2.1 象限1~4超出范围拒绝。
if v, ok := args["quadrant"]; ok {
switch val := v.(type) {
case float64:
q := int(val)
if q < 1 || q > 4 {
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q)
}
params.Quadrant = &q
case int:
if val < 1 || val > 4 {
return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val)
}
params.Quadrant = &val
}
}
// 2.2 排序字段:仅支持 deadline/priority/id。
if v, ok := args["sort_by"].(string); ok {
sortBy := strings.ToLower(strings.TrimSpace(v))
if sortBy != "" {
switch sortBy {
case "deadline", "priority", "id":
params.SortBy = sortBy
default:
return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy)
}
}
}
// 2.3 排序方向:仅支持 asc/desc。
if v, ok := args["order"].(string); ok {
order := strings.ToLower(strings.TrimSpace(v))
if order != "" {
switch order {
case "asc", "desc":
params.Order = order
default:
return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order)
}
}
}
// 2.4 条数:默认 5上限 20。
if v, ok := args["limit"]; ok {
switch val := v.(type) {
case float64:
params.Limit = int(val)
case int:
params.Limit = val
}
}
if params.Limit <= 0 {
params.Limit = defaultTaskQueryLimit
}
if params.Limit > maxTaskQueryLimit {
params.Limit = maxTaskQueryLimit
}
// 2.5 是否包含已完成任务。
if v, ok := args["include_completed"]; ok {
switch val := v.(type) {
case bool:
params.IncludeCompleted = val
}
}
// 2.6 关键词。
if v, ok := args["keyword"].(string); ok {
params.Keyword = strings.TrimSpace(v)
}
// 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。
beforeRaw, _ := args["deadline_before"].(string)
before, err := parseTaskQueryBoundaryTime(beforeRaw, true)
if err != nil {
return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err)
}
params.DeadlineBefore = before
afterRaw, _ := args["deadline_after"].(string)
after, err := parseTaskQueryBoundaryTime(afterRaw, false)
if err != nil {
return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err)
}
params.DeadlineAfter = after
// 2.8 时间区间合法性校验:下界不能晚于上界。
if params.DeadlineBefore != nil && params.DeadlineAfter != nil &&
params.DeadlineAfter.After(*params.DeadlineBefore) {
return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before")
}
return params, nil
}

View File

@@ -54,16 +54,13 @@ var toolProfileByName = map[string]toolProfile{
"queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"spread_even": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"min_context_switch": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},

View File

@@ -253,7 +253,7 @@ func (s *AgentService) runNewAgentGraph(
// 11.6. graph 完成后条件触发记忆抽取。
// 说明:
// 1. 只有本轮未使用 quick_note_create 时才触发记忆抽取;
// 1. 只有本轮未走快捷随口记任务路径时才触发记忆抽取;
// 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。
if finalState != nil {
cs := finalState.EnsureRuntimeState().EnsureCommonState()