Files
smartmate/backend/agent2/node/schedule_plan_tool.go
LoveLosita aa04bfb452 Version: 0.7.8.dev.260325
后端:
迁移了schedule_plan逻辑并探索了新的架构组织思路
删除了一些Codex测试时产生的单测文件
前端:
做了一些改进
2026-03-25 20:37:55 +08:00

572 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
"github.com/LoveLosita/smartflow/backend/model"
)
// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。
//
// 职责边界:
// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。
// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。
// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。
type SchedulePlanToolDeps struct {
// SmartPlanningMultiRaw 是可选依赖:
// 1) 用于需要单独输出“粗排预览”时复用;
// 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。
SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
// HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片,
// 供 daily/weekly ReAct 节点在内存中继续优化。
HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
// ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。
//
// 返回语义:
// 1. startWeek/startDay窗口起点
// 2. endWeek/endDay窗口终点
// 3. error解析失败如任务类不存在、日期非法
//
// 用途:
// 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数;
// 2. 解决“首尾不足一周”场景下的周内越界问题。
ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
}
// Validate 校验依赖完整性。
//
// 失败处理:
// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。
// 2. 调用方runSchedulePlanFlow收到错误后会走回退链路不影响普通聊天可用性。
func (d SchedulePlanToolDeps) Validate() error {
if d.HybridScheduleWithPlanMulti == nil {
return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti is nil")
}
return nil
}
// ExtraInt 从 extra map 中安全提取整数值。
//
// 兼容策略:
// 1) JSON 数字默认解析为 float64做 int 转换;
// 2) 兼容字符串形式(如 "42"),用 Atoi 解析;
// 3) 其余类型返回 false由调用方决定后续处理。
func ExtraInt(extra map[string]any, key string) (int, bool) {
v, ok := extra[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
i, err := strconv.Atoi(n)
return i, err == nil
default:
return 0, false
}
}
// ExtraIntSlice 从 extra map 中安全提取整数切片。
//
// 兼容输入:
// 1) []anyJSON 数组反序列化后的常见类型);
// 2) []int
// 3) []float64
// 4) 逗号分隔字符串(例如 "1,2,3")。
//
// 返回语义:
// 1) ok=true至少成功解析出一个整数
// 2) ok=false字段不存在或全部解析失败。
func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) {
v, exists := extra[key]
if !exists {
return nil, false
}
parseOne := func(raw any) (int, error) {
switch n := raw.(type) {
case int:
return n, nil
case float64:
return int(n), nil
case string:
i, err := strconv.Atoi(n)
if err != nil {
return 0, err
}
return i, nil
default:
return 0, fmt.Errorf("unsupported type: %T", raw)
}
}
out := make([]int, 0)
switch arr := v.(type) {
case []int:
for _, item := range arr {
out = append(out, item)
}
case []float64:
for _, item := range arr {
out = append(out, int(item))
}
case []any:
for _, item := range arr {
if parsed, err := parseOne(item); err == nil {
out = append(out, parsed)
}
}
case string:
parts := strings.Split(arr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if parsed, err := strconv.Atoi(part); err == nil {
out = append(out, parsed)
}
}
default:
return nil, false
}
if len(out) == 0 {
return nil, false
}
return out, true
}
// ── ReAct Tool 调用/结果结构 ──
// reactToolCall 是 LLM 输出的单个工具调用。
type reactToolCall = agentllm.ReactToolCall
// reactToolResult 是单个工具调用的执行结果。
type reactToolResult struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Result string `json:"result"`
}
// reactLLMOutput 是 LLM 输出的完整 JSON 结构。
type reactLLMOutput = agentllm.ReactLLMOutput
// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。
//
// 语义:
// 1. Enabled=false不启用窗口硬边界仅做基础合法性校验
// 2. Enabled=trueMove 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内;
// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。
type weeklyPlanningWindow struct {
Enabled bool
StartWeek int
StartDay int
EndWeek int
EndDay int
}
// ── 工具分发器 ──
// dispatchReactTool 根据工具名分发调用返回可能修改后的entries 和执行结果。
func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) ([]model.HybridScheduleEntry, reactToolResult) {
switch call.Tool {
case "Swap":
return reactToolSwap(entries, call.Params)
case "Move":
return reactToolMove(entries, call.Params)
case "TimeAvailable":
return entries, reactToolTimeAvailable(entries, call.Params)
case "GetAvailableSlots":
return entries, reactToolGetAvailableSlots(entries, call.Params)
default:
return entries, reactToolResult{Tool: call.Tool, Success: false, Result: fmt.Sprintf("未知工具: %s", call.Tool)}
}
}
// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。
//
// 职责边界:
// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots
// 2. 强制 Move 的目标周必须等于 currentWeek避免并发周优化时发生跨周写穿
// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。
func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
tool := strings.TrimSpace(call.Tool)
switch tool {
case "Swap":
return reactToolSwap(entries, call.Params)
case "Move":
// 1. 周级并发模式下,每个 worker 只负责单周数据。
// 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。
// 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。
toWeek, ok := paramInt(call.Params, "to_week")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"}
}
if toWeek != currentWeek {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("当前仅允许优化本周worker_week=%d目标周=%d", currentWeek, toWeek),
}
}
// 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。
// 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。
// 4.2 窗口未启用时不阻断,保持兼容旧链路。
if window.Enabled {
toDay, ok := paramInt(call.Params, "to_day")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"}
}
allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay)
if !allowed {
return entries, reactToolResult{
Tool: "Move",
Success: false,
Result: fmt.Sprintf("目标日期超出排程窗口W%d 仅允许 D%d-D%d当前目标为 D%d", toWeek, dayFrom, dayTo, toDay),
}
}
}
return reactToolMove(entries, call.Params)
default:
return entries, reactToolResult{
Tool: tool,
Success: false,
Result: fmt.Sprintf("周级单步模式不支持工具: %s仅允许 Move/Swap", tool),
}
}
}
// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。
//
// 返回值:
// 1. allowed是否允许
// 2. dayFrom/dayTo该周允许的 day 区间(用于错误提示)。
func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) {
// 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。
if !window.Enabled {
return true, 1, 7
}
// 2. 先做周范围校验。
if week < window.StartWeek || week > window.EndWeek {
return false, 1, 7
}
// 3. 计算当前周允许的 day 边界。
from := 1
to := 7
if week == window.StartWeek {
from = window.StartDay
}
if week == window.EndWeek {
to = window.EndDay
}
if day < from || day > to {
return false, from, to
}
return true, from, to
}
// ── 参数提取辅助 ──
func paramInt(params map[string]any, key string) (int, bool) {
v, ok := params[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
default:
return 0, false
}
}
// findSuggestedByID 在 entries 中查找指定 TaskItemID 的 suggested 条目索引。
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
for i, e := range entries {
if e.TaskItemID == taskItemID && e.Status == "suggested" {
return i
}
}
return -1
}
// sectionsOverlap 判断两个节次区间是否有交集。
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
return aFrom <= bTo && bFrom <= aTo
}
// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。
//
// 规则:
// 1. suggested 任务永远阻塞(任务之间不能重叠);
// 2. existing 条目按 BlockForSuggested 字段决定;
// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
if entry.Status == "suggested" {
return true
}
// existing 走显式字段语义。
if entry.Status == "existing" {
return entry.BlockForSuggested
}
// 未知状态兜底:按阻塞处理。
return true
}
// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) {
for i, e := range entries {
if i == excludeIdx {
continue
}
// 1. 可嵌入且未占用的课程槽BlockForSuggested=false不参与冲突判断。
// 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。
if !entryBlocksSuggested(e) {
continue
}
if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) {
return true, fmt.Sprintf("%s(%s)", e.Name, e.Type)
}
}
return false, ""
}
// ══════════════════════════════════════════════════════════════
// Tool 1: Swap — 交换两个 suggested 任务的时间
// ══════════════════════════════════════════════════════════════
func reactToolSwap(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
idA, okA := paramInt(params, "task_a")
idB, okB := paramInt(params, "task_b")
if !okA || !okB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:需要 task_a 和 task_btask_item_id"}
}
if idA == idB {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 和 task_b 不能相同"}
}
idxA := findSuggestedByID(entries, idA)
idxB := findSuggestedByID(entries, idB)
if idxA == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idA)}
}
if idxB == -1 {
return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idB)}
}
// 交换时间坐标
a, b := &entries[idxA], &entries[idxB]
a.Week, b.Week = b.Week, a.Week
a.DayOfWeek, b.DayOfWeek = b.DayOfWeek, a.DayOfWeek
a.SectionFrom, b.SectionFrom = b.SectionFrom, a.SectionFrom
a.SectionTo, b.SectionTo = b.SectionTo, a.SectionTo
return entries, reactToolResult{
Tool: "Swap", Success: true,
Result: fmt.Sprintf("已交换 [%s](id=%d) 和 [%s](id=%d) 的时间", a.Name, idA, b.Name, idB),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 2: Move — 将一个 suggested 任务移动到新时间
// ══════════════════════════════════════════════════════════════
func reactToolMove(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) {
taskID, ok := paramInt(params, "task_item_id")
if !ok {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 task_item_id"}
}
toWeek, ok1 := paramInt(params, "to_week")
toDay, ok2 := paramInt(params, "to_day")
toSF, ok3 := paramInt(params, "to_section_from")
toST, ok4 := paramInt(params, "to_section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week, to_day, to_section_from, to_section_to"}
}
// 基础校验
if toDay < 1 || toDay > 7 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 不合法,应为 1-7", toDay)}
}
if toSF < 1 || toST > 12 || toSF > toST {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次范围 %d-%d 不合法,应为 1-12 且 from<=to", toSF, toST)}
}
idx := findSuggestedByID(entries, taskID)
if idx == -1 {
return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", taskID)}
}
// 节次跨度必须一致
origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
newSpan := toST - toSF
if origSpan != newSpan {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("节次跨度不一致:原任务占 %d 节,目标占 %d 节", origSpan+1, newSpan+1)}
}
// 冲突检测(排除自身)
if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, idx); conflict {
return entries, reactToolResult{Tool: "Move", Success: false,
Result: fmt.Sprintf("目标时间 W%dD%d 第%d-%d节 已被 %s 占用", toWeek, toDay, toSF, toST, name)}
}
// 执行移动
e := &entries[idx]
oldDesc := fmt.Sprintf("W%dD%d 第%d-%d节", e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo)
e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo = toWeek, toDay, toSF, toST
newDesc := fmt.Sprintf("W%dD%d 第%d-%d节", toWeek, toDay, toSF, toST)
return entries, reactToolResult{
Tool: "Move", Success: true,
Result: fmt.Sprintf("已将 [%s](id=%d) 从 %s 移动到 %s", e.Name, taskID, oldDesc, newDesc),
}
}
// ══════════════════════════════════════════════════════════════
// Tool 3: TimeAvailable — 检查目标时间段是否可用
// ══════════════════════════════════════════════════════════════
func reactToolTimeAvailable(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
week, ok1 := paramInt(params, "week")
day, ok2 := paramInt(params, "day_of_week")
sf, ok3 := paramInt(params, "section_from")
st, ok4 := paramInt(params, "section_to")
if !ok1 || !ok2 || !ok3 || !ok4 {
return reactToolResult{Tool: "TimeAvailable", Success: false, Result: "参数缺失:需要 week, day_of_week, section_from, section_to"}
}
if conflict, name := hasConflict(entries, week, day, sf, st, -1); conflict {
return reactToolResult{Tool: "TimeAvailable", Success: true,
Result: fmt.Sprintf(`{"available":false,"conflict_with":"%s"}`, name)}
}
return reactToolResult{Tool: "TimeAvailable", Success: true, Result: `{"available":true}`}
}
// ══════════════════════════════════════════════════════════════
// Tool 4: GetAvailableSlots — 返回可用时间段列表
// ══════════════════════════════════════════════════════════════
func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult {
filterWeek, _ := paramInt(params, "week") // 0 表示不过滤
// 1. 收集所有周次范围
minW, maxW := 999, 0
for _, e := range entries {
if e.Week < minW {
minW = e.Week
}
if e.Week > maxW {
maxW = e.Week
}
}
if minW > maxW {
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: "[]"}
}
// 2. 构建占用集合
type slotKey struct{ W, D, S int }
occupied := make(map[slotKey]bool)
for _, e := range entries {
if !entryBlocksSuggested(e) {
continue
}
for s := e.SectionFrom; s <= e.SectionTo; s++ {
occupied[slotKey{e.Week, e.DayOfWeek, s}] = true
}
}
// 3. 遍历所有时间格,找出空闲并合并连续节次
type availSlot struct {
Week, Day, From, To int
}
var slots []availSlot
startW, endW := minW, maxW
if filterWeek > 0 {
startW, endW = filterWeek, filterWeek
}
for w := startW; w <= endW; w++ {
for d := 1; d <= 7; d++ {
runStart := 0
for s := 1; s <= 12; s++ {
if !occupied[slotKey{w, d, s}] {
if runStart == 0 {
runStart = s
}
} else {
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, s - 1})
runStart = 0
}
}
}
if runStart > 0 {
slots = append(slots, availSlot{w, d, runStart, 12})
}
}
}
// 4. 按自然顺序排序(已经是了,但确保)
sort.Slice(slots, func(i, j int) bool {
if slots[i].Week != slots[j].Week {
return slots[i].Week < slots[j].Week
}
if slots[i].Day != slots[j].Day {
return slots[i].Day < slots[j].Day
}
return slots[i].From < slots[j].From
})
// 5. 序列化
type slotJSON struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"`
}
out := make([]slotJSON, 0, len(slots))
for _, s := range slots {
out = append(out, slotJSON{s.Week, s.Day, s.From, s.To})
}
data, _ := json.Marshal(out)
return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: string(data)}
}
// ── 辅助:解析 LLM 输出 ──
// parseReactLLMOutput 解析 LLM 的 JSON 输出。
// 兼容 ```json ... ``` 包裹。
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
return agentllm.ParseScheduleReactOutput(raw)
}
// truncate 截断字符串到指定长度。
func truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}