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

@@ -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},