Version: 0.9.9.dev.260408
后端: 1. 粗排后分流与顺序守卫落地,支持“无明确微调偏好时粗排后直接收口”,并新增 allow_reorder / needs_refine_after_rough_build 语义,打通 chat→rough_build→execute/order_guard→deliver 路由。 2. execute 工具执行链路修复:清理乱码坏块与重复分支;新增 min_context_switch 未授权拦截;补齐 suggested 顺序基线初始化与顺序守卫联动。 3. 新增复合写工具 min_context_switch(减少上下文切换)并接入注册、参数解析、写工具白名单、提示词与文档;仅在用户明确允许打乱顺序时可用。 4. 工具口径升级:find_first_free 支持 day/day_start/day_end 范围参数并统一文案;移除 find_free 兼容别名;读写工具输出统一到“第N天(星期X)”格式。 5. prompt 同步升级:chat/execute/execute_context 增加粗排后是否继续微调、顺序授权、min_context_switch 使用边界与返回示例约束。 6. handoff 文档重命名并重写下班交接重点:下一步聚焦“工具收敛能力研究 + 运行态必要参数重置(不丢运行态)”。 7. 同步更新调试日志文件。 前端:无 仓库:无
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Handoff
|
||||
# Handoff(工具研究与运行态重置)
|
||||
|
||||
以下内容可直接交给下一位助理继续做。
|
||||
|
||||
@@ -725,3 +725,90 @@ go test ./conv ./newAgent/node ./newAgent/model ./newAgent/graph ./newAgent/tool
|
||||
- `backend/newAgent/tools/write_tools.go`
|
||||
- `backend/newAgent/tools/registry.go`
|
||||
- `backend/newAgent/tools/SCHEDULE_TOOLS.md`
|
||||
|
||||
---
|
||||
|
||||
## 13. 2026-04-08 下班前交接(本节优先级最高)
|
||||
|
||||
> 下一棒主线已经明确:先研究工具收敛能力,再修“新一轮执行前必要参数重置”。
|
||||
|
||||
### 13.1 本轮新增落地(已完成)
|
||||
|
||||
1. 顺序约束链路已落地:
|
||||
- 新增 `order_guard` 节点,并接入 graph 分支;
|
||||
- 默认 `AllowReorder=false` 时,`PhaseDone(completed)` 会先走 `order_guard` 再 `deliver`;
|
||||
- 用户明确允许打乱顺序时才放行。
|
||||
|
||||
2. `min_context_switch` 工具已接入:
|
||||
- 新增工具实现与注册;
|
||||
- execute 层已加护栏:未授权打乱顺序时拒绝执行并返回明确 observation;
|
||||
- prompt / 工具文档已同步“仅用户明确授权才可用”。
|
||||
|
||||
3. `execute.go` 乱码坏块已修复:
|
||||
- 清理了污染字符串、重复 if、结构异常;
|
||||
- 该段目前为单一、可读、可编译分支。
|
||||
|
||||
4. 当前分支编译验证通过:
|
||||
- `go test ./newAgent/... ./logic/...` 已通过;
|
||||
- 测试后已清理项目根目录 `.gocache`。
|
||||
|
||||
### 13.2 已确认的两个“未完成关键点”
|
||||
|
||||
#### A. msg2/msg3 语义尚未改到目标形态
|
||||
|
||||
现状:
|
||||
- execute 仍是固定 4 消息骨架(`msg2=当轮 ReAct 窗口`,`msg3=执行状态锚点`);
|
||||
- “当轮 ReAct 结束后降级为普通历史并走统一 token 裁剪”还没真正落地;
|
||||
- `ConversationContext.AppendHistory` 本身不做裁剪,统一裁剪链路也尚未接入。
|
||||
|
||||
相关文件:
|
||||
- `backend/newAgent/prompt/execute_context.go`
|
||||
- `backend/newAgent/prompt/base.go`
|
||||
- `backend/newAgent/model/conversation_context.go`
|
||||
|
||||
#### B. round 未在“新一轮开始”自动重置
|
||||
|
||||
现状:
|
||||
- `RoundUsed` 只在 `NextRound()` 累加;
|
||||
- `StartDirectExecute()` 不会重置 `RoundUsed`;
|
||||
- 快照恢复会带回旧 `RoundUsed`,所以“对话已结束但 round 没清零”可复现。
|
||||
|
||||
相关文件:
|
||||
- `backend/newAgent/model/common_state.go`
|
||||
- `backend/service/agentsvc/agent_newagent.go`
|
||||
|
||||
### 13.3 下一步实施建议(按此顺序)
|
||||
|
||||
#### P0:运行态重置(必须先做)
|
||||
|
||||
目标:不丢运行态,不破坏连续对话;只重置执行期临时字段。
|
||||
|
||||
1. 在 `CommonState` 新增 `ResetForNextRun()`(统一重置入口):
|
||||
- 需要重置:`RoundUsed`、`ConsecutiveCorrections`、`PlanSteps/CurrentStep`、`NeedsRoughBuild`、`NeedsRefineAfterRoughBuild`、`AllowReorder`、`SuggestedOrderBaseline`、`TerminalOutcome`。
|
||||
- 不重置:`ConversationID`、`UserID`、历史对话、ScheduleState。
|
||||
|
||||
2. 在 `Chat` 节点入口做主路径重置:
|
||||
- 条件:`!HasPendingInteraction()` 且上一轮 `PhaseDone`;
|
||||
- 目的:用户发起新轮请求时自动清执行期脏状态。
|
||||
|
||||
3. 在冷加载恢复处做同样重置兜底:
|
||||
- 位置:`loadOrCreateRuntimeState()`;
|
||||
- 条件同上;
|
||||
- 目的:覆盖断联恢复场景,避免旧 round 污染新轮。
|
||||
|
||||
#### P1:工具收敛能力研究与改造
|
||||
|
||||
延续 12.x 的结论,优先做:
|
||||
1. `evaluate_balance`(完成判据工具)
|
||||
2. `find_first_free` 从单点升级为候选集(`top_k`)
|
||||
3. `query_range` 明确区分 `hard_conflict` 与 `embeddable_overlap`
|
||||
|
||||
### 13.4 本节涉及的关键文件
|
||||
|
||||
- `backend/newAgent/model/common_state.go`
|
||||
- `backend/newAgent/node/chat.go`
|
||||
- `backend/service/agentsvc/agent_newagent.go`
|
||||
- `backend/newAgent/prompt/execute_context.go`
|
||||
- `backend/newAgent/tools/read_tools.go`
|
||||
- `backend/newAgent/tools/registry.go`
|
||||
- `backend/newAgent/tools/SCHEDULE_TOOLS.md`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ const (
|
||||
NodeConfirm = "confirm"
|
||||
NodeRoughBuild = "rough_build"
|
||||
NodeExecute = "execute"
|
||||
NodeOrderGuard = "order_guard"
|
||||
NodeInterrupt = "interrupt"
|
||||
NodeDeliver = "deliver"
|
||||
)
|
||||
@@ -51,6 +52,9 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
|
||||
if err := g.AddLambdaNode(NodeExecute, compose.InvokableLambda(nodes.Execute)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeOrderGuard, compose.InvokableLambda(nodes.OrderGuard)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddLambdaNode(NodeInterrupt, compose.InvokableLambda(nodes.Interrupt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,31 +106,38 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// RoughBuild -> Execute / Deliver:
|
||||
// RoughBuild -> Execute / OrderGuard / Deliver:
|
||||
// 1. 正常粗排完成后进入 execute 微调;
|
||||
// 2. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。
|
||||
// 2. 若粗排阶段 completed 且默认保持顺序,先走 order_guard 再交付;
|
||||
// 3. 若粗排阶段已写入正式终止结果(如粗排异常 abort),则直接进入 deliver 收口。
|
||||
if err := g.AddBranch(NodeRoughBuild, compose.NewGraphBranch(
|
||||
branchAfterRoughBuild,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
NodeExecute: true,
|
||||
NodeOrderGuard: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
// Execute -> Execute(继续 ReAct) / Confirm(写操作待确认) / OrderGuard(顺序守卫) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
if err := g.AddBranch(NodeExecute, compose.NewGraphBranch(
|
||||
branchAfterExecute,
|
||||
map[string]bool{
|
||||
NodeExecute: true,
|
||||
NodeConfirm: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
NodeExecute: true,
|
||||
NodeConfirm: true,
|
||||
NodeOrderGuard: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// OrderGuard -> Deliver:顺序守卫只做校验,最终都由 Deliver 统一收口。
|
||||
if err := g.AddEdge(NodeOrderGuard, NodeDeliver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Interrupt -> END:当前连接必须在这里收口,等待用户输入或确认回调恢复。
|
||||
if err := g.AddEdge(NodeInterrupt, compose.END); err != nil {
|
||||
return nil, err
|
||||
@@ -253,6 +264,9 @@ func branchAfterRoughBuild(_ context.Context, st *newagentmodel.AgentGraphState)
|
||||
return NodeExecute, nil
|
||||
}
|
||||
if flowState.Phase == newagentmodel.PhaseDone {
|
||||
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder {
|
||||
return NodeOrderGuard, nil
|
||||
}
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
@@ -280,6 +294,9 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s
|
||||
// 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入,
|
||||
// 导致 deliver 收口和后续预览落盘语义不一致。
|
||||
if flowState.Phase == newagentmodel.PhaseDone {
|
||||
if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder {
|
||||
return NodeOrderGuard, nil
|
||||
}
|
||||
return NodeDeliver, nil
|
||||
}
|
||||
return NodeExecute, nil
|
||||
|
||||
@@ -28,12 +28,16 @@ const (
|
||||
// 1. Route 决定后续处理路径;
|
||||
// 2. Speak 始终填写:给用户看的话;
|
||||
// 3. NeedsRoughBuild 仅在 route=execute 且满足粗排条件时为 true;
|
||||
// 4. Reason 给后端和日志看。
|
||||
// 4. NeedsRefineAfterRoughBuild 仅在 needs_rough_build=true 时有效;
|
||||
// 5. AllowReorder 表示是否允许打乱 suggested 任务顺序,仅用户明确授权时应为 true;
|
||||
// 6. Reason 给后端和日志看。
|
||||
type ChatRoutingDecision struct {
|
||||
Route ChatRoute `json:"route"`
|
||||
Speak string `json:"speak,omitempty"`
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Route ChatRoute `json:"route"`
|
||||
Speak string `json:"speak,omitempty"`
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize 统一清洗路由决策中的字符串字段。
|
||||
@@ -68,5 +72,16 @@ func (d *ChatRoutingDecision) Validate() error {
|
||||
return fmt.Errorf("direct_reply 必须携带 speak")
|
||||
}
|
||||
|
||||
// 非 execute 路由不应携带粗排和粗排后微调标记,统一归一化为 false。
|
||||
if d.Route != ChatRouteExecute {
|
||||
d.NeedsRoughBuild = false
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
d.AllowReorder = false
|
||||
}
|
||||
// 只有 needs_rough_build=true 时,needs_refine_after_rough_build 才有语义。
|
||||
if !d.NeedsRoughBuild {
|
||||
d.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,6 +97,19 @@ type CommonState struct {
|
||||
// NeedsRoughBuild 由 Plan 节点在 plan_done 时写入,标记 Confirm 后是否需要走粗排节点。
|
||||
// 粗排节点执行完毕后会将此字段重置为 false。
|
||||
NeedsRoughBuild bool `json:"needs_rough_build,omitempty"`
|
||||
// NeedsRefineAfterRoughBuild 表示“粗排完成后是否需要立即进入微调”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该标记主要用于 chat->execute 的直执行链路;
|
||||
// 2. true 表示用户已明确提出优化偏好,粗排后继续进 execute 微调;
|
||||
// 3. false 表示用户仅要求完成排入,粗排成功后可直接收口,等待后续再优化。
|
||||
NeedsRefineAfterRoughBuild bool `json:"needs_refine_after_rough_build,omitempty"`
|
||||
// AllowReorder 表示本轮是否允许打乱 suggested 任务的相对顺序。
|
||||
// 默认 false,只有用户明确说明“可以打乱顺序/顺序不重要”才会为 true。
|
||||
AllowReorder bool `json:"allow_reorder,omitempty"`
|
||||
// SuggestedOrderBaseline 保存“本轮 execute 启动前”的 suggested 任务相对顺序基线。
|
||||
// OrderGuard 节点会基于该基线判断微调是否破坏顺序约束。
|
||||
SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"`
|
||||
|
||||
// TerminalOutcome 保存“本轮流程最终如何结束”的统一收口结果。
|
||||
// 第二轮开始,rough_build / execute / deliver 都应围绕这份快照判断收口语义。
|
||||
@@ -134,12 +147,16 @@ func (s *CommonState) FinishPlan(steps []PlanStep) {
|
||||
s.PlanSteps = steps
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseWaitingConfirm
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
// ConfirmPlan 表示用户已确认计划,流程进入执行阶段。
|
||||
func (s *CommonState) ConfirmPlan() {
|
||||
s.Phase = PhaseExecuting
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -151,6 +168,9 @@ func (s *CommonState) StartDirectExecute() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhaseExecuting
|
||||
s.NeedsRoughBuild = false
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
@@ -159,6 +179,8 @@ func (s *CommonState) RejectPlan() {
|
||||
s.PlanSteps = nil
|
||||
s.CurrentStep = 0
|
||||
s.Phase = PhasePlanning
|
||||
s.NeedsRefineAfterRoughBuild = false
|
||||
s.SuggestedOrderBaseline = nil
|
||||
s.ClearTerminalOutcome()
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,25 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// OrderGuard 是顺序守卫阶段的正式节点方法。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责调用 RunOrderGuardNode 做 suggested 相对顺序校验;
|
||||
// 2. 不负责交付文案生成,校验结果统一交给 Deliver 节点收口;
|
||||
// 3. 节点执行后保存状态,保证异常中断后仍可复盘守卫结果。
|
||||
func (n *AgentNodes) OrderGuard(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("order_guard node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunOrderGuardNode(ctx, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Deliver 是交付阶段的正式节点方法。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -21,6 +21,14 @@ const (
|
||||
chatSpeakBlockID = "chat.speak"
|
||||
)
|
||||
|
||||
type reorderPreference int
|
||||
|
||||
const (
|
||||
reorderUnknown reorderPreference = iota
|
||||
reorderAllow
|
||||
reorderDisallow
|
||||
)
|
||||
|
||||
// ChatNodeInput 描述聊天节点单轮运行所需的最小依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -98,6 +106,7 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
|
||||
log.Printf("[DEBUG] chat routing chat=%s route=%s reason=%s",
|
||||
flowState.ConversationID, decision.Route, decision.Reason)
|
||||
flowState.AllowReorder = resolveAllowReorder(input.UserInput, decision.AllowReorder)
|
||||
|
||||
// 3. 按路由决策推进。
|
||||
switch decision.Route {
|
||||
@@ -161,14 +170,89 @@ func handleRouteExecute(
|
||||
// 清空旧 PlanSteps 并设 PhaseExecuting,避免上一次任务残留的步骤被 HasPlan() 误判。
|
||||
flowState.StartDirectExecute()
|
||||
|
||||
// 安全兜底:只有真正持有 task_class_ids 时才开粗排。
|
||||
// 1. 默认不走粗排与粗排后微调,避免沿用上轮遗留标记。
|
||||
// 2. 只有 route 判定为“需要粗排”且确实有 task_class_ids 时,才打开粗排开关。
|
||||
// 3. 粗排后是否立即进入微调,完全由路由决策显式标记控制。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if decision.NeedsRoughBuild && len(flowState.TaskClassIDs) > 0 {
|
||||
flowState.NeedsRoughBuild = true
|
||||
flowState.NeedsRefineAfterRoughBuild = decision.NeedsRefineAfterRoughBuild
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAllowReorder 统一计算“本轮是否允许打乱顺序”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 后端先做显式语义判定:用户明确允许/明确禁止时,直接以后端判定为准;
|
||||
// 2. 若后端未识别到显式语义,再回退到路由模型的 allow_reorder 字段;
|
||||
// 3. 默认返回 false,确保“保持顺序”是系统默认行为。
|
||||
func resolveAllowReorder(userInput string, modelAllowReorder bool) bool {
|
||||
switch detectReorderPreference(userInput) {
|
||||
case reorderAllow:
|
||||
return true
|
||||
case reorderDisallow:
|
||||
return false
|
||||
default:
|
||||
return modelAllowReorder
|
||||
}
|
||||
}
|
||||
|
||||
// detectReorderPreference 识别用户是否“明确授权打乱顺序”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责关键词级别的显式意图识别,不做复杂语义推理;
|
||||
// 2. 若同时命中“允许”与“禁止”,优先按“禁止”处理,避免误放开顺序约束;
|
||||
// 3. 未命中显式表达时返回 unknown,交给上层兜底策略。
|
||||
func detectReorderPreference(userInput string) reorderPreference {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
disallowPhrases := []string{
|
||||
"不要打乱顺序",
|
||||
"不允许打乱顺序",
|
||||
"保持顺序",
|
||||
"顺序不变",
|
||||
"按原顺序",
|
||||
"不要乱序",
|
||||
"别打乱",
|
||||
}
|
||||
if containsAnyPhrase(text, disallowPhrases) {
|
||||
return reorderDisallow
|
||||
}
|
||||
|
||||
allowPhrases := []string{
|
||||
"可以打乱顺序",
|
||||
"允许打乱顺序",
|
||||
"顺序不重要",
|
||||
"顺序无所谓",
|
||||
"顺序不限",
|
||||
"允许乱序",
|
||||
"可以乱序",
|
||||
"允许重排顺序",
|
||||
"reorder is fine",
|
||||
"any order",
|
||||
}
|
||||
if containsAnyPhrase(text, allowPhrases) {
|
||||
return reorderAllow
|
||||
}
|
||||
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
func containsAnyPhrase(text string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(text, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleDeepAnswer 处理复杂问答:推送过渡语 → 原地开 thinking 再调一次 LLM → 输出深度回答。
|
||||
func handleDeepAnswer(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
executeStatusBlockID = "execute.status"
|
||||
executeSpeakBlockID = "execute.speak"
|
||||
executePinnedKey = "execution_context"
|
||||
toolMinContextSwitch = "min_context_switch"
|
||||
|
||||
// maxConsecutiveCorrections 是 Execute 节点连续修正次数上限。
|
||||
// 超过此阈值后终止执行,防止 LLM 陷入无限修正循环。
|
||||
@@ -102,6 +103,14 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
return executePendingTool(ctx, runtimeState, conversationContext, input.ToolRegistry, input.ScheduleState, input.SchedulePersistor, input.OriginalScheduleState, emitter)
|
||||
}
|
||||
|
||||
// 1.6. 顺序守卫基线初始化:
|
||||
// 1) 仅在未授权打乱顺序时记录 suggested 顺序基线;
|
||||
// 2) 只在基线为空时初始化,避免执行循环中反复覆盖;
|
||||
// 3) 后续由 order_guard 节点基于该基线做相对顺序校验。
|
||||
if !flowState.AllowReorder && len(flowState.SuggestedOrderBaseline) == 0 {
|
||||
flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState)
|
||||
}
|
||||
|
||||
// 2. 推送执行阶段状态,让前端知道当前进度。
|
||||
if flowState.HasCurrentPlanStep() {
|
||||
// 有 plan:显示步骤进度。
|
||||
@@ -592,6 +601,54 @@ func executeToolCall(
|
||||
}
|
||||
|
||||
// 2. 执行工具。
|
||||
// 顺序护栏:未授权打乱顺序时,拒绝执行 min_context_switch,并写回工具观察结果。
|
||||
if shouldBlockMinContextSwitch(flowState, toolName) {
|
||||
blockedResult := "已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。"
|
||||
log.Printf(
|
||||
"[WARN] execute tool blocked chat=%s round=%d tool=%s allow_reorder=%v",
|
||||
flowState.ConversationID,
|
||||
flowState.RoundUsed,
|
||||
toolName,
|
||||
flowState.AllowReorder,
|
||||
)
|
||||
_ = emitter.EmitStatus(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
"tool_blocked",
|
||||
blockedResult,
|
||||
false,
|
||||
)
|
||||
|
||||
toolCallID := uuid.NewString()
|
||||
argsJSON := "{}"
|
||||
if toolCall.Arguments != nil {
|
||||
if raw, marshalErr := json.Marshal(toolCall.Arguments); marshalErr == nil {
|
||||
argsJSON = string(raw)
|
||||
}
|
||||
}
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
ID: toolCallID,
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: toolName,
|
||||
Arguments: argsJSON,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: blockedResult,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
@@ -646,6 +703,19 @@ func executeToolCall(
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldBlockMinContextSwitch 判断是否要拦截 min_context_switch 工具。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 仅当工具名为 min_context_switch 且未授权打乱顺序时返回 true;
|
||||
// 2. 其余场景统一放行;
|
||||
// 3. nil flowState 视为未命中拦截条件,避免因状态缺失导致误阻断。
|
||||
func shouldBlockMinContextSwitch(flowState *newagentmodel.CommonState, toolName string) bool {
|
||||
if flowState == nil {
|
||||
return false
|
||||
}
|
||||
return !flowState.AllowReorder && strings.EqualFold(strings.TrimSpace(toolName), toolMinContextSwitch)
|
||||
}
|
||||
|
||||
// executePendingTool 执行用户已确认的写工具。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
207
backend/newAgent/node/order_guard.go
Normal file
207
backend/newAgent/node/order_guard.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package newagentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
orderGuardStageName = "order_guard"
|
||||
orderGuardStatusBlock = "order_guard.status"
|
||||
)
|
||||
|
||||
type suggestedOrderItem struct {
|
||||
StateID int
|
||||
Day int
|
||||
SlotStart int
|
||||
SlotEnd int
|
||||
}
|
||||
|
||||
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
|
||||
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
|
||||
// 3. 校验失败只写入统一终止结果(Abort),由 Deliver 节点统一收口文案。
|
||||
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("order_guard node: state is nil")
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return fmt.Errorf("order_guard node: flow state is nil")
|
||||
}
|
||||
// 1. 用户明确授权可打乱顺序时,顺序守卫节点直接放行。
|
||||
if flowState.AllowReorder {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 读取当前 ScheduleState,提取 suggested 任务的“时间顺序快照”。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("order_guard node: load schedule state failed: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return nil
|
||||
}
|
||||
currentOrder := buildSuggestedOrderSnapshot(scheduleState)
|
||||
|
||||
// 3. 基线为空时,仅初始化基线并放行,避免第一次进入守卫就误判。
|
||||
if len(flowState.SuggestedOrderBaseline) == 0 {
|
||||
flowState.SuggestedOrderBaseline = append([]int(nil), currentOrder...)
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_initialized",
|
||||
"已记录本轮建议任务顺序基线,继续交付当前结果。",
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 基线存在时做逆序检测;一旦发现逆序,立即终止本轮自动微调。
|
||||
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
|
||||
if !violated {
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_passed",
|
||||
"顺序守卫校验通过,保持原有相对顺序。",
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
userMessage := "检测到当前方案打乱了原有建议任务顺序,本轮先停止自动微调。若你确认可以打乱顺序,请明确说明“允许打乱顺序”。"
|
||||
flowState.Abort(
|
||||
orderGuardStageName,
|
||||
"relative_order_violation",
|
||||
userMessage,
|
||||
fmt.Sprintf("baseline=%v current=%v detail=%s", flowState.SuggestedOrderBaseline, currentOrder, detail),
|
||||
)
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_failed",
|
||||
userMessage,
|
||||
true,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSuggestedOrderSnapshot 生成 suggested 任务的相对顺序快照(按时间坐标排序)。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只关心 suggested 任务,因为顺序守卫目标是约束“本轮建议层”的相对次序;
|
||||
// 2. 多 slot 任务取“最早 slot”作为排序锚点,保证排序键稳定;
|
||||
// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。
|
||||
func buildSuggestedOrderSnapshot(state *newagenttools.ScheduleState) []int {
|
||||
if state == nil || len(state.Tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]suggestedOrderItem, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !newagenttools.IsSuggestedTask(task) || len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
day, slotStart, slotEnd := earliestTaskSlot(task.Slots)
|
||||
items = append(items, suggestedOrderItem{
|
||||
StateID: task.StateID,
|
||||
Day: day,
|
||||
SlotStart: slotStart,
|
||||
SlotEnd: slotEnd,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Day != items[j].Day {
|
||||
return items[i].Day < items[j].Day
|
||||
}
|
||||
if items[i].SlotStart != items[j].SlotStart {
|
||||
return items[i].SlotStart < items[j].SlotStart
|
||||
}
|
||||
if items[i].SlotEnd != items[j].SlotEnd {
|
||||
return items[i].SlotEnd < items[j].SlotEnd
|
||||
}
|
||||
return items[i].StateID < items[j].StateID
|
||||
})
|
||||
|
||||
order := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
order = append(order, item.StateID)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func earliestTaskSlot(slots []newagenttools.TaskSlot) (day int, slotStart int, slotEnd int) {
|
||||
if len(slots) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
best := slots[0]
|
||||
for i := 1; i < len(slots); i++ {
|
||||
current := slots[i]
|
||||
if current.Day < best.Day {
|
||||
best = current
|
||||
continue
|
||||
}
|
||||
if current.Day == best.Day && current.SlotStart < best.SlotStart {
|
||||
best = current
|
||||
continue
|
||||
}
|
||||
if current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd {
|
||||
best = current
|
||||
}
|
||||
}
|
||||
return best.Day, best.SlotStart, best.SlotEnd
|
||||
}
|
||||
|
||||
// detectRelativeOrderViolation 检查 current 是否破坏 baseline 的相对顺序。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 仅比较 baseline 与 current 的交集任务,避免新增/删除任务引发误报;
|
||||
// 2. 一旦出现 rank 逆序即判定为 violation;
|
||||
// 3. detail 只用于内部排查,不直接给用户。
|
||||
func detectRelativeOrderViolation(baseline []int, current []int) (bool, string) {
|
||||
if len(baseline) == 0 || len(current) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rankByID := make(map[int]int, len(baseline))
|
||||
for idx, id := range baseline {
|
||||
rankByID[id] = idx
|
||||
}
|
||||
|
||||
filtered := make([]int, 0, len(current))
|
||||
for _, id := range current {
|
||||
if _, ok := rankByID[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
if len(filtered) < 2 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
prevID := filtered[0]
|
||||
prevRank := rankByID[prevID]
|
||||
for i := 1; i < len(filtered); i++ {
|
||||
id := filtered[i]
|
||||
rank := rankByID[id]
|
||||
if rank < prevRank {
|
||||
return true, strings.TrimSpace(fmt.Sprintf(
|
||||
"reverse pair detected: prev_id=%d prev_rank=%d current_id=%d current_rank=%d",
|
||||
prevID, prevRank, id, rank,
|
||||
))
|
||||
}
|
||||
prevID = id
|
||||
prevRank = rank
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -34,7 +34,9 @@ type roughBuildApplyStats struct {
|
||||
// 4. 调用 RoughBuildFunc 拿到粗排结果([]RoughBuildPlacement);
|
||||
// 5. 把粗排结果写入 ScheduleState,把已落位任务标记为 suggested;
|
||||
// 6. 若粗排后仍存在真实 pending,则写入正式 abort 结果并结束本轮;
|
||||
// 7. 否则推送"粗排完成"状态,清除 NeedsRoughBuild 标记,进入执行阶段。
|
||||
// 7. 否则按“是否需要粗排后立即微调”分流:
|
||||
// - 无明确微调诉求:直接 Done -> Deliver;
|
||||
// - 有明确微调诉求:进入 Execute。
|
||||
func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("rough build node: state is nil")
|
||||
@@ -63,6 +65,7 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
// 没有任务类 ID 时静默跳过粗排,直接进入执行阶段。
|
||||
flowState.Phase = newagentmodel.PhaseExecuting
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,16 +136,29 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. 推送完成状态。
|
||||
// 8. 计算是否需要“粗排后立即微调”。
|
||||
//
|
||||
// 1. 只在“无计划直执行”链路下应用该止血分流;
|
||||
// 2. 有计划链路依旧进入 execute,避免改变既有 plan->execute 语义;
|
||||
// 3. chat 路由明确标记 needs_refine_after_rough_build=true 时才进微调。
|
||||
shouldRefineAfterRoughBuild := flowState.HasPlan() || flowState.NeedsRefineAfterRoughBuild
|
||||
|
||||
// 9. 推送完成状态(区分“继续微调”与“直接收口”两种路径)。
|
||||
doneStatus := "rough_build_done"
|
||||
doneMessage := fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排,进入微调阶段。", len(placements))
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
doneStatus = "rough_build_done_no_refine"
|
||||
doneMessage = fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排。本轮按默认策略先结束;如需优化,请继续告诉我你的偏好。", len(placements))
|
||||
}
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_done",
|
||||
fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排,进入微调阶段。", len(placements)),
|
||||
doneStatus,
|
||||
doneMessage,
|
||||
false,
|
||||
)
|
||||
|
||||
// 9. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入查看和微调。
|
||||
// 10. 把粗排完成信息写入 pinned context,让后续节点能拿到一致事实。
|
||||
|
||||
// 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。
|
||||
idParts := make([]string, len(taskClassIDs))
|
||||
@@ -154,18 +170,31 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
pinnedContent := fmt.Sprintf(
|
||||
"后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
|
||||
"这些预排任务已标记为 suggested,表示“可继续优化的建议落位”,不是待补排任务。\n"+
|
||||
"请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。\n"+
|
||||
"本轮不需要再调用 place,也无需再次触发粗排。",
|
||||
idStr, len(placements),
|
||||
)
|
||||
if shouldRefineAfterRoughBuild {
|
||||
pinnedContent += "\n请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。"
|
||||
} else {
|
||||
pinnedContent += "\n当前未收到明确微调偏好,流程将先收口;如需进一步优化,请基于本次结果提出调整要求。"
|
||||
}
|
||||
st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||
Key: "rough_build_done",
|
||||
Title: "粗排已完成",
|
||||
Content: pinnedContent,
|
||||
})
|
||||
|
||||
// 10. 清除标记,进入执行阶段。
|
||||
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
|
||||
//
|
||||
// 1. 无明确微调诉求:直接标记 completed,graph 会路由到 deliver;
|
||||
// 2. 有明确微调诉求:进入 execute 节点继续工具微调;
|
||||
// 3. 无论哪条路径,都要重置粗排相关标记,避免污染后续轮次。
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
flowState.Done()
|
||||
return nil
|
||||
}
|
||||
flowState.Phase = newagentmodel.PhaseExecuting
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,29 @@ const chatRoutingSystemPrompt = `
|
||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。speak 写确认语。
|
||||
|
||||
粗排判断:当用户意图包含"批量安排/排课/把任务类排进日程",且上下文中有任务类 ID 时,设置 needs_rough_build=true。
|
||||
粗排后微调判断:
|
||||
- 仅当 needs_rough_build=true 时才判断 needs_refine_after_rough_build。
|
||||
- 若用户明确提出优化目标/偏好(如"尽量均衡""周三别太满""某门课往后挪"),设 needs_refine_after_rough_build=true。
|
||||
- 若用户只要求"先排进去/给初稿",未提出微调目标,设 needs_refine_after_rough_build=false。
|
||||
顺序授权判断:
|
||||
- allow_reorder 仅在用户明确说明“允许打乱顺序/顺序不重要”时才为 true。
|
||||
- 用户明确要求“保持顺序/不要打乱”时必须为 false。
|
||||
- 若用户未明确提及顺序,一律为 false。
|
||||
|
||||
输出协议(严格 JSON):
|
||||
{"route":"direct_reply / execute / deep_answer / plan","speak":"给用户看的话","needs_rough_build":false,"reason":"简短判断依据"}
|
||||
{"route":"direct_reply / execute / deep_answer / plan","speak":"给用户看的话","needs_rough_build":false,"needs_refine_after_rough_build":false,"allow_reorder":false,"reason":"简短判断依据"}
|
||||
|
||||
合法示例:
|
||||
|
||||
{"route":"direct_reply","speak":"你好!我是 SmartFlow 助手,有什么可以帮你的?","reason":"用户打招呼"}
|
||||
|
||||
{"route":"execute","speak":"好的,我来帮你看看今天的安排。","reason":"需要调用工具查询日程","needs_rough_build":false}
|
||||
{"route":"execute","speak":"好的,我来帮你看看今天的安排。","reason":"需要调用工具查询日程","needs_rough_build":false,"needs_refine_after_rough_build":false,"allow_reorder":false}
|
||||
|
||||
{"route":"execute","speak":"好的,我来帮你排课。","reason":"批量排课需求,有任务类 ID","needs_rough_build":true}
|
||||
{"route":"execute","speak":"好的,我来帮你排课。","reason":"批量排课需求,有任务类 ID,未给微调偏好","needs_rough_build":true,"needs_refine_after_rough_build":false,"allow_reorder":false}
|
||||
|
||||
{"route":"execute","speak":"好的,我来帮你排课并按你的偏好做微调。","reason":"批量排课需求,有任务类 ID,且给出明确微调偏好","needs_rough_build":true,"needs_refine_after_rough_build":true,"allow_reorder":false}
|
||||
|
||||
{"route":"execute","speak":"好的,我按你的要求重排。","reason":"用户明确允许打乱顺序","needs_rough_build":false,"needs_refine_after_rough_build":false,"allow_reorder":true}
|
||||
|
||||
{"route":"deep_answer","speak":"这是个好问题,让我仔细想想。","reason":"需要深度分析但不需要工具"}
|
||||
|
||||
@@ -65,6 +77,9 @@ func BuildChatRoutingUserPrompt(ctx *newagentmodel.ConversationContext, userInpu
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("请判断用户本轮意图的复杂度,并选择最合适的路由。\n")
|
||||
sb.WriteString("若 route=execute 且 needs_rough_build=true,请同时判断 needs_refine_after_rough_build:")
|
||||
sb.WriteString("只有用户明确提出微调目标时才为 true。\n")
|
||||
sb.WriteString("请同时输出 allow_reorder:只有用户明确授权打乱顺序时才为 true,默认 false。\n")
|
||||
|
||||
// 注入任务类上下文(供粗排判断参考)。
|
||||
if state != nil && len(state.TaskClassIDs) > 0 {
|
||||
|
||||
@@ -15,16 +15,21 @@ const executeSystemPromptWithPlan = `
|
||||
1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。
|
||||
2. 可调用读工具补充事实,再决定下一步。
|
||||
3. 需要写操作时输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
4. 若用户给出了“二次微调方向”(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。
|
||||
5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要跳到其他 plan 步骤,不要越级执行。
|
||||
2. 不要伪造工具结果。
|
||||
3. 如果上下文明确“粗排已完成/rough_build_done”,不要把任务当成未排入,不要重新逐个手动 place。
|
||||
4. 不要连续重复同类查询而没有推进;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
5. list_tasks 的 status 只允许单值:all / existing / suggested / pending。禁止使用 "existing,suggested" 这类拼接值。
|
||||
6. 若工具结果与已知事实明显冲突(如无写操作却从“有任务”变成“0任务”),先自我纠错并重查一次,不要直接 ask_user。
|
||||
7. 不要连续两轮调用“同一读工具 + 等价 arguments”;若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
8. list_tasks.category 只接受任务类名称,不接受 task_class_ids(如 "1,2,3")。
|
||||
4. 如果上下文明确“当前未收到明确微调偏好/本轮先收口”,不要继续微调,直接输出 action=done。
|
||||
5. 不要连续重复同类查询而没有推进;连续两轮同类读查询后,必须转入执行、ask_user,或明确阻塞原因。
|
||||
6. list_tasks 的 status 只允许单值:all / existing / suggested / pending。禁止使用 "existing,suggested" 这类拼接值。
|
||||
7. 若工具结果与已知事实明显冲突(如无写操作却从“有任务”变成“0任务”),先自我纠错并重查一次,不要直接 ask_user。
|
||||
8. 不要连续两轮调用“同一读工具 + 等价 arguments”;若上一轮已成功返回,下一轮必须换工具或进入 confirm。
|
||||
9. list_tasks.category 只接受任务类名称,不接受 task_class_ids(如 "1,2,3")。
|
||||
10. 不要忽略用户最新补充的微调方向;若与旧目标冲突,以最新用户要求为准。
|
||||
11. 若当前顺序策略是“默认保持顺序”,禁止调用 min_context_switch。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
@@ -41,12 +46,15 @@ const executeSystemPromptReAct = `
|
||||
阶段事实(强约束):
|
||||
1. 若上下文给出“粗排已完成/rough_build_done”,表示目标任务类已经进入 suggested/existing,不是待排入状态。
|
||||
2. 当前阶段目标是“微调”,不是“重新粗排”。
|
||||
3. 若上下文明确“当前未收到明确微调偏好/本轮先收口”,应直接结束而不是继续优化循环。
|
||||
4. 若用户提出了二次微调方向,本轮优先目标就是满足该方向。
|
||||
|
||||
你可以做什么:
|
||||
1. 你可以基于科学排程原则(负载均衡、学习连贯性、冲突最小化)对 suggested 做微调。
|
||||
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)。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
|
||||
你不要做什么:
|
||||
1. 不要假设任务还没排进去,然后改成逐个手动 place。
|
||||
@@ -56,6 +64,9 @@ 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 做无目标微调。
|
||||
9. 若用户明确了微调方向,不要只做“局部看起来更空”的随机调整;每次改动都要能对应到该方向。
|
||||
10. 若顺序策略为“保持顺序”,禁止调用 min_context_switch。
|
||||
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
@@ -353,6 +364,9 @@ func buildExecuteStrictJSONUserPrompt() string {
|
||||
- list_tasks.arguments.category 仅接受任务类名称,不要传 task_class_ids(如 "1,2,3")
|
||||
- 若读工具结果与已知事实明显冲突,先修正参数并重查一次,再决定是否 ask_user
|
||||
- 不要连续两轮调用“同一读工具 + 等价 arguments”;若上一轮已成功返回,下一轮必须换工具或进入 confirm
|
||||
- 若用户本轮给了二次微调方向,优先满足该方向,再考虑通用均衡优化
|
||||
- 若上下文已明确“当前未收到微调偏好,本轮先收口”,请直接输出 action=done
|
||||
- 仅当顺序策略明确允许打乱顺序时,才可以调用 min_context_switch
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,13 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段约束:粗排已完成,本轮只微调 suggested;existing 仅作已安排事实参考,不做 move/batch_move。")
|
||||
}
|
||||
if state != nil {
|
||||
if state.AllowReorder {
|
||||
lines = append(lines, "- 顺序策略:用户已明确允许打乱顺序,可在必要时使用 min_context_switch。")
|
||||
} else {
|
||||
lines = append(lines, "- 顺序策略:默认保持 suggested 相对顺序,禁止调用 min_context_switch。")
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容上层传入的执行指令;若为空则使用固定收口指令。
|
||||
instruction := strings.TrimSpace(runtimeUserPrompt)
|
||||
@@ -236,8 +243,6 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return returnType, "[35]第一章随机事件与概率 | 状态:已预排(suggested) | 占用时段:第3天第5-6节"
|
||||
case "find_first_free":
|
||||
return returnType, "首个可用位置:第5天第1-2节(可直接放置)| 当日负载:总占6/12..."
|
||||
case "find_free":
|
||||
return returnType, "兼容别名,返回同 find_first_free。"
|
||||
case "query_range":
|
||||
return returnType, "第5天第3-6节:第3节空、第4节空..."
|
||||
case "place":
|
||||
@@ -248,6 +253,8 @@ func renderExecuteToolReturnHint(toolName string) (returnType string, sample str
|
||||
return returnType, "交换完成:[35]... ↔ [36]..."
|
||||
case "batch_move":
|
||||
return returnType, "批量移动完成,2个任务全部成功。"
|
||||
case "min_context_switch":
|
||||
return returnType, "最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。"
|
||||
case "unplace":
|
||||
return returnType, "已将 [35]... 移除,恢复为待安排状态。"
|
||||
default:
|
||||
|
||||
@@ -255,15 +255,14 @@ DB 记录:
|
||||
|
||||
按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息。
|
||||
|
||||
兼容说明:
|
||||
- `find_free` 仍保留为兼容别名,行为与 `find_first_free` 完全一致。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| duration | int | 是 | 需要的连续时段数 |
|
||||
| day | int | 否 | 限定某天,不传则搜索全部天 |
|
||||
| day | int | 否 | 限定某天;与 `day_start/day_end` 互斥 |
|
||||
| day_start | int | 否 | 搜索起始天(闭区间) |
|
||||
| day_end | int | 否 | 搜索结束天(闭区间) |
|
||||
|
||||
**返回示例:**
|
||||
|
||||
@@ -543,6 +542,42 @@ DB 记录:
|
||||
|
||||
---
|
||||
|
||||
### 5.6 min_context_switch
|
||||
|
||||
在给定任务集合内重排 suggested 任务,尽量把同类任务排成连续块,以减少上下文切换。
|
||||
|
||||
使用约束:
|
||||
- 仅在用户明确说明“允许打乱顺序”时调用。
|
||||
- 仅支持 suggested 且已落位任务。
|
||||
- 工具只在传入集合内部重排,不会主动改动集合外任务。
|
||||
|
||||
**入参:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| task_ids | array[int] | 是 | 参与重排的任务 ID 列表(至少 2 个) |
|
||||
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
|
||||
|
||||
**成功返回:**
|
||||
|
||||
```
|
||||
最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
|
||||
本次调整:
|
||||
[35]概率第一章:第3天(星期3)第1-2节 -> 第2天(星期2)第5-6节
|
||||
[41]概率第二章:第4天(星期4)第1-2节 -> 第3天(星期3)第1-2节
|
||||
第2天当前占用:...
|
||||
第3天当前占用:...
|
||||
第4天当前占用:...
|
||||
```
|
||||
|
||||
**失败返回(未授权顺序重排时应由上层拦截):**
|
||||
|
||||
```
|
||||
已拒绝执行 min_context_switch:当前未授权打乱顺序。如需使用该工具,请先由用户明确说明“允许打乱顺序”。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 公共规则
|
||||
|
||||
### 冲突检测
|
||||
@@ -556,8 +591,8 @@ DB 记录:
|
||||
|
||||
### 状态约束
|
||||
- pending 任务只能 place,不能 move / swap / unplace
|
||||
- suggested 任务可以 move / swap / unplace
|
||||
- existing 任务不能 move / batch_move(仅作已安排事实层)
|
||||
- suggested 任务可以 move / swap / unplace / min_context_switch
|
||||
- existing 任务不能 move / batch_move / min_context_switch(仅作已安排事实层)
|
||||
- 状态不符时返回明确错误信息
|
||||
|
||||
### 返回格式
|
||||
@@ -574,7 +609,7 @@ DB 记录:
|
||||
### 嵌入任务规则
|
||||
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
|
||||
- 嵌入任务占位时不触发冲突检测(与宿主共存)
|
||||
- `find_first_free` 返回首个命中位,并附当日详细负载;`find_free` 为兼容别名
|
||||
- `find_first_free` 返回首个命中位,并附当日详细负载
|
||||
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
|
||||
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
|
||||
|
||||
|
||||
@@ -49,6 +49,44 @@ func argsStringPtr(args map[string]any, key string) *string {
|
||||
return &v
|
||||
}
|
||||
|
||||
// argsIntSlice 从 map 中提取 int 数组,支持 []any / []int / []float64。
|
||||
func argsIntSlice(args map[string]any, key string) ([]int, bool) {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch arr := v.(type) {
|
||||
case []int:
|
||||
if len(arr) == 0 {
|
||||
return []int{}, true
|
||||
}
|
||||
result := make([]int, len(arr))
|
||||
copy(result, arr)
|
||||
return result, true
|
||||
case []float64:
|
||||
result := make([]int, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
result = append(result, int(item))
|
||||
}
|
||||
return result, true
|
||||
case []any:
|
||||
result := make([]int, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
switch n := item.(type) {
|
||||
case float64:
|
||||
result = append(result, int(n))
|
||||
case int:
|
||||
result = append(result, n)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// argsMoveList 从 map 中提取 batch_move 的 moves 数组。
|
||||
func argsMoveList(args map[string]any) ([]MoveRequest, error) {
|
||||
v, ok := args["moves"]
|
||||
|
||||
458
backend/newAgent/tools/compound_tools.go
Normal file
458
backend/newAgent/tools/compound_tools.go
Normal file
@@ -0,0 +1,458 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type minContextSnapshot struct {
|
||||
StateID int
|
||||
Name string
|
||||
ContextTag string
|
||||
Slot TaskSlot
|
||||
}
|
||||
|
||||
type minContextPlanTask struct {
|
||||
StateID int
|
||||
Name string
|
||||
ContextTag string
|
||||
GroupingKey string
|
||||
OriginRank int
|
||||
Span int
|
||||
}
|
||||
|
||||
type minContextPlanGroup struct {
|
||||
Key string
|
||||
MinRank int
|
||||
Tasks []minContextPlanTask
|
||||
}
|
||||
|
||||
// MinContextSwitch 在给定任务集合内重排 suggested 任务,减少上下文切换次数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
|
||||
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
|
||||
// 3. 采用原子提交:任一校验失败则整体不生效。
|
||||
//
|
||||
// 并行迁移说明:
|
||||
// 1. 这里没有直接复用 backend/logic 的同名规划器;
|
||||
// 2. 原因是 logic 包依赖链会回流到 newAgent/tools,直接引用会产生 import cycle;
|
||||
// 3. 因此在 tools 层内置一份最小可用的确定性规划逻辑,先保证线上可用,再在后续结构迁移时抽公共层。
|
||||
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
if state == nil {
|
||||
return "减少上下文切换失败:日程状态为空。"
|
||||
}
|
||||
|
||||
normalizedIDs := uniquePositiveInts(taskIDs)
|
||||
if len(normalizedIDs) < 2 {
|
||||
return "减少上下文切换失败:task_ids 至少需要 2 个有效任务 ID。"
|
||||
}
|
||||
|
||||
// 1. 构建规划输入并做前置校验。
|
||||
plannerTasks := make([]minContextPlanTask, 0, len(normalizedIDs))
|
||||
plannerSlots := make([]TaskSlot, 0, len(normalizedIDs))
|
||||
beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs))
|
||||
excludeIDs := make([]int, 0, len(normalizedIDs))
|
||||
|
||||
for rank, taskID := range normalizedIDs {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务ID %d 不存在。", taskID)
|
||||
}
|
||||
if !IsSuggestedTask(*task) {
|
||||
return fmt.Sprintf("减少上下文切换失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具。", task.StateID, task.Name)
|
||||
}
|
||||
if err := checkLocked(*task); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
if len(task.Slots) != 1 {
|
||||
return fmt.Sprintf("减少上下文切换失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态。", task.StateID, task.Name, len(task.Slots))
|
||||
}
|
||||
|
||||
slot := task.Slots[0]
|
||||
if err := validateDay(state, slot.Day); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:[%d]%s 的时段非法:%s。", task.StateID, task.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:[%d]%s 的节次非法:%s。", task.StateID, task.Name, err.Error())
|
||||
}
|
||||
|
||||
contextTag := normalizeMinContextTag(*task)
|
||||
beforeByID[task.StateID] = minContextSnapshot{
|
||||
StateID: task.StateID,
|
||||
Name: task.Name,
|
||||
ContextTag: contextTag,
|
||||
Slot: slot,
|
||||
}
|
||||
excludeIDs = append(excludeIDs, task.StateID)
|
||||
plannerTasks = append(plannerTasks, minContextPlanTask{
|
||||
StateID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
ContextTag: contextTag,
|
||||
OriginRank: rank + 1,
|
||||
Span: slot.SlotEnd - slot.SlotStart + 1,
|
||||
})
|
||||
plannerSlots = append(plannerSlots, slot)
|
||||
}
|
||||
|
||||
plannedSlots, err := planMinContextAssignments(plannerTasks, plannerSlots)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
|
||||
for taskID, before := range beforeByID {
|
||||
targetSlot, ok := plannedSlots[taskID]
|
||||
if !ok {
|
||||
return "减少上下文切换失败:规划结果不完整。"
|
||||
}
|
||||
if err := validateDay(state, targetSlot.Day); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(targetSlot.SlotStart, targetSlot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if conflict := findConflict(state, targetSlot.Day, targetSlot.SlotStart, targetSlot.SlotEnd, excludeIDs...); conflict != nil {
|
||||
return fmt.Sprintf(
|
||||
"减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, targetSlot.Day, targetSlot.SlotStart, targetSlot.SlotEnd),
|
||||
conflict.StateID,
|
||||
conflict.Name,
|
||||
)
|
||||
}
|
||||
afterByID[before.StateID] = minContextSnapshot{
|
||||
StateID: before.StateID,
|
||||
Name: before.Name,
|
||||
ContextTag: before.ContextTag,
|
||||
Slot: targetSlot,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 全量通过后再原子提交,避免中间态污染。
|
||||
clone := state.Clone()
|
||||
for taskID, after := range afterByID {
|
||||
task := clone.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务ID %d 在提交阶段不存在。", taskID)
|
||||
}
|
||||
task.Slots = []TaskSlot{after.Slot}
|
||||
}
|
||||
state.Tasks = clone.Tasks
|
||||
|
||||
beforeOrdered := sortMinContextSnapshots(beforeByID)
|
||||
afterOrdered := sortMinContextSnapshots(afterByID)
|
||||
beforeSwitches := countMinContextSwitches(beforeOrdered)
|
||||
afterSwitches := countMinContextSwitches(afterOrdered)
|
||||
|
||||
changedLines := make([]string, 0, len(beforeOrdered))
|
||||
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
|
||||
for _, before := range beforeOrdered {
|
||||
after := afterByID[before.StateID]
|
||||
if sameTaskSlot(before.Slot, after.Slot) {
|
||||
continue
|
||||
}
|
||||
changedLines = append(changedLines, fmt.Sprintf(
|
||||
" [%d]%s:%s -> %s",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
))
|
||||
affectedDays[before.Slot.Day] = true
|
||||
affectedDays[after.Slot.Day] = true
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n",
|
||||
len(beforeByID), beforeSwitches, afterSwitches,
|
||||
))
|
||||
if len(changedLines) == 0 {
|
||||
sb.WriteString("当前任务顺序已是较优结果,无需调整。")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
sb.WriteString("本次调整:\n")
|
||||
for _, line := range changedLines {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
for _, day := range sortedKeys(affectedDays) {
|
||||
sb.WriteString(formatDayOccupancy(state, day) + "\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func parseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
|
||||
if ids, ok := argsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
|
||||
return ids, nil
|
||||
}
|
||||
if id, ok := argsInt(args, "task_id"); ok {
|
||||
return []int{id}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("缺少必填参数 task_ids(兼容单值 task_id)")
|
||||
}
|
||||
|
||||
func planMinContextAssignments(tasks []minContextPlanTask, slots []TaskSlot) (map[int]TaskSlot, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return nil, fmt.Errorf("可用坑位为空")
|
||||
}
|
||||
if len(slots) < len(tasks) {
|
||||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(tasks), len(slots))
|
||||
}
|
||||
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
if tasks[i].OriginRank != tasks[j].OriginRank {
|
||||
return tasks[i].OriginRank < tasks[j].OriginRank
|
||||
}
|
||||
return tasks[i].StateID < tasks[j].StateID
|
||||
})
|
||||
for i := range tasks {
|
||||
tasks[i].GroupingKey = normalizeMinContextGroupingKey(tasks[i].ContextTag)
|
||||
}
|
||||
applyMinContextNameFallback(tasks)
|
||||
|
||||
groupMap := make(map[string]*minContextPlanGroup, len(tasks))
|
||||
groupOrder := make([]string, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
group, exists := groupMap[task.GroupingKey]
|
||||
if !exists {
|
||||
group = &minContextPlanGroup{
|
||||
Key: task.GroupingKey,
|
||||
MinRank: task.OriginRank,
|
||||
}
|
||||
groupMap[task.GroupingKey] = group
|
||||
groupOrder = append(groupOrder, task.GroupingKey)
|
||||
}
|
||||
if task.OriginRank < group.MinRank {
|
||||
group.MinRank = task.OriginRank
|
||||
}
|
||||
group.Tasks = append(group.Tasks, task)
|
||||
}
|
||||
|
||||
groups := make([]minContextPlanGroup, 0, len(groupMap))
|
||||
for _, key := range groupOrder {
|
||||
group := groupMap[key]
|
||||
sort.SliceStable(group.Tasks, func(i, j int) bool {
|
||||
if group.Tasks[i].OriginRank != group.Tasks[j].OriginRank {
|
||||
return group.Tasks[i].OriginRank < group.Tasks[j].OriginRank
|
||||
}
|
||||
return group.Tasks[i].StateID < group.Tasks[j].StateID
|
||||
})
|
||||
groups = append(groups, *group)
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if len(groups[i].Tasks) != len(groups[j].Tasks) {
|
||||
return len(groups[i].Tasks) > len(groups[j].Tasks)
|
||||
}
|
||||
if groups[i].MinRank != groups[j].MinRank {
|
||||
return groups[i].MinRank < groups[j].MinRank
|
||||
}
|
||||
return groups[i].Key < groups[j].Key
|
||||
})
|
||||
|
||||
orderedTasks := make([]minContextPlanTask, 0, len(tasks))
|
||||
for _, group := range groups {
|
||||
orderedTasks = append(orderedTasks, group.Tasks...)
|
||||
}
|
||||
|
||||
sortedSlots := make([]TaskSlot, len(slots))
|
||||
copy(sortedSlots, slots)
|
||||
sort.SliceStable(sortedSlots, func(i, j int) bool {
|
||||
if sortedSlots[i].Day != sortedSlots[j].Day {
|
||||
return sortedSlots[i].Day < sortedSlots[j].Day
|
||||
}
|
||||
if sortedSlots[i].SlotStart != sortedSlots[j].SlotStart {
|
||||
return sortedSlots[i].SlotStart < sortedSlots[j].SlotStart
|
||||
}
|
||||
if sortedSlots[i].SlotEnd != sortedSlots[j].SlotEnd {
|
||||
return sortedSlots[i].SlotEnd < sortedSlots[j].SlotEnd
|
||||
}
|
||||
return i < j
|
||||
})
|
||||
|
||||
used := make([]bool, len(sortedSlots))
|
||||
result := make(map[int]TaskSlot, len(orderedTasks))
|
||||
for _, task := range orderedTasks {
|
||||
chosenIdx := -1
|
||||
for idx, slot := range sortedSlots {
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
if slot.SlotEnd-slot.SlotStart+1 != task.Span {
|
||||
continue
|
||||
}
|
||||
chosenIdx = idx
|
||||
break
|
||||
}
|
||||
if chosenIdx < 0 {
|
||||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.StateID)
|
||||
}
|
||||
used[chosenIdx] = true
|
||||
result[task.StateID] = sortedSlots[chosenIdx]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyMinContextNameFallback(tasks []minContextPlanTask) {
|
||||
distinctExplicit := make(map[string]struct{}, len(tasks))
|
||||
distinctNonCoarse := make(map[string]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
key := normalizeMinContextGroupingKey(task.GroupingKey)
|
||||
distinctExplicit[key] = struct{}{}
|
||||
if !isCoarseMinContextKey(key) {
|
||||
distinctNonCoarse[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(distinctNonCoarse) >= 2 {
|
||||
return
|
||||
}
|
||||
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
distinctInferred := make(map[string]struct{}, len(tasks))
|
||||
for i := range tasks {
|
||||
inferred := inferMinContextKeyFromTaskName(tasks[i].Name)
|
||||
if inferred == "" {
|
||||
inferred = tasks[i].GroupingKey
|
||||
}
|
||||
tasks[i].GroupingKey = inferred
|
||||
distinctInferred[inferred] = struct{}{}
|
||||
}
|
||||
if len(distinctInferred) < 2 {
|
||||
for i := range tasks {
|
||||
tasks[i].GroupingKey = normalizeMinContextGroupingKey(tasks[i].ContextTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uniquePositiveInts(values []int) []int {
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeMinContextTag(task ScheduleTask) string {
|
||||
if tag := strings.TrimSpace(task.Category); tag != "" {
|
||||
return tag
|
||||
}
|
||||
if tag := strings.TrimSpace(task.Name); tag != "" {
|
||||
return tag
|
||||
}
|
||||
return "General"
|
||||
}
|
||||
|
||||
func normalizeMinContextGroupingKey(tag string) string {
|
||||
trimmed := strings.TrimSpace(tag)
|
||||
if trimmed == "" {
|
||||
return "General"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isCoarseMinContextKey(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "", "general", "high-logic", "high_logic", "memory", "review":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inferMinContextKeyFromTaskName(name string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(name))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
subjectKeywordGroups := []struct {
|
||||
keywords []string
|
||||
groupKey string
|
||||
}{
|
||||
{
|
||||
keywords: []string{
|
||||
"概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
|
||||
"分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
|
||||
},
|
||||
groupKey: "subject:probability",
|
||||
},
|
||||
{
|
||||
keywords: []string{
|
||||
"数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
|
||||
"数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
|
||||
},
|
||||
groupKey: "subject:digital_logic",
|
||||
},
|
||||
{
|
||||
keywords: []string{
|
||||
"命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
|
||||
"图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
|
||||
},
|
||||
groupKey: "subject:discrete_math",
|
||||
},
|
||||
}
|
||||
for _, group := range subjectKeywordGroups {
|
||||
for _, keyword := range group.keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
return group.groupKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot {
|
||||
items := make([]minContextSnapshot, 0, len(snapshotByID))
|
||||
for _, item := range snapshotByID {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Slot.Day != items[j].Slot.Day {
|
||||
return items[i].Slot.Day < items[j].Slot.Day
|
||||
}
|
||||
if items[i].Slot.SlotStart != items[j].Slot.SlotStart {
|
||||
return items[i].Slot.SlotStart < items[j].Slot.SlotStart
|
||||
}
|
||||
if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd {
|
||||
return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd
|
||||
}
|
||||
return items[i].StateID < items[j].StateID
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func countMinContextSwitches(ordered []minContextSnapshot) int {
|
||||
if len(ordered) < 2 {
|
||||
return 0
|
||||
}
|
||||
switches := 0
|
||||
prevTag := strings.TrimSpace(ordered[0].ContextTag)
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
currentTag := strings.TrimSpace(ordered[i].ContextTag)
|
||||
if currentTag != prevTag {
|
||||
switches++
|
||||
}
|
||||
prevTag = currentTag
|
||||
}
|
||||
return switches
|
||||
}
|
||||
|
||||
func sameTaskSlot(a, b TaskSlot) bool {
|
||||
return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
|
||||
}
|
||||
|
||||
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
|
||||
// 条件:CanEmbed == true,用于 find_free 和 get_overview 输出可嵌入位置。
|
||||
// 条件:CanEmbed == true,用于 find_first_free 和 get_overview 输出可嵌入位置。
|
||||
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
|
||||
var result []*ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
@@ -204,9 +204,10 @@ func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
|
||||
func buildOverviewDayLine(state *ScheduleState, day int) string {
|
||||
occupied := countDayOccupied(state, day)
|
||||
tasks := getTasksOnDay(state, day)
|
||||
dayLabel := formatDayLabel(state, day)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("第%d天:占%d/12", day, occupied))
|
||||
sb.WriteString(fmt.Sprintf("%s:占%d/12", dayLabel, occupied))
|
||||
|
||||
if len(tasks) > 0 {
|
||||
sb.WriteString(" — ")
|
||||
@@ -228,9 +229,9 @@ func buildOverviewDayLine(state *ScheduleState, day int) string {
|
||||
|
||||
// buildFreeRangeLine 格式化空闲区间行。
|
||||
// 格式如:第3天 第1-6节(6时段连续空闲)
|
||||
func buildFreeRangeLine(r freeRange) string {
|
||||
func buildFreeRangeLine(state *ScheduleState, r freeRange) string {
|
||||
dur := r.slotEnd - r.slotStart + 1
|
||||
return fmt.Sprintf("第%d天 第%s(%d时段连续空闲)", r.day, formatSlotRange(r.slotStart, r.slotEnd), dur)
|
||||
return fmt.Sprintf("%s第%s(%d时段连续空闲)", formatDayLabel(state, r.day), formatSlotRange(r.slotStart, r.slotEnd), dur)
|
||||
}
|
||||
|
||||
// formatSourceName 将 source 字段转为用户可读的来源名称。
|
||||
|
||||
@@ -135,7 +135,7 @@ func QueryRange(state *ScheduleState, day int, slotStart, slotEnd *int) string {
|
||||
// 输出格式对齐 SCHEDULE_TOOLS.md 4.2 节示例。
|
||||
func queryRangeFullDay(state *ScheduleState, day int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("第%d天 全天:\n\n", day))
|
||||
sb.WriteString(fmt.Sprintf("%s 全天:\n\n", formatDayLabel(state, day)))
|
||||
|
||||
// 1. 按 6 个标准段输出(1-2, 3-4, 5-6, 7-8, 9-10, 11-12)。
|
||||
for start := 1; start <= 11; start += 2 {
|
||||
@@ -174,7 +174,7 @@ func queryRangeFullDay(state *ScheduleState, day int) string {
|
||||
// queryRangeSpecific 指定范围查询模式:逐节输出。
|
||||
func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("第%d天 第%s:\n\n", day, formatSlotRange(startSlot, endSlot)))
|
||||
sb.WriteString(fmt.Sprintf("%s第%s:\n\n", formatDayLabel(state, day), formatSlotRange(startSlot, endSlot)))
|
||||
|
||||
total := endSlot - startSlot + 1
|
||||
freeCount := 0
|
||||
@@ -199,16 +199,26 @@ func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) strin
|
||||
|
||||
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
|
||||
//
|
||||
// 参数说明:
|
||||
// 1. duration 必填,表示需要的连续时段数;
|
||||
// 2. day 选填,指定单天搜索;
|
||||
// 3. dayStart/dayEnd 选填,指定按天范围搜索(闭区间);
|
||||
// 4. day 与 dayStart/dayEnd 互斥,避免语义冲突。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 参数与旧 find_free 保持一致(duration/day);
|
||||
// 2. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
|
||||
// 3. 当前阶段按用户要求全量返回,不做文本截断。
|
||||
func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
// 1. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
|
||||
// 2. 当前阶段按用户要求全量返回,不做文本截断。
|
||||
func FindFirstFree(state *ScheduleState, duration int, day, dayStart, dayEnd *int) string {
|
||||
if duration <= 0 {
|
||||
return "查询失败:duration 必须大于 0。"
|
||||
}
|
||||
|
||||
// 1. 确定搜索范围。
|
||||
// 1. 参数互斥校验:单天搜索与范围搜索只能二选一。
|
||||
if day != nil && (dayStart != nil || dayEnd != nil) {
|
||||
return "查询失败:day 与 day_start/day_end 不能同时传入。"
|
||||
}
|
||||
|
||||
// 2. 确定搜索范围。
|
||||
days := make([]int, 0)
|
||||
if day != nil {
|
||||
if *day < 1 || *day > state.Window.TotalDays {
|
||||
@@ -216,12 +226,30 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
}
|
||||
days = append(days, *day)
|
||||
} else {
|
||||
for d := 1; d <= state.Window.TotalDays; d++ {
|
||||
startDay := 1
|
||||
endDay := state.Window.TotalDays
|
||||
if dayStart != nil {
|
||||
startDay = *dayStart
|
||||
}
|
||||
if dayEnd != nil {
|
||||
endDay = *dayEnd
|
||||
}
|
||||
if startDay < 1 || startDay > state.Window.TotalDays {
|
||||
return fmt.Sprintf("查询失败:day_start=%d 不在规划窗口范围内(1-%d)。", startDay, state.Window.TotalDays)
|
||||
}
|
||||
if endDay < 1 || endDay > state.Window.TotalDays {
|
||||
return fmt.Sprintf("查询失败:day_end=%d 不在规划窗口范围内(1-%d)。", endDay, state.Window.TotalDays)
|
||||
}
|
||||
if startDay > endDay {
|
||||
return fmt.Sprintf("查询失败:day_start=%d 不能大于 day_end=%d。", startDay, endDay)
|
||||
}
|
||||
|
||||
for d := startDay; d <= endDay; d++ {
|
||||
days = append(days, d)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按天从前往后寻找“首个可直接放置”的空位。
|
||||
// 3. 按天从前往后寻找“首个可直接放置”的空位。
|
||||
for _, d := range days {
|
||||
freeRanges := findFreeRangesOnDay(state, d)
|
||||
for _, r := range freeRanges {
|
||||
@@ -235,7 +263,7 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 若没有纯空位,再尝试首个可嵌入宿主时段。
|
||||
// 4. 若没有纯空位,再尝试首个可嵌入宿主时段。
|
||||
for _, d := range days {
|
||||
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
|
||||
if host != nil {
|
||||
@@ -243,7 +271,7 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
|
||||
// 5. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("未找到满足%d个连续时段的可用位置。\n", duration))
|
||||
sb.WriteString("各天最大连续空闲区(前10天):\n")
|
||||
@@ -261,17 +289,11 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
|
||||
maxDur = dur
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("第%d天:最大连续空闲%d节\n", d, maxDur))
|
||||
sb.WriteString(fmt.Sprintf("%s:最大连续空闲%d节\n", formatDayLabel(state, d), maxDur))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FindFree 是 find_first_free 的兼容别名。
|
||||
// 保留该入口可避免旧提示词和历史轨迹中的工具名失效。
|
||||
func FindFree(state *ScheduleState, duration int, day *int) string {
|
||||
return FindFirstFree(state, duration, day)
|
||||
}
|
||||
|
||||
// buildFindFirstFreeReport 构造首个可用位的详细报告。
|
||||
func buildFindFirstFreeReport(
|
||||
state *ScheduleState,
|
||||
@@ -284,10 +306,10 @@ func buildFindFirstFreeReport(
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
if isEmbedded && host != nil {
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s(可嵌入宿主 [%d]%s)。\n",
|
||||
day, formatSlotRange(slotStart, slotEnd), host.StateID, host.Name))
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:%s(可嵌入宿主 [%d]%s)。\n",
|
||||
formatDaySlotLabel(state, day, slotStart, slotEnd), host.StateID, host.Name))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s(可直接放置)。\n", day, formatSlotRange(slotStart, slotEnd)))
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:%s(可直接放置)。\n", formatDaySlotLabel(state, day, slotStart, slotEnd)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("匹配条件:需要%d个连续时段。\n", duration))
|
||||
|
||||
@@ -313,7 +335,7 @@ func buildFindFirstFreeReport(
|
||||
sb.WriteString(" 无连续空闲区。\n")
|
||||
} else {
|
||||
for _, r := range freeRanges {
|
||||
sb.WriteString(" - " + buildFreeRangeLine(r) + "\n")
|
||||
sb.WriteString(" - " + buildFreeRangeLine(state, r) + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
@@ -385,9 +407,10 @@ func buildTaskOnlyOverviewDayLine(state *ScheduleState, day int) string {
|
||||
taskOccupied := countDayTaskOccupied(state, day)
|
||||
courseOccupied := totalOccupied - taskOccupied
|
||||
taskEntries := collectTaskEntriesOnDay(state, day)
|
||||
dayLabel := formatDayLabel(state, day)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("第%d天:总占%d/12(课程占%d/12,任务占%d/12)", day, totalOccupied, courseOccupied, taskOccupied))
|
||||
sb.WriteString(fmt.Sprintf("%s:总占%d/12(课程占%d/12,任务占%d/12)", dayLabel, totalOccupied, courseOccupied, taskOccupied))
|
||||
if len(taskEntries) == 0 {
|
||||
sb.WriteString(" — 任务:无")
|
||||
return sb.String()
|
||||
@@ -435,7 +458,7 @@ func buildTaskOnlyOverviewList(state *ScheduleState) string {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 时段:%s\n",
|
||||
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBrief(t.Slots)))
|
||||
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBriefWithState(state, t.Slots)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -545,7 +568,7 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
if len(suggestedTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatSuggestedList(suggestedTasks)
|
||||
return formatSuggestedList(state, suggestedTasks)
|
||||
}
|
||||
|
||||
// 6. 纯已安排模式:只输出已安排任务。
|
||||
@@ -553,7 +576,7 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
if len(existingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatExistingList(existingTasks)
|
||||
return formatExistingList(state, existingTasks)
|
||||
}
|
||||
|
||||
// 7. 全部模式:统计 + 分组输出。
|
||||
@@ -563,11 +586,11 @@ func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
|
||||
if len(existingTasks) > 0 {
|
||||
sb.WriteString("\n已安排(existing):\n")
|
||||
sb.WriteString(formatExistingList(existingTasks))
|
||||
sb.WriteString(formatExistingList(state, existingTasks))
|
||||
}
|
||||
if len(suggestedTasks) > 0 {
|
||||
sb.WriteString("\n已预排(suggested):\n")
|
||||
sb.WriteString(formatSuggestedList(suggestedTasks))
|
||||
sb.WriteString(formatSuggestedList(state, suggestedTasks))
|
||||
}
|
||||
if len(pendingTasks) > 0 {
|
||||
sb.WriteString("\n待安排(pending):\n")
|
||||
@@ -680,7 +703,7 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
|
||||
if len(task.Slots) > 0 {
|
||||
sb.WriteString("占用时段:\n")
|
||||
for _, slot := range task.Slots {
|
||||
sb.WriteString(fmt.Sprintf(" 第%d天 第%s\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,14 +801,14 @@ func formatEmbedInfoForDay(state *ScheduleState, day int) string {
|
||||
|
||||
// formatExistingList 格式化已安排任务列表。
|
||||
// 格式如: [1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节)
|
||||
func formatExistingList(tasks []ScheduleTask) string {
|
||||
func formatExistingList(state *ScheduleState, tasks []ScheduleTask) string {
|
||||
var sb strings.Builder
|
||||
for _, t := range tasks {
|
||||
label := formatTaskLabelWithCategory(t)
|
||||
// 格式化所有时段位置。
|
||||
slotParts := make([]string, 0, len(t.Slots))
|
||||
for _, slot := range t.Slots {
|
||||
slotParts = append(slotParts, fmt.Sprintf("第%d天(%s)", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
|
||||
slotParts = append(slotParts, fmt.Sprintf("%s(%s)", formatDayLabel(state, slot.Day), formatSlotRange(slot.SlotStart, slot.SlotEnd)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", label, strings.Join(slotParts, " ")))
|
||||
}
|
||||
@@ -794,13 +817,13 @@ func formatExistingList(tasks []ScheduleTask) string {
|
||||
|
||||
// formatSuggestedList 格式化已预排任务列表。
|
||||
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节,类别:学习
|
||||
func formatSuggestedList(tasks []ScheduleTask) string {
|
||||
func formatSuggestedList(state *ScheduleState, tasks []ScheduleTask) string {
|
||||
var sb strings.Builder
|
||||
if len(tasks) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("已预排任务共%d个:\n\n", len(tasks)))
|
||||
}
|
||||
for _, t := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s,类别:%s\n", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots), t.Category))
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s,类别:%s\n", t.StateID, t.Name, formatTaskSlotsBriefWithState(state, t.Slots), t.Category))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -88,11 +88,12 @@ func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
// ==================== 写工具名集合 ====================
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"unplace": true,
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
@@ -123,27 +124,14 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
)
|
||||
|
||||
r.Register("find_first_free",
|
||||
"查找首个满足时长条件的可用位置,并返回该日详细负载信息。duration 必填,day 选填(不填按天顺序搜索)。",
|
||||
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
|
||||
"查找首个满足时长条件的可用位置,并返回该日详细负载信息。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"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
duration, ok := argsInt(args, "duration")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 duration。"
|
||||
}
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
|
||||
},
|
||||
)
|
||||
|
||||
// 兼容别名:保留 find_free,避免旧历史轨迹中的工具调用失效。
|
||||
r.Register("find_free",
|
||||
"兼容别名,行为同 find_first_free。",
|
||||
`{"name":"find_free","parameters":{"duration":{"type":"int","required":true},"day":{"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"))
|
||||
return FindFirstFree(state, duration, argsIntPtr(args, "day"), argsIntPtr(args, "day_start"), argsIntPtr(args, "day_end"))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -236,6 +224,18 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
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"}}}`,
|
||||
func(state *ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := parseMinContextSwitchTaskIDs(args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
return MinContextSwitch(state, taskIDs)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
|
||||
@@ -134,12 +134,40 @@ func countPending(state *ScheduleState) int {
|
||||
|
||||
// ==================== 任务时段辅助 ====================
|
||||
|
||||
// formatDayLabel 将 day_index 格式化为“第N天(星期X)”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这是工具层统一的“星期数展示口径”,避免各工具各自拼接导致输出不一致;
|
||||
// 2. 当 DayMapping 可用时,追加 weekday 数字(1~7);
|
||||
// 3. 若 DayMapping 缺失或异常,退回原始“第N天”,保证工具输出稳定。
|
||||
func formatDayLabel(state *ScheduleState, day int) string {
|
||||
base := fmt.Sprintf("第%d天", day)
|
||||
if state == nil {
|
||||
return base
|
||||
}
|
||||
_, dayOfWeek, ok := state.DayToWeekDay(day)
|
||||
if !ok || dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s(星期%d)", base, dayOfWeek)
|
||||
}
|
||||
|
||||
// formatDaySlotLabel 将“天 + 时段”拼成统一格式。
|
||||
func formatDaySlotLabel(state *ScheduleState, day, slotStart, slotEnd int) string {
|
||||
return fmt.Sprintf("%s第%s", formatDayLabel(state, day), formatSlotRange(slotStart, slotEnd))
|
||||
}
|
||||
|
||||
// formatTaskSlotsBrief 将任务的时段列表格式化为简短描述。
|
||||
// 如 "第1天(1-2节) 第4天(3-4节)"。
|
||||
func formatTaskSlotsBrief(slots []TaskSlot) string {
|
||||
return formatTaskSlotsBriefWithState(nil, slots)
|
||||
}
|
||||
|
||||
// formatTaskSlotsBriefWithState 在时段描述里补齐星期数。
|
||||
func formatTaskSlotsBriefWithState(state *ScheduleState, slots []TaskSlot) string {
|
||||
parts := make([]string, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
parts = append(parts, fmt.Sprintf("第%d天第%s", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
|
||||
parts = append(parts, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -197,9 +225,10 @@ func uniqueSorted(s []int) []int {
|
||||
func formatDayOccupancy(state *ScheduleState, day int) string {
|
||||
tasks := getTasksOnDay(state, day)
|
||||
occupied := countDayOccupied(state, day)
|
||||
dayLabel := formatDayLabel(state, day)
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Sprintf("第%d天当前占用:0/12。", day)
|
||||
return fmt.Sprintf("%s当前占用:0/12。", dayLabel)
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(tasks))
|
||||
@@ -208,7 +237,7 @@ func formatDayOccupancy(state *ScheduleState, day int) string {
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", label, formatSlotRange(td.slotStart, td.slotEnd)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("第%d天当前占用:%s,占用%d/12。", day, strings.Join(parts, " "), occupied)
|
||||
return fmt.Sprintf("%s当前占用:%s,占用%d/12。", dayLabel, strings.Join(parts, " "), occupied)
|
||||
}
|
||||
|
||||
// formatFreeHint 格式化某天的空闲时段提示。
|
||||
|
||||
@@ -52,12 +52,12 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
if conflict != nil {
|
||||
// 锁定任务的冲突给出特殊提示。
|
||||
if conflict.Locked {
|
||||
return fmt.Sprintf("放置失败:第%d天第%s已被 [%d]%s(固定)占用。\n%s\n%s",
|
||||
day, formatSlotRange(slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||||
return fmt.Sprintf("放置失败:%s已被 [%d]%s(固定)占用。\n%s\n%s",
|
||||
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||||
formatDayOccupancy(state, day), formatFreeHint(state, day))
|
||||
}
|
||||
return fmt.Sprintf("放置失败:第%d天第%s已被 [%d]%s 占用。\n%s\n%s",
|
||||
day, formatSlotRange(slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||||
return fmt.Sprintf("放置失败:%s已被 [%d]%s 占用。\n%s\n%s",
|
||||
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||||
formatDayOccupancy(state, day), formatFreeHint(state, day))
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
|
||||
task.Status = TaskStatusSuggested
|
||||
|
||||
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到第%d天第%s(宿主:[%d]%s)。\n%s\n待安排任务剩余:%d个。",
|
||||
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
|
||||
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到%s(宿主:[%d]%s)。\n%s\n待安排任务剩余:%d个。",
|
||||
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
|
||||
host.StateID, host.Name,
|
||||
formatDayOccupancy(state, day), countPending(state))
|
||||
}
|
||||
@@ -84,8 +84,8 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
|
||||
task.Status = TaskStatusSuggested
|
||||
|
||||
return fmt.Sprintf("已将 [%d]%s 预排到第%d天第%s。\n%s\n待安排任务剩余:%d个。",
|
||||
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
|
||||
return fmt.Sprintf("已将 [%d]%s 预排到%s。\n%s\n待安排任务剩余:%d个。",
|
||||
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
|
||||
formatDayOccupancy(state, day), countPending(state))
|
||||
}
|
||||
|
||||
@@ -130,15 +130,15 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
// 5. 冲突检测(排除自身)。
|
||||
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
|
||||
if conflict != nil {
|
||||
return fmt.Sprintf("移动失败:第%d天第%s已被 [%d]%s 占用。\n%s\n%s",
|
||||
newDay, formatSlotRange(newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
|
||||
return fmt.Sprintf("移动失败:%s已被 [%d]%s 占用。\n%s\n%s",
|
||||
formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
|
||||
formatDayOccupancy(state, newDay), formatFreeHint(state, newDay))
|
||||
}
|
||||
|
||||
// 6. 记录旧位置。
|
||||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||||
copy(oldSlots, task.Slots)
|
||||
oldDesc := formatTaskSlotsBrief(oldSlots)
|
||||
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
|
||||
|
||||
// 7. 执行变更。
|
||||
task.Slots = []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}
|
||||
@@ -147,8 +147,8 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
affectedDays := collectAffectedDays(oldSlots, task.Slots)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至第%d天第%s。\n",
|
||||
task.StateID, task.Name, oldDesc, newDay, formatSlotRange(newSlotStart, newSlotEnd)))
|
||||
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至%s。\n",
|
||||
task.StateID, task.Name, oldDesc, formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd)))
|
||||
for _, d := range affectedDays {
|
||||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||||
}
|
||||
@@ -215,8 +215,8 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
// 回滚
|
||||
taskA.Slots = oldSlotsA
|
||||
taskB.Slots = oldSlotsB
|
||||
return fmt.Sprintf("交换失败:[%d]%s 的新位置第%d天第%s与 [%d]%s 冲突。",
|
||||
taskA.StateID, taskA.Name, slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd),
|
||||
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
|
||||
taskA.StateID, taskA.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
|
||||
conflict.StateID, conflict.Name)
|
||||
}
|
||||
}
|
||||
@@ -226,8 +226,8 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
// 回滚
|
||||
taskA.Slots = oldSlotsA
|
||||
taskB.Slots = oldSlotsB
|
||||
return fmt.Sprintf("交换失败:[%d]%s 的新位置第%d天第%s与 [%d]%s 冲突。",
|
||||
taskB.StateID, taskB.Name, slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd),
|
||||
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
|
||||
taskB.StateID, taskB.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
|
||||
conflict.StateID, conflict.Name)
|
||||
}
|
||||
}
|
||||
@@ -241,10 +241,10 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
sb.WriteString("交换完成:\n")
|
||||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||||
taskA.StateID, taskA.Name,
|
||||
formatTaskSlotsBrief(oldSlotsA), formatTaskSlotsBrief(taskA.Slots)))
|
||||
formatTaskSlotsBriefWithState(state, oldSlotsA), formatTaskSlotsBriefWithState(state, taskA.Slots)))
|
||||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||||
taskB.StateID, taskB.Name,
|
||||
formatTaskSlotsBrief(oldSlotsB), formatTaskSlotsBrief(taskB.Slots)))
|
||||
formatTaskSlotsBriefWithState(state, oldSlotsB), formatTaskSlotsBriefWithState(state, taskB.Slots)))
|
||||
for _, d := range affectedDays {
|
||||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||||
}
|
||||
@@ -311,8 +311,8 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
// 冲突检测(在 clone 的中间状态上,排除自身)。
|
||||
conflict := findConflict(clone, m.NewDay, m.NewSlotStart, newSlotEnd, m.TaskID)
|
||||
if conflict != nil {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突:[%d]%s → 第%d天第%s,该位置已被 [%d]%s 占用。",
|
||||
task.StateID, task.Name, m.NewDay, formatSlotRange(m.NewSlotStart, newSlotEnd),
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突:[%d]%s → %s,该位置已被 [%d]%s 占用。",
|
||||
task.StateID, task.Name, formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, newSlotEnd),
|
||||
conflict.StateID, conflict.Name)
|
||||
}
|
||||
|
||||
@@ -331,9 +331,9 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
for _, m := range moves {
|
||||
task := state.TaskByStateID(m.TaskID)
|
||||
duration := taskDuration(*task)
|
||||
sb.WriteString(fmt.Sprintf(" [%d]%s → 第%d天第%s\n",
|
||||
task.StateID, task.Name, m.NewDay,
|
||||
formatSlotRange(m.NewSlotStart, m.NewSlotStart+duration-1)))
|
||||
sb.WriteString(fmt.Sprintf(" [%d]%s → %s\n",
|
||||
task.StateID, task.Name,
|
||||
formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, m.NewSlotStart+duration-1)))
|
||||
}
|
||||
for _, d := range days {
|
||||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||||
@@ -366,7 +366,7 @@ func Unplace(state *ScheduleState, taskID int) string {
|
||||
// 4. 记录旧位置。
|
||||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||||
copy(oldSlots, task.Slots)
|
||||
oldDesc := formatTaskSlotsBrief(oldSlots)
|
||||
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
|
||||
|
||||
// 5. 清理嵌入关系。
|
||||
// 如果该任务嵌入到了某个宿主上,清除宿主的 EmbeddedBy。
|
||||
|
||||
Reference in New Issue
Block a user