Version: 0.9.10.dev.260409
后端: 1. newAgent 运行态重置双保险落地,并补齐写工具后的实时排程预览刷新 - 更新 model/common_state.go:新增 ResetForNextRun,统一清理 round/plan/rough_build/allow_reorder/terminal 等执行期临时状态 - 更新 node/chat.go + service/agentsvc/agent_newagent.go:在“无 pending 且上一轮已 done”时分别于 chat 主入口与 loadOrCreateRuntimeState 冷加载处执行兜底重置,覆盖正常新一轮对话与断线恢复场景 - 更新 model/graph_run_state.go + node/agent_nodes.go + node/execute.go:写工具执行后立即刷新 Redis 排程预览,Deliver 继续保留最终覆盖写,保证前端能及时看到最新操作结果 2. 顺序守卫从“直接中止”改为“优先自动复原 suggested 相对顺序” - 更新 node/order_guard.go:检测到 suggested 顺序被打乱后,不再直接 abort;改为复用当前坑位按 baseline 自动回填,并在复原失败时仅记录诊断日志后继续交付 - 更新 tools/state.go:ScheduleState 新增 RuntimeQueue 运行态快照字段,支持队列化处理与断线恢复 3. 多任务微调工具链升级:新增筛选/队列工具并替换首空位查询口径 - 新建 tools/read_filter_tools.go + tools/runtime_queue.go + tools/queue_tools.go:新增 query_available_slots / query_target_tasks / queue_pop_head / queue_apply_head_move / queue_skip_head / queue_status,支持“先筛选目标,再逐项处理”的稳定微调链路 - 更新 tools/registry.go + tools/write_tools.go + tools/read_helpers.go:移除 find_first_free 注册口径;batch_move 限制为最多 2 条,超过时引导改走队列逐项处理;queue_apply_head_move 纳入写工具集合 4. 复合规划工具扩充,并改为在 newAgent/tools 本地实现以规避循环导入 - 更新 tools/compound_tools.go + tools/registry.go:spread_even 正式接入,并与 min_context_switch 一起作为复合写工具保留在 newAgent/tools 内部实现,不再依赖外层 logic 5. prompt 与工具文档同步升级,明确当前用户诉求锚点与队列化执行约束 - 更新 prompt/execute.go + prompt/execute_context.go + prompt/plan.go:执行提示默认引导 query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head;补齐 batch_move 上限、spread_even 使用边界、顺序策略与工具 JSON 返回示例 - 更新 prompt/execute_context.go:将“初始用户目标”改为“当前用户诉求”,并保留首轮目标来源;旧 observation 折叠文案改为“当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠” - 更新 tools/SCHEDULE_TOOLS.md:同步补齐 query_* / queue_* / spread_even / min_context_switch 的说明、限制与返回示例 6. 同步更新调试日志文件 前端:无 仓库:无
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -184,6 +184,37 @@ func (s *CommonState) RejectPlan() {
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ResetForNextRun 在“上一轮已经收口,且本轮准备开始新请求”时重置执行期临时状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责清理会污染新一轮执行的临时字段(轮次、修正计数、计划游标、粗排开关、顺序基线、终止结果);
|
||||
// 2. 不负责清理会话身份与跨轮共享数据(ConversationID/UserID/TaskClassIDs/TaskClasses/历史上下文/ScheduleState);
|
||||
// 3. 该方法是幂等操作:重复调用不会引入额外副作用,便于在“加载兜底 + chat 入口”双保险场景下复用。
|
||||
func (s *CommonState) ResetForNextRun() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 先把阶段回收为 planning,确保新一轮从可路由的干净入口开始。
|
||||
// 2. 这样即使后续还有兜底重置判断,也不会因为仍处于 done 而重复触发。
|
||||
s.Phase = PhasePlanning
|
||||
|
||||
// 3. 清理执行轮次与连续修正计数,避免上一轮预算/异常计数污染本轮。
|
||||
s.RoundUsed = 0
|
||||
s.ConsecutiveCorrections = 0
|
||||
|
||||
// 4. 清理计划执行游标与粗排相关临时标记,确保新请求不会误沿用旧计划。
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
|
||||
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
|
||||
s.AllowReorder = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// AdvanceStep 推进到下一个计划步骤,并返回是否仍有剩余步骤。
|
||||
func (s *CommonState) AdvanceStep() bool {
|
||||
s.CurrentStep++
|
||||
|
||||
@@ -45,7 +45,9 @@ type RoughBuildPlacement struct {
|
||||
type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]RoughBuildPlacement, error)
|
||||
|
||||
// WriteSchedulePreviewFunc 是排程预览写入的依赖注入签名。
|
||||
// 由 service 层封装 cacheDAO 后注入,deliver 节点在任务完成时调用,保证只有真正完成的结果才写入缓存。
|
||||
// 由 service 层封装 cacheDAO 后注入,execute/deliver 节点可按需调用:
|
||||
// 1. execute 写工具后可实时刷新,保障前端及时看到最新调整;
|
||||
// 2. deliver 结束时再做最终覆盖写,保障收口状态一致。
|
||||
type WriteSchedulePreviewFunc func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error
|
||||
|
||||
// AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。
|
||||
|
||||
@@ -218,6 +218,7 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
ToolRegistry: st.Deps.ToolRegistry,
|
||||
ScheduleState: scheduleState,
|
||||
SchedulePersistor: st.Deps.SchedulePersistor,
|
||||
WriteSchedulePreview: st.Deps.WriteSchedulePreview,
|
||||
OriginalScheduleState: st.OriginalScheduleState,
|
||||
AlwaysExecute: st.Request.AlwaysExecute,
|
||||
},
|
||||
|
||||
@@ -67,6 +67,17 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
|
||||
// 2. 无 pending → 路由决策(一次快速 LLM 调用,不开 thinking)。
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if !runtimeState.HasPendingInteraction() && flowState.Phase == newagentmodel.PhaseDone {
|
||||
terminalBefore := flowState.TerminalStatus()
|
||||
roundBefore := flowState.RoundUsed
|
||||
flowState.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
flowState.ConversationID,
|
||||
roundBefore,
|
||||
terminalBefore,
|
||||
)
|
||||
}
|
||||
messages := newagentprompt.BuildChatRoutingMessages(conversationContext, input.UserInput, flowState)
|
||||
|
||||
decision, rawResult, err := newagentllm.GenerateJSON[newagentmodel.ChatRoutingDecision](
|
||||
|
||||
@@ -51,6 +51,7 @@ type ExecuteNodeInput struct {
|
||||
ToolRegistry *newagenttools.ToolRegistry
|
||||
ScheduleState *newagenttools.ScheduleState
|
||||
SchedulePersistor newagentmodel.SchedulePersistor
|
||||
WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc
|
||||
OriginalScheduleState *newagenttools.ScheduleState
|
||||
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行
|
||||
}
|
||||
@@ -100,13 +101,31 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
|
||||
// 1.5. 确认执行分支:如果用户已确认写操作,直接执行工具。
|
||||
if runtimeState.PendingConfirmTool != nil {
|
||||
return executePendingTool(ctx, runtimeState, conversationContext, input.ToolRegistry, input.ScheduleState, input.SchedulePersistor, input.OriginalScheduleState, emitter)
|
||||
return executePendingTool(
|
||||
ctx,
|
||||
runtimeState,
|
||||
conversationContext,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.SchedulePersistor,
|
||||
input.OriginalScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
emitter,
|
||||
)
|
||||
}
|
||||
|
||||
// 1.6. 顺序守卫基线初始化:
|
||||
// 1) 仅在未授权打乱顺序时记录 suggested 顺序基线;
|
||||
// 2) 只在基线为空时初始化,避免执行循环中反复覆盖;
|
||||
// 3) 后续由 order_guard 节点基于该基线做相对顺序校验。
|
||||
//
|
||||
// 同时在“本轮 execute 首轮”重置一次临时队列,避免上一轮残留队列污染新请求。
|
||||
// 判定依据:
|
||||
// 1. RoundUsed==0 说明当前还未消耗执行预算;
|
||||
// 2. 此时清理不会影响断线恢复中的中间进度(恢复场景通常 RoundUsed>0)。
|
||||
if input.ScheduleState != nil && flowState.RoundUsed == 0 {
|
||||
newagenttools.ResetTaskProcessingQueue(input.ScheduleState)
|
||||
}
|
||||
if !flowState.AllowReorder && len(flowState.SuggestedOrderBaseline) == 0 {
|
||||
flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState)
|
||||
}
|
||||
@@ -329,7 +348,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
// 继续当前步骤的 ReAct 循环。
|
||||
// 若有工具调用意图,则执行工具并记录证据。
|
||||
if decision.ToolCall != nil {
|
||||
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
// 无工具调用且 speak 为空(speak 非空时已在步骤 6 写入历史)。
|
||||
// 若 history 本轮完全没有更新,下一轮 LLM 会收到完全相同的上下文,容易死循环。
|
||||
@@ -351,7 +379,16 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
case newagentmodel.ExecuteActionConfirm:
|
||||
// AlwaysExecute=true:跳过确认闸门,直接执行内存写工具,不走 confirm 节点。
|
||||
if input.AlwaysExecute && decision.ToolCall != nil {
|
||||
return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState)
|
||||
return executeToolCall(
|
||||
ctx,
|
||||
flowState,
|
||||
conversationContext,
|
||||
decision.ToolCall,
|
||||
emitter,
|
||||
input.ToolRegistry,
|
||||
input.ScheduleState,
|
||||
input.WriteSchedulePreview,
|
||||
)
|
||||
}
|
||||
// AlwaysExecute=false(默认):暂存工具意图,设 Phase → 下游 confirm 节点接管。
|
||||
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
||||
@@ -552,6 +589,7 @@ func executeToolCall(
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
registry *newagenttools.ToolRegistry,
|
||||
scheduleState *newagenttools.ScheduleState,
|
||||
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||
) error {
|
||||
if toolCall == nil {
|
||||
return nil
|
||||
@@ -700,6 +738,14 @@ func executeToolCall(
|
||||
ToolName: toolName,
|
||||
})
|
||||
|
||||
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 仅写工具触发实时预览刷新,读工具不触发,避免无意义放大写流量;
|
||||
// 2. 这里采用“失败不阻断主流程”策略:预览写失败只记日志,不影响当前执行链路;
|
||||
// 3. Deliver 节点仍保留最终覆盖写,保证 order_guard/收口后的最终态一致。
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, toolName, writePreview)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -732,6 +778,7 @@ func executePendingTool(
|
||||
scheduleState *newagenttools.ScheduleState,
|
||||
persistor newagentmodel.SchedulePersistor,
|
||||
originalState *newagenttools.ScheduleState,
|
||||
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
) error {
|
||||
pending := runtimeState.PendingConfirmTool
|
||||
@@ -804,12 +851,53 @@ func executePendingTool(
|
||||
ToolName: pending.ToolName,
|
||||
})
|
||||
|
||||
// 5. 写工具实时预览:confirm accept 后真实执行写工具时,立即刷新一次预览缓存。
|
||||
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
||||
|
||||
// 6. 清空临时邮箱,避免重复执行。
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryWritePreviewAfterWriteTool 在写工具执行后尝试刷新一次排程预览缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责“写工具后实时可见”的旁路写入,不负责最终收口;
|
||||
// 2. 只在 write tool 命中时执行,读工具直接跳过;
|
||||
// 3. 失败只记日志,不影响主流程,避免因为缓存抖动打断执行。
|
||||
func tryWritePreviewAfterWriteTool(
|
||||
ctx context.Context,
|
||||
flowState *newagentmodel.CommonState,
|
||||
scheduleState *newagenttools.ScheduleState,
|
||||
registry *newagenttools.ToolRegistry,
|
||||
toolName string,
|
||||
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||
) {
|
||||
if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil {
|
||||
return
|
||||
}
|
||||
if !registry.IsWriteTool(toolName) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := writePreview(ctx, scheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||
log.Printf(
|
||||
"[WARN] execute realtime preview write failed chat=%s tool=%s err=%v",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] execute realtime preview write success chat=%s tool=%s",
|
||||
flowState.ConversationID,
|
||||
toolName,
|
||||
)
|
||||
}
|
||||
|
||||
// listItemRe 匹配被粘连在一起的列表序号(如 ")2. " "水课3. "),用于自动补换行。
|
||||
// 规则:非换行字符后紧跟 2-9 的序号("2. " "3、" 等),说明 LLM 漏写了换行。
|
||||
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||
|
||||
@@ -3,6 +3,7 @@ package newagentnode
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -20,6 +21,13 @@ type suggestedOrderItem struct {
|
||||
Day int
|
||||
SlotStart int
|
||||
SlotEnd int
|
||||
Slots []newagenttools.TaskSlot
|
||||
}
|
||||
|
||||
type orderRestoreResult struct {
|
||||
Restored bool
|
||||
Changed int
|
||||
Detail string
|
||||
}
|
||||
|
||||
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
|
||||
@@ -27,7 +35,7 @@ type suggestedOrderItem struct {
|
||||
// 职责边界:
|
||||
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
|
||||
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
|
||||
// 3. 校验失败只写入统一终止结果(Abort),由 Deliver 节点统一收口文案。
|
||||
// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止。
|
||||
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("order_guard node: state is nil")
|
||||
@@ -65,7 +73,7 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 基线存在时做逆序检测;一旦发现逆序,立即终止本轮自动微调。
|
||||
// 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止。
|
||||
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
|
||||
if !violated {
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
@@ -78,19 +86,36 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
return nil
|
||||
}
|
||||
|
||||
userMessage := "检测到当前方案打乱了原有建议任务顺序,本轮先停止自动微调。若你确认可以打乱顺序,请明确说明“允许打乱顺序”。"
|
||||
flowState.Abort(
|
||||
orderGuardStageName,
|
||||
"relative_order_violation",
|
||||
userMessage,
|
||||
fmt.Sprintf("baseline=%v current=%v detail=%s", flowState.SuggestedOrderBaseline, currentOrder, detail),
|
||||
)
|
||||
// 4.1 违序后进入自动复原:
|
||||
// 1) 复用“当前坑位集合”,按 baseline 相对顺序回填任务;
|
||||
// 2) 成功则继续 completed 路径,保证预览可写入;
|
||||
// 3) 若复原条件不满足,保守放行并输出诊断,避免再次把整轮流程打成 aborted。
|
||||
restore := restoreSuggestedOrderByBaseline(scheduleState, flowState.SuggestedOrderBaseline)
|
||||
if restore.Restored {
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_restored",
|
||||
fmt.Sprintf("检测到建议任务顺序被打乱,已自动复原(调整 %d 个任务)。", restore.Changed),
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_failed",
|
||||
userMessage,
|
||||
true,
|
||||
"order_guard_restore_skipped",
|
||||
"检测到顺序异常,但本次未执行自动复原,已继续交付当前结果。详情见日志。",
|
||||
false,
|
||||
)
|
||||
log.Printf(
|
||||
"[WARN] order_guard restore skipped chat=%s baseline=%v current=%v detail=%s restore_detail=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.SuggestedOrderBaseline,
|
||||
currentOrder,
|
||||
detail,
|
||||
restore.Detail,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -102,6 +127,21 @@ func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
// 2. 多 slot 任务取“最早 slot”作为排序锚点,保证排序键稳定;
|
||||
// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。
|
||||
func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
|
||||
items := buildSuggestedOrderItems(state)
|
||||
order := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
order = append(order, item.StateID)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// buildSuggestedOrderItems 生成 suggested 任务的排序明细。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 统一封装顺序守卫和自动复原都需要的排序素材,避免两处逻辑口径漂移;
|
||||
// 2. 排序键保持与历史实现一致:day -> slot_start -> slot_end -> state_id;
|
||||
// 3. 每项附带完整 slots 快照,供“坑位复用式复原”直接使用。
|
||||
func buildSuggestedOrderItems(state *newagenttools.ScheduleState) []suggestedOrderItem {
|
||||
if state == nil || len(state.Tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -118,6 +158,7 @@ func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
|
||||
Day: day,
|
||||
SlotStart: slotStart,
|
||||
SlotEnd: slotEnd,
|
||||
Slots: cloneTaskSlots(task.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,11 +175,7 @@ func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
|
||||
return items[i].StateID < items[j].StateID
|
||||
})
|
||||
|
||||
order := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
order = append(order, item.StateID)
|
||||
}
|
||||
return order
|
||||
return items
|
||||
}
|
||||
|
||||
func earliestTaskSlot(slots []newagenttools.TaskSlot) (day int, slotStart int, slotEnd int) {
|
||||
@@ -205,3 +242,221 @@ func detectRelativeOrderViolation(baseline []int, current []int) (bool, string)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// restoreSuggestedOrderByBaseline 在“默认不允许打乱顺序”场景下自动复原 suggested 相对顺序。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先提取 baseline 与 current 的交集任务,确保只修复本轮可比对对象;
|
||||
// 2. 复用 current 的“坑位序列”(时段集合),按 baseline 顺序重新回填任务;
|
||||
// 3. 回填前校验时长兼容,避免把长任务塞进短坑位;
|
||||
// 4. 回填后再次校验顺序;若失败则回滚,保证状态不会半成功。
|
||||
func restoreSuggestedOrderByBaseline(state *newagenttools.ScheduleState, baseline []int) orderRestoreResult {
|
||||
if state == nil {
|
||||
return orderRestoreResult{Restored: false, Detail: "schedule_state=nil"}
|
||||
}
|
||||
if len(baseline) == 0 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
items := buildSuggestedOrderItems(state)
|
||||
if len(items) < 2 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
itemByID := make(map[int]suggestedOrderItem, len(items))
|
||||
currentInScope := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemByID[item.StateID] = item
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, ok := itemByID[item.StateID]; ok {
|
||||
currentInScope = append(currentInScope, item.StateID)
|
||||
}
|
||||
}
|
||||
|
||||
baselineInScope := make([]int, 0, len(baseline))
|
||||
for _, id := range baseline {
|
||||
if _, ok := itemByID[id]; ok {
|
||||
baselineInScope = append(baselineInScope, id)
|
||||
}
|
||||
}
|
||||
if len(baselineInScope) < 2 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
// currentInScope 只保留 baseline 交集,保证两边长度一致且语义可比。
|
||||
baselineSet := make(map[int]struct{}, len(baselineInScope))
|
||||
for _, id := range baselineInScope {
|
||||
baselineSet[id] = struct{}{}
|
||||
}
|
||||
filteredCurrent := make([]int, 0, len(currentInScope))
|
||||
for _, id := range currentInScope {
|
||||
if _, ok := baselineSet[id]; ok {
|
||||
filteredCurrent = append(filteredCurrent, id)
|
||||
}
|
||||
}
|
||||
if sameIDOrder(filteredCurrent, baselineInScope) {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
if len(filteredCurrent) != len(baselineInScope) {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("size_mismatch baseline=%d current=%d", len(baselineInScope), len(filteredCurrent)),
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 先构建“当前坑位序列”。
|
||||
slotPool := make([][]newagenttools.TaskSlot, 0, len(filteredCurrent))
|
||||
for _, currentID := range filteredCurrent {
|
||||
item, ok := itemByID[currentID]
|
||||
if !ok {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("current_id_missing id=%d", currentID),
|
||||
}
|
||||
}
|
||||
slotPool = append(slotPool, cloneTaskSlots(item.Slots))
|
||||
}
|
||||
|
||||
// 2. 回填前做兼容性校验:默认要求“目标任务时长 == 坑位时长”。
|
||||
for i, targetID := range baselineInScope {
|
||||
targetTask := state.TaskByStateID(targetID)
|
||||
if targetTask == nil {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("target_task_missing id=%d", targetID),
|
||||
}
|
||||
}
|
||||
if !isSlotsCompatibleWithTask(*targetTask, slotPool[i]) {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf(
|
||||
"slot_incompatible target=%d expected_duration=%d slot_duration=%d expected_segments=%d slot_segments=%d",
|
||||
targetID,
|
||||
expectedTaskDuration(*targetTask),
|
||||
totalSlotDuration(slotPool[i]),
|
||||
len(targetTask.Slots),
|
||||
len(slotPool[i]),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 执行回填,并在失败时支持回滚。
|
||||
beforeSlots := make(map[int][]newagenttools.TaskSlot, len(baselineInScope))
|
||||
changed := 0
|
||||
for i, targetID := range baselineInScope {
|
||||
task := state.TaskByStateID(targetID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
beforeSlots[targetID] = cloneTaskSlots(task.Slots)
|
||||
targetSlots := cloneTaskSlots(slotPool[i])
|
||||
if !equalTaskSlots(task.Slots, targetSlots) {
|
||||
task.Slots = targetSlots
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
afterOrder := buildSuggestedOrderSnapshot(state)
|
||||
afterFiltered := make([]int, 0, len(afterOrder))
|
||||
for _, id := range afterOrder {
|
||||
if _, ok := baselineSet[id]; ok {
|
||||
afterFiltered = append(afterFiltered, id)
|
||||
}
|
||||
}
|
||||
if !sameIDOrder(afterFiltered, baselineInScope) {
|
||||
// 回滚,避免保留半成功状态。
|
||||
for _, targetID := range baselineInScope {
|
||||
task := state.TaskByStateID(targetID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
task.Slots = cloneTaskSlots(beforeSlots[targetID])
|
||||
}
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf(
|
||||
"restore_verify_failed expected=%v actual=%v",
|
||||
baselineInScope, afterFiltered,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return orderRestoreResult{
|
||||
Restored: true,
|
||||
Changed: changed,
|
||||
}
|
||||
}
|
||||
|
||||
func sameIDOrder(left, right []int) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneTaskSlots(slots []newagenttools.TaskSlot) []newagenttools.TaskSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := make([]newagenttools.TaskSlot, len(slots))
|
||||
copy(copied, slots)
|
||||
return copied
|
||||
}
|
||||
|
||||
func equalTaskSlots(left, right []newagenttools.TaskSlot) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i].Day != right[i].Day {
|
||||
return false
|
||||
}
|
||||
if left[i].SlotStart != right[i].SlotStart {
|
||||
return false
|
||||
}
|
||||
if left[i].SlotEnd != right[i].SlotEnd {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func expectedTaskDuration(task newagenttools.ScheduleTask) int {
|
||||
if task.Duration > 0 {
|
||||
return task.Duration
|
||||
}
|
||||
if len(task.Slots) > 0 {
|
||||
return totalSlotDuration(task.Slots)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func totalSlotDuration(slots []newagenttools.TaskSlot) int {
|
||||
total := 0
|
||||
for _, slot := range slots {
|
||||
total += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func isSlotsCompatibleWithTask(task newagenttools.ScheduleTask, slots []newagenttools.TaskSlot) bool {
|
||||
if len(slots) == 0 {
|
||||
return false
|
||||
}
|
||||
expectedDuration := expectedTaskDuration(task)
|
||||
if expectedDuration > 0 && expectedDuration != totalSlotDuration(slots) {
|
||||
return false
|
||||
}
|
||||
// 兼容策略:当前任务已有多段落位时,要求目标坑位段数一致,避免跨段语义被破坏。
|
||||
if len(task.Slots) > 0 && len(task.Slots) != len(slots) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const executeSystemPromptWithPlan = `
|
||||
3. 需要写操作时输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
4. 若用户给出了“二次微调方向”(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。
|
||||
5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。
|
||||
6. 多任务微调时默认走队列链路:query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要跳到其他 plan 步骤,不要越级执行。
|
||||
@@ -30,6 +31,8 @@ const executeSystemPromptWithPlan = `
|
||||
9. list_tasks.category 只接受任务类名称,不接受 task_class_ids(如 "1,2,3")。
|
||||
10. 不要忽略用户最新补充的微调方向;若与旧目标冲突,以最新用户要求为准。
|
||||
11. 若当前顺序策略是“默认保持顺序”,禁止调用 min_context_switch。
|
||||
12. 不要把超过 2 条任务打包到 batch_move;大批量调整请改走队列逐项处理。
|
||||
13. 不要在未获取队首(queue_pop_head)时直接调用 queue_apply_head_move。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
@@ -51,10 +54,11 @@ const executeSystemPromptReAct = `
|
||||
|
||||
你可以做什么:
|
||||
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/find_first_free/get_task_info)。
|
||||
4. 你可以在需要改动时提出 confirm(move/swap/unplace/batch_move)。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/list_tasks/query_target_tasks/query_available_slots/get_task_info)。
|
||||
4. 你可以在需要改动时提出 confirm(move/swap/unplace/batch_move/spread_even)。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要假设任务还没排进去,然后改成逐个手动 place。
|
||||
@@ -64,9 +68,11 @@ const executeSystemPromptReAct = `
|
||||
5. 若工具结果与已知事实明显冲突(如无写操作却从“有任务”变成“0任务”),先自我纠错并重查一次,不要直接 ask_user。
|
||||
6. 不要连续两轮调用“同一读工具 + 等价 arguments”;若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
7. list_tasks.category 只接受任务类名称,不接受 task_class_ids(如 "1,2,3")。
|
||||
8. 若已明确“本轮先收口”,不要继续调用 list_tasks/find_first_free/move 做无目标微调。
|
||||
8. 若已明确“本轮先收口”,不要继续调用 list_tasks/query_available_slots/move 做无目标微调。
|
||||
9. 若用户明确了微调方向,不要只做“局部看起来更空”的随机调整;每次改动都要能对应到该方向。
|
||||
10. 若顺序策略为“保持顺序”,禁止调用 min_context_switch。
|
||||
11. 不要在同一轮构造大规模 batch_move;batch_move 最多 2 条,超过请走队列逐项处理。
|
||||
12. 未调用 queue_pop_head 获取 current 前,不要调用 queue_apply_head_move。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
@@ -367,6 +373,10 @@ func buildExecuteStrictJSONUserPrompt() string {
|
||||
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化
|
||||
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
|
||||
- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch
|
||||
- spread_even 用于“范围内均匀化”,必须先用 query_target_tasks 明确目标任务集合
|
||||
- 多任务调整默认先调用 query_target_tasks(enqueue=true),再用 queue_pop_head 逐项处理
|
||||
- queue_apply_head_move 只能用于 current 任务;若当前任务无法落位,调用 queue_skip_head 后继续
|
||||
- batch_move 一次最多 2 条;超过 2 条必须改走队列逐项处理
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
|
||||
// executeTrimmedObservationText 是重复工具压缩后的 observation 占位文案。
|
||||
// 当同工具在窗口内出现多次时,只保留最新一条真实结果,其余旧结果统一替换为该文案。
|
||||
executeTrimmedObservationText = "当前工具调用结果过于久远,已经被删除。"
|
||||
executeTrimmedObservationText = "当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠"
|
||||
)
|
||||
|
||||
type executeToolSchemaDoc struct {
|
||||
@@ -159,20 +159,23 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
"- 当前模式:"+modeText,
|
||||
)
|
||||
|
||||
goal := extractExecuteInitialGoal(ctx)
|
||||
if goal == "" {
|
||||
goal = "暂无可用目标描述,请按当前上下文稳步推进。"
|
||||
initialGoal, currentGoal := extractExecuteGoalAnchors(ctx)
|
||||
if currentGoal == "" {
|
||||
currentGoal = "暂无可用目标描述,请按当前上下文稳步推进。"
|
||||
}
|
||||
|
||||
lines = append(lines, "执行锚点:")
|
||||
lines = append(lines, "- 初始用户目标:"+goal)
|
||||
lines = append(lines, "- 当前用户诉求:"+currentGoal)
|
||||
if initialGoal != "" && initialGoal != currentGoal {
|
||||
lines = append(lines, "- 首轮目标来源:"+initialGoal)
|
||||
}
|
||||
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
|
||||
lines = append(lines, "- 目标任务类:"+taskClassText)
|
||||
}
|
||||
lines = append(lines, "- 啥时候结束Loop:你可以根据工具调用记录自行判断。")
|
||||
lines = append(lines, "- 非目标:不重新粗排、不修改无关任务类。")
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不做 move/batch_move。")
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不做 move/batch_move/spread_even。")
|
||||
}
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
@@ -230,7 +233,7 @@ func renderExecuteToolCatalogCompact(ctx *newagentmodel.ConversationContext) str
|
||||
// renderExecuteToolReturnHint 返回工具的“返回类型 + 最小示例”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 所有工具当前都返回 string(自然语言),这里主要补“内容形态示例”,减少模型盲猜;
|
||||
// 1. 所有工具当前都返回 string,但部分是“JSON 字符串”,这里补齐内容形态示例减少模型盲猜;
|
||||
// 2. 示例只保留最小片段,避免工具说明过长挤占上下文窗口。
|
||||
func renderExecuteToolReturnHint(toolName string) (returnType string, sample string) {
|
||||
returnType = "string(自然语言文本)"
|
||||
@@ -241,8 +244,18 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return returnType, "已预排任务共24个: [35]第一章随机事件与概率 — 已预排至 第3天第5-6节..."
|
||||
case "get_task_info":
|
||||
return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
case "find_first_free":
|
||||
return returnType, "首个可用位置:第5天第1-2节(可直接放置)| 当日负载:总占6/12..."
|
||||
case "query_available_slots":
|
||||
return "string(JSON字符串)", `{"tool":"query_available_slots","count":12,"strict_count":8,"embedded_count":4,"slots":[{"day":5,"week":12,"day_of_week":3,"slot_start":1,"slot_end":2,"slot_type":"empty"}]}`
|
||||
case "query_target_tasks":
|
||||
return "string(JSON字符串)", `{"tool":"query_target_tasks","count":6,"status":"suggested","enqueue":true,"enqueued":6,"queue":{"pending_count":6},"items":[{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}]}`
|
||||
case "queue_pop_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}`
|
||||
case "queue_status":
|
||||
return "string(JSON字符串)", `{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}`
|
||||
case "queue_apply_head_move":
|
||||
return "string(JSON字符串)", `{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}`
|
||||
case "queue_skip_head":
|
||||
return "string(JSON字符串)", `{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}`
|
||||
case "query_range":
|
||||
return returnType, "第5天第3-6节:第3节空、第4节空..."
|
||||
case "place":
|
||||
@@ -252,7 +265,9 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
case "swap":
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2个任务全部成功。"
|
||||
return returnType, "批量移动完成,2个任务全部成功。(单次最多2条)"
|
||||
case "spread_even":
|
||||
return returnType, "均匀化调整完成:共处理 6 个任务,候选坑位 24 个。"
|
||||
case "min_context_switch":
|
||||
return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
|
||||
case "unplace":
|
||||
@@ -496,13 +511,13 @@ func buildEarlyExecuteReactSummary(records []executeLoopRecord, windowLimit int)
|
||||
return fmt.Sprintf("已折叠 %d 条旧记录,涉及:%s。", len(early), strings.Join(parts, "、"))
|
||||
}
|
||||
|
||||
func extractExecuteInitialGoal(ctx *newagentmodel.ConversationContext) string {
|
||||
func extractExecuteGoalAnchors(ctx *newagentmodel.ConversationContext) (initial string, current string) {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
history := ctx.HistorySnapshot()
|
||||
firstUser, _ := pickExecuteUserInputs(history)
|
||||
return firstUser
|
||||
firstUser, lastUser := pickExecuteUserInputs(history)
|
||||
return firstUser, lastUser
|
||||
}
|
||||
|
||||
func hasExecuteRoughBuildDone(ctx *newagentmodel.ConversationContext) bool {
|
||||
|
||||
@@ -27,7 +27,7 @@ const planSystemPrompt = `
|
||||
条件2:用户意图明确是"批量安排/帮我排课/把任务类排进日程"等批量调度需求。
|
||||
满足时:后端会在用户确认计划后自动运行粗排算法(硬性约束已由算法保证,无需 LLM 校验)。
|
||||
你的 plan_steps 应聚焦于"用读写工具优化方案",建议两步:
|
||||
第1步:用 get_overview / find_first_free 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
|
||||
第1步:用 get_overview / query_target_tasks / query_available_slots 等读工具审视粗排结果,找出可优化的点(时段分布不均、空位未利用等);
|
||||
第2步:用 move / batch_move 等写工具微调后,将最终方案展示给用户确认。
|
||||
禁止安排任何"校验/验证约束"步骤——硬性约束由算法兜底,LLM 不需要操心。
|
||||
|
||||
|
||||
@@ -251,32 +251,54 @@ DB 记录:
|
||||
|
||||
---
|
||||
|
||||
### 4.3 find_first_free
|
||||
### 4.3 query_available_slots
|
||||
|
||||
按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息。
|
||||
查询候选坑位池(结构化返回):默认先返回“纯空位”,不足时再补“可嵌入位”。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| duration | int | 是 | 需要的连续时段数 |
|
||||
| day | int | 否 | 限定某天;与 `day_start/day_end` 互斥 |
|
||||
| day_start | int | 否 | 搜索起始天(闭区间) |
|
||||
| day_end | int | 否 | 搜索结束天(闭区间) |
|
||||
| span / duration | int | 否 | 目标连续时段长度,默认 2 |
|
||||
| limit | int | 否 | 返回候选上限,默认 12 |
|
||||
| allow_embed | bool | 否 | 是否允许补可嵌入位,默认 true |
|
||||
| day / day_start / day_end | int | 否 | 天级范围过滤(`day` 与区间互斥) |
|
||||
| day_scope | string | 否 | `all` / `workday` / `weekend` |
|
||||
| day_of_week | []int | 否 | 星期过滤(1-7) |
|
||||
| week / week_filter / week_from / week_to | int / []int | 否 | 周级过滤 |
|
||||
| slot_type / slot_types | string / []string | 否 | `pure/empty/strict` 会强制只返回纯空位 |
|
||||
| exclude_sections | []int | 否 | 排除节次(1-12) |
|
||||
| after_section / before_section | int | 否 | 只返回区间之后/之前的候选 |
|
||||
| section_from + section_to | int | 否 | 精确节次区间查询(需同时提供) |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```
|
||||
首个可用位置:第5天第1-2节(可直接放置)。
|
||||
匹配条件:需要2个连续时段。
|
||||
当日负载:总占6/12(课程占2/12,任务占4/12)。
|
||||
当日任务明细(全量,已过滤课程):
|
||||
- [35]第一章随机事件与概率 | 状态:suggested | 类别:概率论 | 时段:第3-4节
|
||||
- [36]第二章随机变量 | 状态:suggested | 类别:概率论 | 时段:第7-8节
|
||||
当日连续空闲区:
|
||||
- 第1-2节(2时段连续空闲)
|
||||
- 第5-6节(2时段连续空闲)
|
||||
- 第9-12节(4时段连续空闲)
|
||||
```json
|
||||
{
|
||||
"tool": "query_available_slots",
|
||||
"count": 12,
|
||||
"strict_count": 8,
|
||||
"embedded_count": 4,
|
||||
"fallback_used": true,
|
||||
"day_scope": "all",
|
||||
"day_of_week": [],
|
||||
"week_filter": [12],
|
||||
"week_from": 12,
|
||||
"week_to": 12,
|
||||
"span": 2,
|
||||
"allow_embed": true,
|
||||
"exclude_sections": [],
|
||||
"slots": [
|
||||
{
|
||||
"day": 5,
|
||||
"week": 12,
|
||||
"day_of_week": 3,
|
||||
"slot_start": 1,
|
||||
"slot_end": 2,
|
||||
"slot_type": "empty"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -487,7 +509,7 @@ DB 记录:
|
||||
|
||||
### 5.4 batch_move
|
||||
|
||||
批量原子移动多个任务(仅 suggested),要么全部成功,要么全部回滚。
|
||||
批量原子移动多个任务(仅 suggested,**单次最多 2 条**),要么全部成功,要么全部回滚。
|
||||
|
||||
**入参:**
|
||||
|
||||
@@ -498,13 +520,17 @@ DB 记录:
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
批量移动完成,3个任务全部成功:
|
||||
批量移动完成,2个任务全部成功:
|
||||
[2]英语 → 第3天第1-2节
|
||||
[6]线代 → 第5天第3-4节
|
||||
[8]程序设计 → 第9天第5-6节
|
||||
第3天当前占用:[2]英语(1-2节),占用2/12。
|
||||
第5天当前占用:[6]线代(3-4节),占用2/12。
|
||||
第9天当前占用:[8]程序设计(5-6节),占用2/12。
|
||||
```
|
||||
|
||||
**失败返回(超出上限):**
|
||||
|
||||
```
|
||||
批量移动失败:当前最多支持 2 条移动请求。请改用队列化逐项处理(queue_pop_head + queue_apply_head_move)。
|
||||
```
|
||||
|
||||
**失败返回:**
|
||||
@@ -578,6 +604,114 @@ DB 记录:
|
||||
|
||||
---
|
||||
|
||||
### 5.7 queue_pop_head
|
||||
|
||||
弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。
|
||||
|
||||
**入参:**
|
||||
|
||||
无
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_pop_head","has_head":true,"pending_count":5,"current":{"task_id":35,"name":"示例任务","status":"suggested","slots":[{"day":3,"week":12,"day_of_week":1,"slot_start":5,"slot_end":6}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.8 queue_apply_head_move
|
||||
|
||||
将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| new_day | int | 是 | 目标 day |
|
||||
| new_slot_start | int | 是 | 目标起始节次 |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_apply_head_move","success":true,"task_id":35,"pending_count":4,"completed_count":2,"result":"已将 [35]... 从第3天第5-6节移至第5天第3-4节。"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.9 queue_skip_head
|
||||
|
||||
跳过当前队首任务(不改日程),标记为 skipped 并继续后续队列。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| reason | string | 否 | 跳过原因 |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_skip_head","success":true,"skipped_task_id":35,"pending_count":4,"skipped_count":1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.10 queue_status
|
||||
|
||||
查看当前待处理队列状态(pending/current/completed/skipped)。
|
||||
|
||||
**入参:**
|
||||
|
||||
无
|
||||
|
||||
**返回示例:**
|
||||
|
||||
```json
|
||||
{"tool":"queue_status","pending_count":5,"completed_count":1,"skipped_count":0,"current_task_id":35,"current_attempt":1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.11 spread_even
|
||||
|
||||
在给定任务集合内执行“均匀化铺开”:
|
||||
先按筛选条件收集候选坑位,再用确定性规划器生成移动方案并原子提交。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_ids | array[int] | 是 | 参与均匀化的任务 ID 列表(至少 2 个) |
|
||||
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
|
||||
| day/day_start/day_end | int | 否 | 天级范围过滤 |
|
||||
| day_scope | string | 否 | `all` / `workday` / `weekend` |
|
||||
| day_of_week | array[int] | 否 | 星期过滤(1~7) |
|
||||
| week/week_filter/week_from/week_to | int/array[int] | 否 | 周级过滤 |
|
||||
| limit | int | 否 | 每个跨度的候选坑位上限(内部会按任务数自动放大) |
|
||||
| allow_embed | bool | 否 | 是否允许补充可嵌入位,默认 true |
|
||||
| exclude_sections | array[int] | 否 | 排除节次 |
|
||||
| after_section/before_section | int | 否 | 节次边界过滤 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
均匀化调整完成:共处理 6 个任务,候选坑位 24 个。
|
||||
本次调整:
|
||||
[35]第一章复习:第3天(星期3)第5-6节 -> 第5天(星期5)第1-2节
|
||||
[41]第二章练习:第4天(星期4)第5-6节 -> 第6天(星期6)第1-2节
|
||||
第5天当前占用:...
|
||||
第6天当前占用:...
|
||||
```
|
||||
|
||||
**失败返回(候选不足):**
|
||||
|
||||
```
|
||||
均匀化调整失败:跨度=2 可用坑位不足:required=4, got=2。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 公共规则
|
||||
|
||||
### 冲突检测
|
||||
@@ -591,8 +725,8 @@ DB 记录:
|
||||
|
||||
### 状态约束
|
||||
- pending 任务只能 place,不能 move / swap / unplace
|
||||
- suggested 任务可以 move / swap / unplace / min_context_switch
|
||||
- existing 任务不能 move / batch_move / min_context_switch(仅作已安排事实层)
|
||||
- suggested 任务可以 move / swap / unplace / spread_even / min_context_switch
|
||||
- existing 任务不能 move / batch_move / spread_even / min_context_switch(仅作已安排事实层)
|
||||
- 状态不符时返回明确错误信息
|
||||
|
||||
### 返回格式
|
||||
@@ -609,7 +743,7 @@ DB 记录:
|
||||
### 嵌入任务规则
|
||||
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
|
||||
- 嵌入任务占位时不触发冲突检测(与宿主共存)
|
||||
- `find_first_free` 返回首个命中位,并附当日详细负载
|
||||
- `query_available_slots` 返回候选坑位池(先纯空位,必要时补可嵌入位)
|
||||
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
|
||||
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
272
backend/newAgent/tools/queue_tools.go
Normal file
272
backend/newAgent/tools/queue_tools.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type queueTaskSlot struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
type queueTaskItem struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
Slots []queueTaskSlot `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
type queuePopHeadResult struct {
|
||||
Tool string `json:"tool"`
|
||||
HasHead bool `json:"has_head"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Current *queueTaskItem `json:"current,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type queueApplyHeadMoveResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
TaskID int `json:"task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
type queueSkipHeadResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
SkippedTaskID int `json:"skipped_task_id,omitempty"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type queueStatusResult struct {
|
||||
Tool string `json:"tool"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
NextTaskIDs []int `json:"next_task_ids,omitempty"`
|
||||
Current *queueTaskItem `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
// QueuePopHead 从队列弹出队首任务(若已有 current 则复用),并返回当前处理对象。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先保证队列容器存在,避免空指针;
|
||||
// 2. 若 current 已存在,直接复用,确保 apply/skip 前不会切换处理对象;
|
||||
// 3. 若 current 为空则从 pending 弹出队首;
|
||||
// 4. 若没有可处理任务,返回 has_head=false,由 LLM 收口或重筛选。
|
||||
func QueuePopHead(state *ScheduleState, _ map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_pop_head","has_head":false,"error":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
taskID := popOrGetCurrentTaskID(state)
|
||||
|
||||
result := queuePopHeadResult{
|
||||
Tool: "queue_pop_head",
|
||||
HasHead: taskID > 0,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
LastError: strings.TrimSpace(queue.LastError),
|
||||
}
|
||||
if taskID > 0 {
|
||||
result.Current = buildQueueTaskItem(state, taskID)
|
||||
}
|
||||
return mustJSON(result, "queue_pop_head")
|
||||
}
|
||||
|
||||
// QueueApplyHeadMove 将当前队首任务移动到指定位置,成功后自动完成并出队。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 只能处理 current 任务,禁止越级指定 task_id,避免 LLM 绕过队列直接乱改;
|
||||
// 2. 成功时标记 completed 并清空 current;
|
||||
// 3. 失败时保留 current 并累加 attempt,让 LLM 继续换坑位重试或 skip。
|
||||
func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_apply_head_move","success":false,"result":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
currentID := queue.CurrentTaskID
|
||||
if currentID <= 0 {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "队列中没有正在处理的任务。请先调用 queue_pop_head。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
newDay, ok := argsInt(args, "new_day")
|
||||
if !ok {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "缺少必填参数 new_day。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
newSlotStart, ok := argsInt(args, "new_slot_start")
|
||||
if !ok {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "缺少必填参数 new_slot_start。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
// 1. 真正执行仍复用既有 move 校验链路,避免重复实现一套冲突判断。
|
||||
// 2. 失败时仅更新队列 attempt,不改 current,确保同一任务可继续重试。
|
||||
resultText := Move(state, currentID, newDay, newSlotStart)
|
||||
success := !strings.Contains(resultText, "移动失败")
|
||||
if success {
|
||||
markCurrentTaskCompleted(state)
|
||||
} else {
|
||||
bumpCurrentTaskAttempt(state, resultText)
|
||||
}
|
||||
|
||||
queue = ensureTaskProcessingQueue(state)
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: success,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: strings.TrimSpace(resultText),
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
// QueueSkipHead 跳过当前队首任务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只修改队列运行态,不改排程结果;
|
||||
// 2. current 必须存在,否则返回失败提示;
|
||||
// 3. 跳过后由下一轮 queue_pop_head 继续取下一项。
|
||||
func QueueSkipHead(state *ScheduleState, args map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_skip_head","success":false,"reason":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
currentID := queue.CurrentTaskID
|
||||
if currentID <= 0 {
|
||||
return mustJSON(queueSkipHeadResult{
|
||||
Tool: "queue_skip_head",
|
||||
Success: false,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Reason: "没有可跳过的 current 任务,请先 queue_pop_head。",
|
||||
}, "queue_skip_head")
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if raw, ok := argsString(args, "reason"); ok {
|
||||
reason = strings.TrimSpace(raw)
|
||||
}
|
||||
markCurrentTaskSkipped(state)
|
||||
queue = ensureTaskProcessingQueue(state)
|
||||
return mustJSON(queueSkipHeadResult{
|
||||
Tool: "queue_skip_head",
|
||||
Success: true,
|
||||
SkippedTaskID: currentID,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Reason: reason,
|
||||
}, "queue_skip_head")
|
||||
}
|
||||
|
||||
// QueueStatus 查询当前队列状态。
|
||||
func QueueStatus(state *ScheduleState, _ map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_status","pending_count":0,"completed_count":0,"skipped_count":0,"last_error":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
nextIDs := queue.PendingTaskIDs
|
||||
if len(nextIDs) > 5 {
|
||||
nextIDs = nextIDs[:5]
|
||||
}
|
||||
|
||||
result := queueStatusResult{
|
||||
Tool: "queue_status",
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
CurrentTaskID: queue.CurrentTaskID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
LastError: strings.TrimSpace(queue.LastError),
|
||||
NextTaskIDs: append([]int(nil), nextIDs...),
|
||||
}
|
||||
if queue.CurrentTaskID > 0 {
|
||||
result.Current = buildQueueTaskItem(state, queue.CurrentTaskID)
|
||||
}
|
||||
return mustJSON(result, "queue_status")
|
||||
}
|
||||
|
||||
// buildQueueTaskItem 构造队列任务快照,供 pop/status 返回。
|
||||
func buildQueueTaskItem(state *ScheduleState, taskID int) *queueTaskItem {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
item := &queueTaskItem{
|
||||
TaskID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
Category: strings.TrimSpace(task.Category),
|
||||
Status: buildTaskStatusLabel(*task),
|
||||
Duration: task.Duration,
|
||||
TaskClassID: task.TaskClassID,
|
||||
Slots: make([]queueTaskSlot, 0, len(task.Slots)),
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item.Slots = append(item.Slots, queueTaskSlot{
|
||||
Day: slot.Day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slot.SlotStart,
|
||||
SlotEnd: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func mustJSON(v any, toolName string) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"%s","success":false,"error":"json encode failed"}`, toolName)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
931
backend/newAgent/tools/read_filter_tools.go
Normal file
931
backend/newAgent/tools/read_filter_tools.go
Normal file
@@ -0,0 +1,931 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// queryAvailableSlotsResult 描述 query_available_slots 的结构化返回。
|
||||
type queryAvailableSlotsResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Count int `json:"count"`
|
||||
StrictCount int `json:"strict_count"`
|
||||
EmbeddedCount int `json:"embedded_count"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
DayScope string `json:"day_scope"`
|
||||
DayOfWeek []int `json:"day_of_week"`
|
||||
WeekFilter []int `json:"week_filter"`
|
||||
WeekFrom int `json:"week_from"`
|
||||
WeekTo int `json:"week_to"`
|
||||
Span int `json:"span"`
|
||||
AllowEmbed bool `json:"allow_embed"`
|
||||
ExcludeSections []int `json:"exclude_sections"`
|
||||
Slots []queryAvailableSlotItem `json:"slots"`
|
||||
}
|
||||
|
||||
// queryAvailableSlotItem 描述单个候选坑位。
|
||||
type queryAvailableSlotItem struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
SlotType string `json:"slot_type,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTasksResult 描述 query_target_tasks 的结构化返回。
|
||||
type queryTargetTasksResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Count int `json:"count"`
|
||||
Status string `json:"status"`
|
||||
DayScope string `json:"day_scope"`
|
||||
DayOfWeek []int `json:"day_of_week"`
|
||||
WeekFilter []int `json:"week_filter"`
|
||||
WeekFrom int `json:"week_from"`
|
||||
WeekTo int `json:"week_to"`
|
||||
Enqueue bool `json:"enqueue"`
|
||||
Enqueued int `json:"enqueued"`
|
||||
Queue *queryTargetQueueInfo `json:"queue,omitempty"`
|
||||
Items []queryTargetTaskItem `json:"items"`
|
||||
}
|
||||
|
||||
// queryTargetQueueInfo 描述 query_target_tasks 入队后的队列摘要。
|
||||
type queryTargetQueueInfo struct {
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTaskItem 描述候选任务。
|
||||
type queryTargetTaskItem struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
Slots []queryTargetTaskSlot `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTaskSlot 描述任务在工具状态中的坐标。
|
||||
type queryTargetTaskSlot struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
// queryAvailableOptions 是 query_available_slots 的参数快照。
|
||||
type queryAvailableOptions struct {
|
||||
DayScope string
|
||||
DayOfWeekSet map[int]struct{}
|
||||
WeekSet map[int]struct{}
|
||||
WeekFrom int
|
||||
WeekTo int
|
||||
Span int
|
||||
Limit int
|
||||
AllowEmbed bool
|
||||
ExcludedSection map[int]struct{}
|
||||
AfterSection *int
|
||||
BeforeSection *int
|
||||
ExactFrom *int
|
||||
ExactTo *int
|
||||
}
|
||||
|
||||
// queryTargetOptions 是 query_target_tasks 的参数快照。
|
||||
type queryTargetOptions struct {
|
||||
DayScope string
|
||||
DayOfWeekSet map[int]struct{}
|
||||
WeekSet map[int]struct{}
|
||||
WeekFrom int
|
||||
WeekTo int
|
||||
Status string
|
||||
Limit int
|
||||
TaskIDSet map[int]struct{}
|
||||
Category string
|
||||
Enqueue bool
|
||||
ResetQueue bool
|
||||
}
|
||||
|
||||
// QueryAvailableSlots 返回“候选坑位池”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责读状态并返回结构化 JSON,不做任何写入;
|
||||
// 2. 优先返回纯空位(strict),不足时再补可嵌入位(embedded);
|
||||
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
|
||||
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
|
||||
// 1. 解析参数并做合法性校验。
|
||||
options, err := parseQueryAvailableOptions(state, args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 2. 解析“可迭代天集合”:先解析 day/day_start/day_end,再叠加 week/day_scope/day_of_week 过滤。
|
||||
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 3. 两阶段收集:
|
||||
// 3.1 先收集 strict(纯空位),保证“先空位后嵌入”的默认策略;
|
||||
// 3.2 strict 不足 limit 时,再补 embed 候选(仅在 allow_embed=true 时)。
|
||||
slots := make([]queryAvailableSlotItem, 0, options.Limit)
|
||||
seen := make(map[string]struct{}, options.Limit*2)
|
||||
|
||||
collect := func(embedAllowed bool, slotType string) {
|
||||
if len(slots) >= options.Limit {
|
||||
return
|
||||
}
|
||||
for _, day := range candidateDays {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for slotStart := 1; slotStart+options.Span-1 <= 12; slotStart++ {
|
||||
slotEnd := slotStart + options.Span - 1
|
||||
if !matchSectionRange(slotStart, slotEnd, options.ExcludedSection, options.AfterSection, options.BeforeSection, options.ExactFrom, options.ExactTo) {
|
||||
continue
|
||||
}
|
||||
|
||||
accepted := false
|
||||
if !embedAllowed {
|
||||
accepted = isStrictSlotAvailable(state, day, slotStart, slotEnd)
|
||||
} else {
|
||||
accepted = isEmbeddableSlotAvailable(state, day, slotStart, slotEnd)
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%d-%d-%d", day, slotStart, slotEnd)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
slots = append(slots, queryAvailableSlotItem{
|
||||
Day: day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slotStart,
|
||||
SlotEnd: slotEnd,
|
||||
SlotType: slotType,
|
||||
})
|
||||
if len(slots) >= options.Limit {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(false, "empty")
|
||||
strictCount := len(slots)
|
||||
if options.AllowEmbed && len(slots) < options.Limit {
|
||||
collect(true, "embedded_candidate")
|
||||
}
|
||||
embeddedCount := len(slots) - strictCount
|
||||
|
||||
// 4. 组装结构化返回(JSON 字符串)。
|
||||
result := queryAvailableSlotsResult{
|
||||
Tool: "query_available_slots",
|
||||
Count: len(slots),
|
||||
StrictCount: strictCount,
|
||||
EmbeddedCount: embeddedCount,
|
||||
FallbackUsed: embeddedCount > 0,
|
||||
DayScope: options.DayScope,
|
||||
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
|
||||
WeekFilter: sortedSetKeys(options.WeekSet),
|
||||
WeekFrom: options.WeekFrom,
|
||||
WeekTo: options.WeekTo,
|
||||
Span: options.Span,
|
||||
AllowEmbed: options.AllowEmbed,
|
||||
ExcludeSections: sortedSetKeys(options.ExcludedSection),
|
||||
Slots: slots,
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"query_available_slots","success":false,"error":"query encode failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// QueryTargetTasks 返回“候选任务集合”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做筛选与结构化返回,不直接执行 move/swap;
|
||||
// 2. 默认 status=suggested,减少模型误选 existing/pending;
|
||||
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
|
||||
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
||||
// 1. 解析参数。
|
||||
options, err := parseQueryTargetOptions(state, args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 2. 解析“可迭代天集合”过滤器。
|
||||
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
calendarFilterActive := isQueryTargetCalendarFilterActive(args, options)
|
||||
daySet := make(map[int]struct{}, len(candidateDays))
|
||||
for _, d := range candidateDays {
|
||||
daySet[d] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 扫描任务并按筛选条件收敛。
|
||||
items := make([]queryTargetTaskItem, 0, options.Limit)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !matchTaskStatus(task, options.Status) {
|
||||
continue
|
||||
}
|
||||
if len(options.TaskIDSet) > 0 {
|
||||
if _, ok := options.TaskIDSet[task.StateID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if options.Category != "" && task.Category != options.Category {
|
||||
continue
|
||||
}
|
||||
|
||||
taskSlots := make([]queryTargetTaskSlot, 0, len(task.Slots))
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 3.1 若存在日历过滤条件,只保留命中过滤的坐标。
|
||||
if calendarFilterActive && len(daySet) > 0 {
|
||||
if _, hit := daySet[slot.Day]; !hit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
taskSlots = append(taskSlots, queryTargetTaskSlot{
|
||||
Day: slot.Day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slot.SlotStart,
|
||||
SlotEnd: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// 3.2 pending 任务默认无 slots;当存在日历过滤条件时,不应混入“未知坐标任务”。
|
||||
if len(taskSlots) == 0 && calendarFilterActive {
|
||||
continue
|
||||
}
|
||||
sort.Slice(taskSlots, func(i, j int) bool {
|
||||
if taskSlots[i].Day != taskSlots[j].Day {
|
||||
return taskSlots[i].Day < taskSlots[j].Day
|
||||
}
|
||||
if taskSlots[i].SlotStart != taskSlots[j].SlotStart {
|
||||
return taskSlots[i].SlotStart < taskSlots[j].SlotStart
|
||||
}
|
||||
return taskSlots[i].SlotEnd < taskSlots[j].SlotEnd
|
||||
})
|
||||
|
||||
items = append(items, queryTargetTaskItem{
|
||||
TaskID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
Category: strings.TrimSpace(task.Category),
|
||||
Status: buildTaskStatusLabel(task),
|
||||
Duration: task.Duration,
|
||||
TaskClassID: task.TaskClassID,
|
||||
Slots: taskSlots,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 稳定排序:先按最早坐标,再按 task_id。
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
leftHasSlot := len(items[i].Slots) > 0
|
||||
rightHasSlot := len(items[j].Slots) > 0
|
||||
if leftHasSlot != rightHasSlot {
|
||||
return leftHasSlot
|
||||
}
|
||||
if leftHasSlot {
|
||||
left := items[i].Slots[0]
|
||||
right := items[j].Slots[0]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.SlotStart != right.SlotStart {
|
||||
return left.SlotStart < right.SlotStart
|
||||
}
|
||||
}
|
||||
return items[i].TaskID < items[j].TaskID
|
||||
})
|
||||
if len(items) > options.Limit {
|
||||
items = items[:options.Limit]
|
||||
}
|
||||
|
||||
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 默认 enqueue=true,让 LLM 优先走“逐项处理”而不是一次性批量组合;
|
||||
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
|
||||
// 3. 入队仅保存 task_id,不复制任务全文,避免队列状态膨胀。
|
||||
queueInfo := (*queryTargetQueueInfo)(nil)
|
||||
enqueued := 0
|
||||
if options.Enqueue {
|
||||
taskIDs := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
taskIDs = append(taskIDs, item.TaskID)
|
||||
}
|
||||
if options.ResetQueue {
|
||||
enqueued = ReplaceTaskProcessingQueue(state, taskIDs)
|
||||
} else {
|
||||
enqueued = appendTaskIDsToQueue(state, taskIDs)
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
queueInfo = &queryTargetQueueInfo{
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
CurrentTaskID: queue.CurrentTaskID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 结构化返回。
|
||||
result := queryTargetTasksResult{
|
||||
Tool: "query_target_tasks",
|
||||
Count: len(items),
|
||||
Status: options.Status,
|
||||
DayScope: options.DayScope,
|
||||
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
|
||||
WeekFilter: sortedSetKeys(options.WeekSet),
|
||||
WeekFrom: options.WeekFrom,
|
||||
WeekTo: options.WeekTo,
|
||||
Enqueue: options.Enqueue,
|
||||
Enqueued: enqueued,
|
||||
Queue: queueInfo,
|
||||
Items: items,
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"query_target_tasks","success":false,"error":"query encode failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// parseQueryAvailableOptions 解析 query_available_slots 参数。
|
||||
func parseQueryAvailableOptions(state *ScheduleState, args map[string]any) (queryAvailableOptions, error) {
|
||||
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
|
||||
|
||||
allowEmbed := readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
|
||||
slotTypeHints := readStringSliceAny(args, "slot_types")
|
||||
if single := strings.TrimSpace(readStringAny(args, "slot_type", "")); single != "" {
|
||||
slotTypeHints = append(slotTypeHints, single)
|
||||
}
|
||||
for _, hint := range slotTypeHints {
|
||||
normalized := strings.ToLower(strings.TrimSpace(hint))
|
||||
if normalized == "pure" || normalized == "empty" || normalized == "strict" {
|
||||
allowEmbed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
span, ok := readIntAny(args, "span", "section_duration", "task_duration", "duration")
|
||||
if !ok || span <= 0 {
|
||||
span = 2
|
||||
}
|
||||
if span > 12 {
|
||||
return queryAvailableOptions{}, fmt.Errorf("span=%d 非法,必须在 1~12", span)
|
||||
}
|
||||
|
||||
limit, ok := readIntAny(args, "limit")
|
||||
if !ok || limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
|
||||
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
|
||||
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
|
||||
if week, hasWeek := readIntAny(args, "week"); hasWeek {
|
||||
weekFrom, weekTo = week, week
|
||||
hasWeekFrom, hasWeekTo = true, true
|
||||
}
|
||||
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
|
||||
weekFrom, weekTo = weekTo, weekFrom
|
||||
}
|
||||
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
|
||||
if !hasWeekFrom {
|
||||
weekFrom = defaultWeekFrom
|
||||
}
|
||||
if !hasWeekTo {
|
||||
weekTo = defaultWeekTo
|
||||
}
|
||||
|
||||
excluded := intSliceToSet(readIntSliceAny(args, "exclude_sections", "exclude_section"))
|
||||
afterSection, hasAfter := readIntAny(args, "after_section")
|
||||
beforeSection, hasBefore := readIntAny(args, "before_section")
|
||||
exactFrom, hasExactFrom := readIntAny(args, "section_from", "target_section_from")
|
||||
exactTo, hasExactTo := readIntAny(args, "section_to", "target_section_to")
|
||||
if hasExactFrom != hasExactTo {
|
||||
return queryAvailableOptions{}, fmt.Errorf("精确节次查询需要同时提供 section_from 和 section_to")
|
||||
}
|
||||
if hasExactFrom {
|
||||
if exactFrom < 1 || exactTo > 12 || exactFrom > exactTo {
|
||||
return queryAvailableOptions{}, fmt.Errorf("精确节次区间非法:%d-%d", exactFrom, exactTo)
|
||||
}
|
||||
span = exactTo - exactFrom + 1
|
||||
}
|
||||
|
||||
options := queryAvailableOptions{
|
||||
DayScope: scope,
|
||||
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
|
||||
WeekSet: weekSet,
|
||||
WeekFrom: weekFrom,
|
||||
WeekTo: weekTo,
|
||||
Span: span,
|
||||
Limit: limit,
|
||||
AllowEmbed: allowEmbed,
|
||||
ExcludedSection: excluded,
|
||||
}
|
||||
if hasAfter {
|
||||
options.AfterSection = &afterSection
|
||||
}
|
||||
if hasBefore {
|
||||
options.BeforeSection = &beforeSection
|
||||
}
|
||||
if hasExactFrom {
|
||||
options.ExactFrom = &exactFrom
|
||||
options.ExactTo = &exactTo
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// parseQueryTargetOptions 解析 query_target_tasks 参数。
|
||||
func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTargetOptions, error) {
|
||||
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
|
||||
status := strings.ToLower(strings.TrimSpace(readStringAny(args, "status", "suggested")))
|
||||
if status == "" {
|
||||
status = "suggested"
|
||||
}
|
||||
switch status {
|
||||
case "all", "existing", "suggested", "pending":
|
||||
default:
|
||||
return queryTargetOptions{}, fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
|
||||
}
|
||||
|
||||
limit, ok := readIntAny(args, "limit")
|
||||
if !ok || limit <= 0 {
|
||||
limit = 16
|
||||
}
|
||||
|
||||
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
|
||||
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
|
||||
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
|
||||
if week, hasWeek := readIntAny(args, "week"); hasWeek {
|
||||
weekFrom, weekTo = week, week
|
||||
hasWeekFrom, hasWeekTo = true, true
|
||||
}
|
||||
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
|
||||
weekFrom, weekTo = weekTo, weekFrom
|
||||
}
|
||||
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
|
||||
if !hasWeekFrom {
|
||||
weekFrom = defaultWeekFrom
|
||||
}
|
||||
if !hasWeekTo {
|
||||
weekTo = defaultWeekTo
|
||||
}
|
||||
|
||||
taskIDs := readIntSliceAny(args, "task_ids", "task_item_ids")
|
||||
if singleTaskID, ok := readIntAny(args, "task_id", "task_item_id"); ok {
|
||||
taskIDs = append(taskIDs, singleTaskID)
|
||||
}
|
||||
|
||||
return queryTargetOptions{
|
||||
DayScope: scope,
|
||||
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
|
||||
WeekSet: weekSet,
|
||||
WeekFrom: weekFrom,
|
||||
WeekTo: weekTo,
|
||||
Status: status,
|
||||
Limit: limit,
|
||||
TaskIDSet: intSliceToSet(taskIDs),
|
||||
Category: strings.TrimSpace(readStringAny(args, "category", "")),
|
||||
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
|
||||
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveCandidateDays 解析并返回候选 day 列表。
|
||||
//
|
||||
// 处理规则:
|
||||
// 1. 先解析 day / day_start / day_end(互斥)形成基础集合;
|
||||
// 2. 再叠加 day_scope / day_of_week / week_* 过滤;
|
||||
// 3. 返回升序去重结果;若过滤后为空,返回空切片但不报错。
|
||||
func resolveCandidateDays(
|
||||
state *ScheduleState,
|
||||
args map[string]any,
|
||||
dayScope string,
|
||||
dayOfWeekSet map[int]struct{},
|
||||
weekSet map[int]struct{},
|
||||
weekFrom int,
|
||||
weekTo int,
|
||||
) ([]int, error) {
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("state 为空")
|
||||
}
|
||||
|
||||
day, hasDay := readIntAny(args, "day")
|
||||
dayStart, hasDayStart := readIntAny(args, "day_start")
|
||||
dayEnd, hasDayEnd := readIntAny(args, "day_end")
|
||||
if hasDay && (hasDayStart || hasDayEnd) {
|
||||
return nil, fmt.Errorf("day 与 day_start/day_end 不能同时传入")
|
||||
}
|
||||
|
||||
baseDays := make([]int, 0, state.Window.TotalDays)
|
||||
if hasDay {
|
||||
if err := validateDay(state, day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDays = append(baseDays, day)
|
||||
} else {
|
||||
start := 1
|
||||
end := state.Window.TotalDays
|
||||
if hasDayStart {
|
||||
start = dayStart
|
||||
}
|
||||
if hasDayEnd {
|
||||
end = dayEnd
|
||||
}
|
||||
if start > end {
|
||||
return nil, fmt.Errorf("day_start=%d 不能大于 day_end=%d", start, end)
|
||||
}
|
||||
if err := validateDay(state, start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDay(state, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for d := start; d <= end; d++ {
|
||||
baseDays = append(baseDays, d)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(baseDays))
|
||||
for _, d := range baseDays {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(d)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(dayOfWeekSet) > 0 {
|
||||
if _, hit := dayOfWeekSet[dayOfWeek]; !hit {
|
||||
continue
|
||||
}
|
||||
} else if !matchDayScope(dayOfWeek, dayScope) {
|
||||
continue
|
||||
}
|
||||
if len(weekSet) > 0 {
|
||||
if _, hit := weekSet[week]; !hit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if week < weekFrom || week > weekTo {
|
||||
continue
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return uniqueInts(result), nil
|
||||
}
|
||||
|
||||
// matchSectionRange 判断候选节次是否满足过滤条件。
|
||||
func matchSectionRange(
|
||||
slotStart int,
|
||||
slotEnd int,
|
||||
excluded map[int]struct{},
|
||||
after *int,
|
||||
before *int,
|
||||
exactFrom *int,
|
||||
exactTo *int,
|
||||
) bool {
|
||||
if exactFrom != nil && exactTo != nil {
|
||||
if slotStart != *exactFrom || slotEnd != *exactTo {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if after != nil && slotStart <= *after {
|
||||
return false
|
||||
}
|
||||
if before != nil && slotEnd >= *before {
|
||||
return false
|
||||
}
|
||||
for section := slotStart; section <= slotEnd; section++ {
|
||||
if _, hit := excluded[section]; hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isStrictSlotAvailable 判断某段是否为“纯空位”。
|
||||
func isStrictSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
if task.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
if rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isEmbeddableSlotAvailable 判断某段是否可作为“可嵌入候选位”。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 该段不能与不可嵌入任务冲突;
|
||||
// 2. 该段必须完全落在某个 can_embed=true 且未被占用嵌入位的宿主中;
|
||||
// 3. 若命中 can_embed 但宿主已被嵌入(embedded_by!=nil),视为不可用。
|
||||
func isEmbeddableSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
|
||||
hostFound := false
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
if task.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
if !rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !task.CanEmbed {
|
||||
return false
|
||||
}
|
||||
if task.EmbeddedBy != nil {
|
||||
return false
|
||||
}
|
||||
if slotStart >= slot.SlotStart && slotEnd <= slot.SlotEnd {
|
||||
hostFound = true
|
||||
continue
|
||||
}
|
||||
// 与可嵌入宿主部分重叠但不被完全包含,也不能作为合法嵌入位。
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hostFound
|
||||
}
|
||||
|
||||
// matchTaskStatus 判断任务是否命中 status 过滤。
|
||||
func matchTaskStatus(task ScheduleTask, status string) bool {
|
||||
switch status {
|
||||
case "all":
|
||||
return true
|
||||
case "existing":
|
||||
return IsExistingTask(task)
|
||||
case "suggested":
|
||||
return IsSuggestedTask(task)
|
||||
case "pending":
|
||||
return IsPendingTask(task)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isQueryTargetCalendarFilterActive 判断是否显式启用了日历坐标过滤。
|
||||
func isQueryTargetCalendarFilterActive(args map[string]any, options queryTargetOptions) bool {
|
||||
if _, ok := readIntAny(args, "day"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "day_start"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "day_end"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week_from", "from_week"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week_to", "to_week"); ok {
|
||||
return true
|
||||
}
|
||||
if len(readIntSliceAny(args, "week_filter", "weeks")) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(options.DayOfWeekSet) > 0 {
|
||||
return true
|
||||
}
|
||||
scopeRaw := strings.TrimSpace(readStringAny(args, "day_scope"))
|
||||
return normalizeDayScope(scopeRaw) != "all" && scopeRaw != ""
|
||||
}
|
||||
|
||||
// buildTaskStatusLabel 返回任务状态标签。
|
||||
func buildTaskStatusLabel(task ScheduleTask) string {
|
||||
if IsPendingTask(task) {
|
||||
return "pending"
|
||||
}
|
||||
if IsSuggestedTask(task) {
|
||||
return "suggested"
|
||||
}
|
||||
return "existing"
|
||||
}
|
||||
|
||||
// rangesOverlap 判断两个闭区间是否重叠。
|
||||
func rangesOverlap(startA, endA, startB, endB int) bool {
|
||||
return startA <= endB && endA >= startB
|
||||
}
|
||||
|
||||
// normalizeDayScope 归一化 day_scope。
|
||||
func normalizeDayScope(scope string) string {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
switch scope {
|
||||
case "weekend", "workday", "all":
|
||||
return scope
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
// matchDayScope 判断 day_of_week 是否命中 day_scope。
|
||||
func matchDayScope(dayOfWeek int, scope string) bool {
|
||||
switch scope {
|
||||
case "weekend":
|
||||
return dayOfWeek == 6 || dayOfWeek == 7
|
||||
case "workday":
|
||||
return dayOfWeek >= 1 && dayOfWeek <= 5
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// inferWeekBounds 推导窗口内的最小/最大周。
|
||||
func inferWeekBounds(state *ScheduleState) (int, int) {
|
||||
if state == nil || len(state.Window.DayMapping) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
minWeek := state.Window.DayMapping[0].Week
|
||||
maxWeek := state.Window.DayMapping[0].Week
|
||||
for _, mapping := range state.Window.DayMapping {
|
||||
if mapping.Week < minWeek {
|
||||
minWeek = mapping.Week
|
||||
}
|
||||
if mapping.Week > maxWeek {
|
||||
maxWeek = mapping.Week
|
||||
}
|
||||
}
|
||||
return minWeek, maxWeek
|
||||
}
|
||||
|
||||
// readIntAny 按别名顺序读取 int 参数。
|
||||
func readIntAny(args map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := argsInt(args, key)
|
||||
if ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// readStringAny 按别名顺序读取 string 参数。
|
||||
func readStringAny(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := argsString(args, key); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readBoolAnyWithDefault 按别名顺序读取 bool 参数。
|
||||
func readBoolAnyWithDefault(args map[string]any, defaultValue bool, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if lower == "true" {
|
||||
return true
|
||||
}
|
||||
if lower == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// readIntSliceAny 按别名顺序读取 int 列表参数。
|
||||
func readIntSliceAny(args map[string]any, keys ...string) []int {
|
||||
for _, key := range keys {
|
||||
if values, ok := argsIntSlice(args, key); ok {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStringSliceAny 按别名顺序读取 string 列表参数。
|
||||
func readStringSliceAny(args map[string]any, keys ...string) []string {
|
||||
for _, key := range keys {
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch values := raw.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(values)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// intSliceToSet 将 int 列表转为集合。
|
||||
func intSliceToSet(values []int) map[int]struct{} {
|
||||
if len(values) == 0 {
|
||||
return map[int]struct{}{}
|
||||
}
|
||||
set := make(map[int]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// sortedSetKeys 返回集合的升序 key 切片。
|
||||
func sortedSetKeys(set map[int]struct{}) []int {
|
||||
if len(set) == 0 {
|
||||
return []int{}
|
||||
}
|
||||
keys := make([]int, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// uniqueInts 对整数切片去重并保持升序。
|
||||
func uniqueInts(values []int) []int {
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
|
||||
}
|
||||
|
||||
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
|
||||
// 条件:CanEmbed == true,用于 find_first_free 和 get_overview 输出可嵌入位置。
|
||||
// 条件:CanEmbed == true,用于 query_available_slots 和 get_overview 输出可嵌入位置。
|
||||
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
|
||||
var result []*ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
|
||||
@@ -7,24 +7,16 @@ import (
|
||||
)
|
||||
|
||||
// ToolHandler 是所有工具的统一执行签名。
|
||||
// 接收当前 ScheduleState + LLM 输出的原始参数,返回自然语言结果。
|
||||
type ToolHandler func(state *ScheduleState, args map[string]any) string
|
||||
|
||||
// ToolSchemaEntry 是工具描述的轻量快照,用于 LLM prompt 注入。
|
||||
// 在注入 ConversationContext 时转换为 model.ToolSchemaContext。
|
||||
// ToolSchemaEntry 是注入给模型的工具说明快照。
|
||||
type ToolSchemaEntry struct {
|
||||
Name string
|
||||
Desc string
|
||||
SchemaText string
|
||||
}
|
||||
|
||||
// ToolRegistry 管理所有工具的注册、查找和执行。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责工具名 → handler 的映射;
|
||||
// 2. 负责工具 schema 的存储(供 LLM prompt 注入);
|
||||
// 3. 不负责 ScheduleState 的生命周期管理;
|
||||
// 4. 不负责 confirm 流程(由 execute.go 的 action 分支处理)。
|
||||
// ToolRegistry 管理工具注册、查找与执行。
|
||||
type ToolRegistry struct {
|
||||
handlers map[string]ToolHandler
|
||||
schemas []ToolSchemaEntry
|
||||
@@ -38,7 +30,7 @@ func NewToolRegistry() *ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册一个工具及其 schema 描述。
|
||||
// Register 注册一个工具及其 schema。
|
||||
func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandler) {
|
||||
r.handlers[name] = handler
|
||||
r.schemas = append(r.schemas, ToolSchemaEntry{
|
||||
@@ -49,7 +41,6 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
|
||||
}
|
||||
|
||||
// Execute 执行指定工具。
|
||||
// 工具名不存在时返回错误提示字符串。
|
||||
func (r *ToolRegistry) Execute(state *ScheduleState, toolName string, args map[string]any) string {
|
||||
handler, ok := r.handlers[toolName]
|
||||
if !ok {
|
||||
@@ -64,36 +55,38 @@ func (r *ToolRegistry) HasTool(name string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// ToolNames 返回所有已注册工具名(按注册顺序)。
|
||||
// ToolNames 返回已注册工具名(按 schema 顺序)。
|
||||
func (r *ToolRegistry) ToolNames() []string {
|
||||
names := make([]string, 0, len(r.handlers))
|
||||
for _, s := range r.schemas {
|
||||
names = append(names, s.Name)
|
||||
for _, item := range r.schemas {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Schemas 返回所有工具的 schema 描述(供 LLM prompt 注入)。
|
||||
// Schemas 返回 schema 快照。
|
||||
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
|
||||
result := make([]ToolSchemaEntry, len(r.schemas))
|
||||
copy(result, r.schemas)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsWriteTool 判断指定工具是否为写工具(需要 confirm 流程)。
|
||||
// IsWriteTool 判断工具是否是写工具(需要 confirm)。
|
||||
func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
return writeTools[name]
|
||||
}
|
||||
|
||||
// ==================== 写工具名集合 ====================
|
||||
// ==================== 写工具集合 ====================
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
@@ -123,20 +116,40 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("find_first_free",
|
||||
"查找首个满足时长条件的可用位置,并返回该日详细负载信息。duration 必填;可用 day 指定单天,或用 day_start/day_end 指定搜索范围(互斥)。",
|
||||
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"}}}`,
|
||||
r.Register("query_available_slots",
|
||||
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。",
|
||||
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"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"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
duration, ok := argsInt(args, "duration")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 duration。"
|
||||
}
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"), argsIntPtr(args, "day_start"), argsIntPtr(args, "day_end"))
|
||||
return QueryAvailableSlots(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。",
|
||||
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"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"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return QueryTargetTasks(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。",
|
||||
`{"name":"queue_pop_head","parameters":{}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return QueuePopHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_status",
|
||||
"查看当前待处理队列状态(pending/current/completed/skipped)。",
|
||||
`{"name":"queue_status","parameters":{}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return QueueStatus(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("list_tasks",
|
||||
"列出任务清单,可按类别和状态过滤。category 传任务类名称(非 ID 列表)可选,status 选填(默认 all,仅支持单值 all/existing/suggested/pending)。",
|
||||
"列出任务清单,可按类别和状态过滤。category 传任务类名称,status 仅支持单值 all/existing/suggested/pending。",
|
||||
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
|
||||
@@ -144,7 +157,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
r.Register("get_task_info",
|
||||
"查询单个任务的详细信息,包括类别、状态、占用时段、嵌入关系。",
|
||||
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
|
||||
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
taskID, ok := argsInt(args, "task_id")
|
||||
@@ -213,7 +226,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
r.Register("batch_move",
|
||||
"原子性批量移动多个任务(仅 suggested),全部成功才生效。若含 existing/pending 将整批失败回滚。moves 数组必填。",
|
||||
"原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
moves, err := argsMoveList(args)
|
||||
@@ -224,6 +237,22 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。",
|
||||
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return QueueApplyHeadMove(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_skip_head",
|
||||
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。",
|
||||
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
return QueueSkipHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("min_context_switch",
|
||||
"在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。",
|
||||
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
|
||||
@@ -236,6 +265,18 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("spread_even",
|
||||
"在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。",
|
||||
`{"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 *ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := parseSpreadEvenTaskIDs(args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
return SpreadEven(state, taskIDs, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
@@ -248,7 +289,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
// 按 schema name 排序,保证输出稳定。
|
||||
// 按 schema name 排序,确保输出稳定。
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
|
||||
177
backend/newAgent/tools/runtime_queue.go
Normal file
177
backend/newAgent/tools/runtime_queue.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package newagenttools
|
||||
|
||||
// TaskProcessingQueue 表示 execute 阶段的“逐项处理队列”运行态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. PendingTaskIDs:尚未开始处理的候选任务;
|
||||
// 2. CurrentTaskID:当前正在处理的队首任务(0 表示暂无);
|
||||
// 3. CompletedTaskIDs / SkippedTaskIDs:本轮处理结果归档;
|
||||
// 4. LastError:最近一次 apply 失败的原因,供 LLM 下一轮决策参考。
|
||||
type TaskProcessingQueue struct {
|
||||
PendingTaskIDs []int `json:"pending_task_ids,omitempty"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempts int `json:"current_attempts,omitempty"`
|
||||
CompletedTaskIDs []int `json:"completed_task_ids,omitempty"`
|
||||
SkippedTaskIDs []int `json:"skipped_task_ids,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// ensureTaskProcessingQueue 确保 state 上有可用队列容器。
|
||||
func ensureTaskProcessingQueue(state *ScheduleState) *TaskProcessingQueue {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if state.RuntimeQueue == nil {
|
||||
state.RuntimeQueue = &TaskProcessingQueue{}
|
||||
}
|
||||
return state.RuntimeQueue
|
||||
}
|
||||
|
||||
// ResetTaskProcessingQueue 清空本轮临时队列,供“新一轮执行开始”时调用。
|
||||
func ResetTaskProcessingQueue(state *ScheduleState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.RuntimeQueue = nil
|
||||
}
|
||||
|
||||
// ReplaceTaskProcessingQueue 用新的任务 ID 列表覆盖队列。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先重置队列,避免上一次处理结果残留;
|
||||
// 2. 对输入任务 ID 去重,防止 LLM 重复筛选造成同任务重复入队;
|
||||
// 3. 不自动弹出当前任务,保持“显式 queue_pop_head 才开始处理”的流程约束。
|
||||
func ReplaceTaskProcessingQueue(state *ScheduleState, taskIDs []int) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil {
|
||||
return 0
|
||||
}
|
||||
queue.PendingTaskIDs = nil
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.CompletedTaskIDs = nil
|
||||
queue.SkippedTaskIDs = nil
|
||||
queue.LastError = ""
|
||||
return appendTaskIDsToQueue(state, taskIDs)
|
||||
}
|
||||
|
||||
// appendTaskIDsToQueue 将任务追加到队列尾部并做去重,返回本次实际入队数量。
|
||||
//
|
||||
// 去重规则:
|
||||
// 1. 与当前正在处理的任务去重;
|
||||
// 2. 与 pending / completed / skipped 去重;
|
||||
// 3. task_id<=0 直接忽略,避免无效数据污染队列。
|
||||
func appendTaskIDsToQueue(state *ScheduleState, taskIDs []int) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || len(taskIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
exists := make(map[int]struct{}, len(queue.PendingTaskIDs)+len(queue.CompletedTaskIDs)+len(queue.SkippedTaskIDs)+1)
|
||||
if queue.CurrentTaskID > 0 {
|
||||
exists[queue.CurrentTaskID] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.PendingTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.CompletedTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.SkippedTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, id := range taskIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := exists[id]; ok {
|
||||
continue
|
||||
}
|
||||
queue.PendingTaskIDs = append(queue.PendingTaskIDs, id)
|
||||
exists[id] = struct{}{}
|
||||
added++
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// popOrGetCurrentTaskID 返回当前可处理任务。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 若已有 CurrentTaskID,直接复用(保证 apply/skip 前不切换对象);
|
||||
// 2. 若 current 为空且 pending 非空,则弹出队首并设为 current;
|
||||
// 3. 若队列为空,返回 0。
|
||||
func popOrGetCurrentTaskID(state *ScheduleState) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil {
|
||||
return 0
|
||||
}
|
||||
if queue.CurrentTaskID > 0 {
|
||||
return queue.CurrentTaskID
|
||||
}
|
||||
if len(queue.PendingTaskIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
queue.CurrentTaskID = queue.PendingTaskIDs[0]
|
||||
queue.PendingTaskIDs = queue.PendingTaskIDs[1:]
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
return queue.CurrentTaskID
|
||||
}
|
||||
|
||||
// markCurrentTaskCompleted 将 current 任务标记为完成并清空 current。
|
||||
func markCurrentTaskCompleted(state *ScheduleState) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.CompletedTaskIDs = append(queue.CompletedTaskIDs, queue.CurrentTaskID)
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
}
|
||||
|
||||
// markCurrentTaskSkipped 将 current 任务标记为跳过并清空 current。
|
||||
func markCurrentTaskSkipped(state *ScheduleState) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.SkippedTaskIDs = append(queue.SkippedTaskIDs, queue.CurrentTaskID)
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
}
|
||||
|
||||
// bumpCurrentTaskAttempt 记录 current 任务一次失败尝试。
|
||||
func bumpCurrentTaskAttempt(state *ScheduleState, errText string) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.CurrentAttempts++
|
||||
queue.LastError = errText
|
||||
}
|
||||
|
||||
// cloneTaskProcessingQueue 深拷贝 RuntimeQueue。
|
||||
func cloneTaskProcessingQueue(src *TaskProcessingQueue) *TaskProcessingQueue {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := &TaskProcessingQueue{
|
||||
CurrentTaskID: src.CurrentTaskID,
|
||||
CurrentAttempts: src.CurrentAttempts,
|
||||
LastError: src.LastError,
|
||||
}
|
||||
if len(src.PendingTaskIDs) > 0 {
|
||||
dst.PendingTaskIDs = append([]int(nil), src.PendingTaskIDs...)
|
||||
}
|
||||
if len(src.CompletedTaskIDs) > 0 {
|
||||
dst.CompletedTaskIDs = append([]int(nil), src.CompletedTaskIDs...)
|
||||
}
|
||||
if len(src.SkippedTaskIDs) > 0 {
|
||||
dst.SkippedTaskIDs = append([]int(nil), src.SkippedTaskIDs...)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -69,6 +69,13 @@ type ScheduleState struct {
|
||||
Window ScheduleWindow `json:"window"`
|
||||
Tasks []ScheduleTask `json:"tasks"`
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
|
||||
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载 LLM 队列化微调时的运行态(待处理/当前处理/已完成/已跳过);
|
||||
// 2. 只用于 newAgent 运行期,不参与数据库持久化;
|
||||
// 3. 支持随 AgentStateSnapshot 一起快照,便于断线恢复后继续处理队首任务。
|
||||
RuntimeQueue *TaskProcessingQueue `json:"runtime_queue,omitempty"`
|
||||
}
|
||||
|
||||
// DayToWeekDay converts day_index to (week, day_of_week).
|
||||
@@ -131,5 +138,6 @@ func (s *ScheduleState) Clone() *ScheduleState {
|
||||
clone.Tasks[i].EmbedHost = &v
|
||||
}
|
||||
}
|
||||
clone.RuntimeQueue = cloneTaskProcessingQueue(s.RuntimeQueue)
|
||||
return clone
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ type MoveRequest struct {
|
||||
NewSlotStart int `json:"new_slot_start"`
|
||||
}
|
||||
|
||||
const (
|
||||
// maxBatchMoveSize 是 batch_move 的安全上限。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 旧链路中 batch_move 容易因组合冲突导致“整批回滚 + 连续重试”;
|
||||
// 2. 先把批量规模限制在 2,作为止血策略,降低一次决策的冲突面;
|
||||
// 3. 更大规模的调整应优先走队列化逐项处理(queue_pop_head + queue_apply_head_move)。
|
||||
maxBatchMoveSize = 2
|
||||
)
|
||||
|
||||
// ==================== Place ====================
|
||||
|
||||
// Place 将一个待安排任务预排到指定位置。
|
||||
@@ -260,6 +270,9 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
if len(moves) == 0 {
|
||||
return "批量移动失败:移动列表为空。"
|
||||
}
|
||||
if len(moves) > maxBatchMoveSize {
|
||||
return fmt.Sprintf("批量移动失败:当前最多支持 %d 条移动请求。请改用队列化逐项处理(queue_pop_head + queue_apply_head_move)。", maxBatchMoveSize)
|
||||
}
|
||||
|
||||
// 1. 全量校验阶段(不改 state)。
|
||||
for i, m := range moves {
|
||||
|
||||
@@ -245,8 +245,23 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
||||
cs.UserID = userID
|
||||
cs.ConversationID = chatID
|
||||
|
||||
// 不需要手动重置 Phase:所有请求统一先过 Chat 节点,Chat 会根据路由决策覆盖 Phase。
|
||||
// 保留完整的 RuntimeState(PlanSteps、CurrentStep 等),支持连续对话调整日程。
|
||||
// 1. 冷加载兜底:若上一轮已经收口且当前没有待恢复交互,说明本次是新一轮请求;
|
||||
// 2. 这里先重置执行期临时字段,避免旧 round/terminal 状态污染 chat 路由和后续 execute;
|
||||
// 3. 即使 chat 节点也有同条件重置,这里仍保留兜底,覆盖断线恢复或入口绕行场景。
|
||||
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
|
||||
terminalBefore := cs.TerminalStatus()
|
||||
roundBefore := cs.RoundUsed
|
||||
cs.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] loadOrCreateRuntimeState reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
chatID,
|
||||
roundBefore,
|
||||
terminalBefore,
|
||||
)
|
||||
}
|
||||
|
||||
// 常规场景仍由 Chat 节点基于路由覆盖 Phase,这里只在"上一轮已 done"时做一次前置清理兜底。
|
||||
// 其余跨轮可复用状态(如任务类范围、会话历史、日程内存态)继续保留,支持连续对话调整日程。
|
||||
|
||||
originalScheduleState := snapshot.OriginalScheduleState
|
||||
if snapshot.ScheduleState != nil && originalScheduleState == nil {
|
||||
@@ -463,7 +478,7 @@ func (s *AgentService) makeRoughBuildFunc() newagentmodel.RoughBuildFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Deliver 节点注入。
|
||||
// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Execute/Deliver 节点复用。
|
||||
func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedulePreviewFunc {
|
||||
if s.cacheDAO == nil {
|
||||
return nil
|
||||
@@ -472,12 +487,12 @@ func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedul
|
||||
stateDigest := summarizeScheduleStateForPreviewDebug(state)
|
||||
preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "")
|
||||
if preview == nil {
|
||||
log.Printf("[WARN] deliver preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
|
||||
log.Printf("[WARN] schedule preview skipped chat=%s user=%d state=%s", conversationID, userID, stateDigest)
|
||||
return nil
|
||||
}
|
||||
previewDigest := summarizeHybridEntriesForPreviewDebug(preview.HybridEntries)
|
||||
log.Printf(
|
||||
"[DEBUG] deliver preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
|
||||
"[DEBUG] schedule preview write chat=%s user=%d state=%s preview=%s generated_at=%s",
|
||||
conversationID,
|
||||
userID,
|
||||
stateDigest,
|
||||
|
||||
Reference in New Issue
Block a user