Version: 0.7.8.dev.260325
后端: 迁移了schedule_plan逻辑并探索了新的架构组织思路 删除了一些Codex测试时产生的单测文件 前端: 做了一些改进
This commit is contained in:
@@ -1,30 +1,164 @@
|
||||
package agentgraph
|
||||
|
||||
import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
const (
|
||||
SchedulePlanGraphName = "schedule_plan"
|
||||
ScheduleRefineGraphName = "schedule_refine"
|
||||
|
||||
ScheduleNodeIntentRoute = "schedule.intent.route"
|
||||
ScheduleNodePlan = "schedule.plan"
|
||||
ScheduleNodeRoughBuild = "schedule.rough_build"
|
||||
ScheduleNodeReact = "schedule.react"
|
||||
ScheduleNodeHardCheck = "schedule.hard_check"
|
||||
ScheduleNodeReply = "schedule.reply"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// SchedulePlanGraph 是“首次排程”图编排骨架。
|
||||
type SchedulePlanGraph struct {
|
||||
Nodes *agentnode.SchedulePlanNodes
|
||||
const (
|
||||
// SchedulePlanGraphName 是首次排程 graph 的稳定标识。
|
||||
SchedulePlanGraphName = "schedule_plan"
|
||||
// ScheduleRefineGraphName 先保留给 refine 链路使用。
|
||||
ScheduleRefineGraphName = "schedule_refine"
|
||||
)
|
||||
|
||||
// RunSchedulePlanGraph 执行“智能排程”图编排。
|
||||
//
|
||||
// 当前链路:
|
||||
// START
|
||||
// -> plan
|
||||
// -> roughBuild
|
||||
// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
|
||||
// -> finalCheck
|
||||
// -> returnPreview
|
||||
// -> END
|
||||
//
|
||||
// 说明:
|
||||
// 1. exit 分支可从 plan/roughBuild 直接提前终止;
|
||||
// 2. 本文件只负责“连线与分支”,节点内业务都在 node 层实现;
|
||||
// 3. 这轮已经去掉旧 runner 适配层,graph 直接挂 node 方法,减少一跳阅读成本。
|
||||
func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) {
|
||||
// 1. 启动前硬校验。
|
||||
if input.Model == nil {
|
||||
return nil, errors.New("schedule plan graph: model is nil")
|
||||
}
|
||||
if input.State == nil {
|
||||
return nil, errors.New("schedule plan graph: state is nil")
|
||||
}
|
||||
if err := input.Deps.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 注入运行时配置(可选覆盖)。
|
||||
if input.DailyRefineConcurrency > 0 {
|
||||
input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
|
||||
}
|
||||
if input.WeeklyAdjustBudget > 0 {
|
||||
input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget
|
||||
}
|
||||
|
||||
nodes, err := agentnode.NewSchedulePlanNodes(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]()
|
||||
|
||||
// 3. 注册节点。
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeRoughBuild, compose.InvokableLambda(nodes.RoughBuild)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailySplit, compose.InvokableLambda(nodes.DailySplit)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeQuickRefine, compose.InvokableLambda(nodes.QuickRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailyRefine, compose.InvokableLambda(nodes.DailyRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeMerge, compose.InvokableLambda(nodes.Merge)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(nodes.WeeklyRefine)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeFinalCheck, compose.InvokableLambda(nodes.FinalCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeReturnPreview, compose.InvokableLambda(nodes.ReturnPreview)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 固定入口:START -> plan。
|
||||
if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. plan 分支:roughBuild | exit。
|
||||
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch(
|
||||
nodes.NextAfterPlan,
|
||||
map[string]bool{
|
||||
agentnode.SchedulePlanGraphNodeRoughBuild: true,
|
||||
agentnode.SchedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. roughBuild 分支:dailySplit | quickRefine | weeklyRefine | exit。
|
||||
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
|
||||
nodes.NextAfterRoughBuild,
|
||||
map[string]bool{
|
||||
agentnode.SchedulePlanGraphNodeDailySplit: true,
|
||||
agentnode.SchedulePlanGraphNodeQuickRefine: true,
|
||||
agentnode.SchedulePlanGraphNodeWeeklyRefine: true,
|
||||
agentnode.SchedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. 固定边:quickRefine -> weeklyRefine;dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END。
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailySplit, agentnode.SchedulePlanGraphNodeDailyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailyRefine, agentnode.SchedulePlanGraphNodeMerge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeMerge, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeWeeklyRefine, agentnode.SchedulePlanGraphNodeFinalCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeFinalCheck, agentnode.SchedulePlanGraphNodeReturnPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeReturnPreview, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeExit, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. 编译并执行。
|
||||
// 路径最多约 8~9 个节点,保守预留 20 步避免误判。
|
||||
runnable, err := graph.Compile(ctx,
|
||||
compose.WithGraphName(SchedulePlanGraphName),
|
||||
compose.WithMaxRunSteps(20),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runnable.Invoke(ctx, input.State)
|
||||
}
|
||||
|
||||
// NewSchedulePlanGraph 创建首次排程图骨架。
|
||||
func NewSchedulePlanGraph(nodes *agentnode.SchedulePlanNodes) *SchedulePlanGraph {
|
||||
return &SchedulePlanGraph{Nodes: nodes}
|
||||
}
|
||||
|
||||
// ScheduleRefineGraph 是“连续微调排程”图编排骨架。
|
||||
// ScheduleRefineGraph 先保留骨架,避免本轮“只迁 schedule_plan”时误动 refine 主链路。
|
||||
type ScheduleRefineGraph struct {
|
||||
Nodes *agentnode.ScheduleRefineNodes
|
||||
}
|
||||
|
||||
@@ -1,22 +1,175 @@
|
||||
package agentllm
|
||||
|
||||
// ScheduleIntentOutput 是智能排程一级意图识别的模型契约草案。
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ScheduleIntentOutput 是 plan 节点要求模型返回的结构化结果。
|
||||
//
|
||||
// 兼容说明:
|
||||
// 1. 新主语义是 task_class_ids(数组);
|
||||
// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析;
|
||||
// 3. TaskTags 的 key 兼容两种写法:
|
||||
// 3.1 推荐:task_item_id(例如 "12");
|
||||
// 3.2 兼容:任务名称(例如 "高数复习")。
|
||||
type ScheduleIntentOutput struct {
|
||||
Intent string `json:"intent"`
|
||||
NeedRefine bool `json:"need_refine"`
|
||||
AdjustmentScope string `json:"adjustment_scope"`
|
||||
Intent string `json:"intent"`
|
||||
Constraints []string `json:"constraints"`
|
||||
TaskClassIDs []int `json:"task_class_ids"`
|
||||
TaskClassID int `json:"task_class_id"`
|
||||
Strategy string `json:"strategy"`
|
||||
TaskTags map[string]string `json:"task_tags"`
|
||||
Restart bool `json:"restart"`
|
||||
AdjustmentScope string `json:"adjustment_scope"`
|
||||
Reason string `json:"reason"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// SchedulePlanOutput 是首次排程规划节点的模型契约草案。
|
||||
type SchedulePlanOutput struct {
|
||||
Goal string `json:"goal"`
|
||||
Constraints []string `json:"constraints"`
|
||||
Strategy string `json:"strategy"`
|
||||
// ReactToolCall 是 LLM 输出的单个工具调用。
|
||||
type ReactToolCall struct {
|
||||
Tool string `json:"tool"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
// ScheduleRefineOutput 是连续微调阶段的模型契约草案。
|
||||
type ScheduleRefineOutput struct {
|
||||
Decision string `json:"decision"`
|
||||
HardAssertions []string `json:"hard_assertions"`
|
||||
NextAction string `json:"next_action"`
|
||||
// ReactLLMOutput 是 ReAct 节点要求模型返回的统一 JSON。
|
||||
type ReactLLMOutput struct {
|
||||
Done bool `json:"done"`
|
||||
Summary string `json:"summary"`
|
||||
ToolCalls []ReactToolCall `json:"tool_calls"`
|
||||
}
|
||||
|
||||
// IdentifySchedulePlanIntent 调用模型识别“排程意图 + 约束 + 任务类集合”。
|
||||
func IdentifySchedulePlanIntent(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
nowText string,
|
||||
userMessage string,
|
||||
adjustmentHint string,
|
||||
) (*ScheduleIntentOutput, error) {
|
||||
prompt := fmt.Sprintf(
|
||||
"当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。",
|
||||
strings.TrimSpace(nowText),
|
||||
strings.TrimSpace(userMessage),
|
||||
strings.TrimSpace(adjustmentHint),
|
||||
)
|
||||
|
||||
parsed, _, err := CallArkJSON[ScheduleIntentOutput](ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, ArkCallOptions{
|
||||
Temperature: 0,
|
||||
MaxTokens: 256,
|
||||
Thinking: ThinkingModeDisabled,
|
||||
})
|
||||
return parsed, err
|
||||
}
|
||||
|
||||
// ParseScheduleReactOutput 解析 ReAct 节点的 JSON 输出。
|
||||
func ParseScheduleReactOutput(raw string) (*ReactLLMOutput, error) {
|
||||
return ParseJSONObject[ReactLLMOutput](raw)
|
||||
}
|
||||
|
||||
// GenerateScheduleDailyReactRound 调用模型生成“单天日内优化”的一轮决策。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责统一关闭 thinking、设置温度,并返回纯文本;
|
||||
// 2. 不负责工具执行,不负责结果回灌。
|
||||
func GenerateScheduleDailyReactRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
messages []*schema.Message,
|
||||
) (string, error) {
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||||
einoModel.WithTemperature(0),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("日内优化调用返回为空")
|
||||
}
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("日内优化调用返回内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateScheduleWeeklyReactRound 调用模型生成“单周单步优化”的一轮决策。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 周级仍保留 thinking,提高复杂排程准确率;
|
||||
// 2. 仅返回最终 content,是否透出思考流由上层决定。
|
||||
func GenerateScheduleWeeklyReactRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
messages []*schema.Message,
|
||||
) (string, error) {
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
|
||||
einoModel.WithTemperature(0.2),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("周级单步调用返回为空")
|
||||
}
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("周级单步调用返回内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateScheduleHumanSummary 调用模型生成“用户可读”的最终总结。
|
||||
func GenerateScheduleHumanSummary(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
entries []model.HybridScheduleEntry,
|
||||
constraints []string,
|
||||
actionLogs []string,
|
||||
) (string, error) {
|
||||
if chatModel == nil {
|
||||
return "", fmt.Errorf("final summary model is nil")
|
||||
}
|
||||
|
||||
entriesJSON, _ := json.Marshal(entries)
|
||||
constraintText := "无"
|
||||
if len(constraints) > 0 {
|
||||
constraintText = strings.Join(constraints, "、")
|
||||
}
|
||||
actionLogText := "无"
|
||||
if len(actionLogs) > 0 {
|
||||
start := 0
|
||||
if len(actionLogs) > 30 {
|
||||
start = len(actionLogs) - 30
|
||||
}
|
||||
actionLogText = strings.Join(actionLogs[start:], "\n")
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"以下是最终排程方案(JSON):\n%s\n\n用户约束:%s\n\n以下是本次周级优化动作日志(按时间顺序):\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结,重点说明本方案的优点和改进点。",
|
||||
string(entriesJSON),
|
||||
constraintText,
|
||||
actionLogText,
|
||||
)
|
||||
|
||||
return CallArkText(ctx, chatModel, agentprompt.SchedulePlanFinalCheckPrompt, userPrompt, ArkCallOptions{
|
||||
Temperature: 0.4,
|
||||
MaxTokens: 256,
|
||||
Thinking: ThinkingModeDisabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,205 @@
|
||||
package agentmodel
|
||||
|
||||
// SchedulePlanState 是“首次排程”skill 的运行时状态骨架。
|
||||
type SchedulePlanState struct {
|
||||
TraceID string
|
||||
UserID int
|
||||
ConversationID string
|
||||
UserInput string
|
||||
TaskClassIDs []int
|
||||
UseQuickRefineOnly bool
|
||||
Completed bool
|
||||
FinalSummary string
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// SchedulePlanTimezoneName 是排程链路默认业务时区。
|
||||
// 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致“明天/今晚”偏移。
|
||||
SchedulePlanTimezoneName = "Asia/Shanghai"
|
||||
|
||||
// SchedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
|
||||
SchedulePlanDatetimeLayout = "2006-01-02 15:04"
|
||||
|
||||
// SchedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。
|
||||
// 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。
|
||||
SchedulePlanDefaultDailyRefineConcurrency = 3
|
||||
|
||||
// SchedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。
|
||||
// 额度存在的目的:
|
||||
// 1. 防止周级 ReAct 过度调整导致震荡;
|
||||
// 2. 控制 token 与时延成本;
|
||||
// 3. 让方案改动更可解释。
|
||||
SchedulePlanDefaultWeeklyAdjustBudget = 5
|
||||
|
||||
// SchedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。
|
||||
//
|
||||
// 设计意图:
|
||||
// 1. 总预算统计“动作尝试次数”(成功/失败都记一次);
|
||||
// 2. 有效预算统计“成功动作次数”(仅成功时记一次);
|
||||
// 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。
|
||||
SchedulePlanDefaultWeeklyTotalBudget = 8
|
||||
|
||||
// SchedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。
|
||||
// 说明:
|
||||
// 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流;
|
||||
// 2. 可在运行时按请求状态覆盖。
|
||||
SchedulePlanDefaultWeeklyRefineConcurrency = 2
|
||||
|
||||
// SchedulePlanAdjustmentScopeSmall 表示“小改动微调”。
|
||||
// 语义:优先走快速路径,只做轻量周级调整。
|
||||
SchedulePlanAdjustmentScopeSmall = "small"
|
||||
// SchedulePlanAdjustmentScopeMedium 表示“中等改动微调”。
|
||||
// 语义:跳过日内拆分,直接进入周级配平。
|
||||
SchedulePlanAdjustmentScopeMedium = "medium"
|
||||
// SchedulePlanAdjustmentScopeLarge 表示“大改动重排”。
|
||||
// 语义:必要时重新走全量路径(日内并发 + 周级配平)。
|
||||
SchedulePlanAdjustmentScopeLarge = "large"
|
||||
)
|
||||
|
||||
const (
|
||||
schedulePlanTimezoneName = SchedulePlanTimezoneName
|
||||
schedulePlanDatetimeLayout = SchedulePlanDatetimeLayout
|
||||
schedulePlanDefaultDailyRefineConcurrency = SchedulePlanDefaultDailyRefineConcurrency
|
||||
schedulePlanDefaultWeeklyAdjustBudget = SchedulePlanDefaultWeeklyAdjustBudget
|
||||
schedulePlanDefaultWeeklyTotalBudget = SchedulePlanDefaultWeeklyTotalBudget
|
||||
schedulePlanDefaultWeeklyRefineConcurrency = SchedulePlanDefaultWeeklyRefineConcurrency
|
||||
schedulePlanAdjustmentScopeSmall = SchedulePlanAdjustmentScopeSmall
|
||||
schedulePlanAdjustmentScopeMedium = SchedulePlanAdjustmentScopeMedium
|
||||
schedulePlanAdjustmentScopeLarge = SchedulePlanAdjustmentScopeLarge
|
||||
)
|
||||
|
||||
// DayGroup 是“按天拆分后”的最小优化单元。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模;
|
||||
// 2. 支持并发优化不同天的数据,缩短整体等待;
|
||||
// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。
|
||||
type DayGroup struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Entries []model.HybridScheduleEntry
|
||||
SkipRefine bool
|
||||
}
|
||||
|
||||
// ScheduleRefineState 是“连续微调排程”skill 的运行时状态骨架。
|
||||
// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落;
|
||||
// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪;
|
||||
// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
|
||||
type SchedulePlanState struct {
|
||||
// ── 基础上下文 ──
|
||||
TraceID string
|
||||
UserID int
|
||||
ConversationID string
|
||||
RequestNow time.Time
|
||||
RequestNowText string
|
||||
|
||||
// ── plan 节点输出 ──
|
||||
UserIntent string
|
||||
Constraints []string
|
||||
TaskClassIDs []int
|
||||
Strategy string
|
||||
TaskTags map[int]string
|
||||
TaskTagHintsByName map[string]string
|
||||
|
||||
// ── preview 节点输出 ──
|
||||
CandidatePlans []model.UserWeekSchedule
|
||||
AllocatedItems []model.TaskClassItem
|
||||
HasPlanningWindow bool
|
||||
PlanStartWeek int
|
||||
PlanStartDay int
|
||||
PlanEndWeek int
|
||||
PlanEndDay int
|
||||
|
||||
// ── 日内并发优化阶段 ──
|
||||
DailyGroups map[int]map[int]*DayGroup
|
||||
DailyResults map[int]map[int][]model.HybridScheduleEntry
|
||||
DailyRefineConcurrency int
|
||||
|
||||
// ── 周级 ReAct 精排阶段 ──
|
||||
HybridEntries []model.HybridScheduleEntry
|
||||
MergeSnapshot []model.HybridScheduleEntry
|
||||
ReactRound int
|
||||
ReactMaxRound int
|
||||
ReactSummary string
|
||||
ReactDone bool
|
||||
WeeklyAdjustBudget int
|
||||
WeeklyAdjustUsed int
|
||||
WeeklyTotalBudget int
|
||||
WeeklyTotalUsed int
|
||||
WeeklyRefineConcurrency int
|
||||
WeeklyActionLogs []string
|
||||
|
||||
// ── 连续对话微调 ──
|
||||
PreviousPlanJSON string
|
||||
IsAdjustment bool
|
||||
RestartRequested bool
|
||||
AdjustmentScope string
|
||||
AdjustmentReason string
|
||||
AdjustmentConfidence float64
|
||||
HasPreviousPreview bool
|
||||
PreviousTaskClassIDs []int
|
||||
PreviousHybridEntries []model.HybridScheduleEntry
|
||||
PreviousAllocatedItems []model.TaskClassItem
|
||||
PreviousCandidatePlans []model.UserWeekSchedule
|
||||
|
||||
// ── 最终输出 ──
|
||||
FinalSummary string
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// NewSchedulePlanState 创建排程状态对象并初始化默认值。
|
||||
func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState {
|
||||
now := schedulePlanNowToMinute()
|
||||
return &SchedulePlanState{
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
RequestNow: now,
|
||||
RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout),
|
||||
Strategy: "steady",
|
||||
TaskTags: make(map[int]string),
|
||||
TaskTagHintsByName: make(map[string]string),
|
||||
DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency,
|
||||
WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency,
|
||||
AdjustmentScope: schedulePlanAdjustmentScopeLarge,
|
||||
ReactMaxRound: 2,
|
||||
WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget,
|
||||
WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget,
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeSchedulePlanAdjustmentScope 归一化排程微调力度字段。
|
||||
//
|
||||
// 兜底策略:
|
||||
// 1. 只接受 small/medium/large;
|
||||
// 2. 任何未知值都回退为 large,保证不会误走“过轻”路径。
|
||||
func NormalizeSchedulePlanAdjustmentScope(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case schedulePlanAdjustmentScopeSmall:
|
||||
return schedulePlanAdjustmentScopeSmall
|
||||
case schedulePlanAdjustmentScopeMedium:
|
||||
return schedulePlanAdjustmentScopeMedium
|
||||
default:
|
||||
return schedulePlanAdjustmentScopeLarge
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePlanLocation 返回排程链路使用的业务时区。
|
||||
func schedulePlanLocation() *time.Location {
|
||||
loc, err := time.LoadLocation(schedulePlanTimezoneName)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// schedulePlanNowToMinute 返回当前时间并截断到分钟级。
|
||||
func schedulePlanNowToMinute() time.Time {
|
||||
return time.Now().In(schedulePlanLocation()).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
func normalizeAdjustmentScope(raw string) string {
|
||||
return NormalizeSchedulePlanAdjustmentScope(raw)
|
||||
}
|
||||
|
||||
// ScheduleRefineState 先保留现有骨架,避免本轮“只迁 schedule_plan”时误动 refine。
|
||||
type ScheduleRefineState struct {
|
||||
TraceID string
|
||||
UserID int
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
571
backend/agent2/node/schedule_plan_tool.go
Normal file
571
backend/agent2/node/schedule_plan_tool.go
Normal file
@@ -0,0 +1,571 @@
|
||||
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) []any(JSON 数组反序列化后的常见类型);
|
||||
// 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=true:Move 必须落在 [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_b(task_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]) + "..."
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
)
|
||||
|
||||
// TestExtractExplicitLimitFromUser_Number 验证阿拉伯数字条数可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_Number(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("给我3个优先级低的任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到显式数量")
|
||||
}
|
||||
if limit != 3 {
|
||||
t.Fatalf("数量识别错误,期望=3 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_ChineseNumber 验证中文数字也可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_ChineseNumber(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("前五个简单任务给我看看")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到中文数量")
|
||||
}
|
||||
if limit != 5 {
|
||||
t.Fatalf("数量识别错误,期望=5 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_LaiYiGe 验证“来一个”这类口语表达也能命中数量提取。
|
||||
func TestExtractExplicitLimitFromUser_LaiYiGe(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("来一个我的简单任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到‘来一个’的显式数量")
|
||||
}
|
||||
if limit != 1 {
|
||||
t.Fatalf("数量识别错误,期望=1 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_RespectsLimit 验证最终回复严格遵守 plan.limit。
|
||||
func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{
|
||||
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
|
||||
{ID: 2, Title: "任务2", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-17 10:00"},
|
||||
{ID: 3, Title: "任务3", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-18 10:00"},
|
||||
}
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 2}, "好的")
|
||||
if !strings.Contains(reply, "整理了 2 条任务") {
|
||||
t.Fatalf("回复未体现 limit=2,reply=%s", reply)
|
||||
}
|
||||
if strings.Contains(reply, "3. ") {
|
||||
t.Fatalf("回复中不应出现第 3 条,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_NoDuplicateList 验证 llmReply 自带列表时不会与后端列表重复拼接。
|
||||
func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"}}
|
||||
llmReply := "以下是你的任务:\n#1 任务1"
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 1}, llmReply)
|
||||
if strings.Contains(reply, "以下是你的任务") {
|
||||
t.Fatalf("不应保留 llm 列表头,reply=%s", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "整理了 1 条任务") {
|
||||
t.Fatalf("应保留后端确定性列表头,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRetryPatch_RespectExplicitLimit 验证显式数量存在时,反思补丁不能覆盖 limit。
|
||||
func TestApplyRetryPatch_RespectExplicitLimit(t *testing.T) {
|
||||
plan := agentmodel.TaskQueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
|
||||
limit := 10
|
||||
next := applyRetryPatch(plan, agentllm.TaskQueryRetryPatch{Limit: &limit}, 1)
|
||||
if next.Limit != 1 {
|
||||
t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package agentnode
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_Default 验证空输入会回填默认查询参数。
|
||||
func TestNormalizeTaskQueryToolInput_Default(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
|
||||
t.Fatalf("默认值异常: %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_InvalidQuadrant 验证 quadrant 越界时会被拦截。
|
||||
func TestNormalizeTaskQueryToolInput_InvalidQuadrant(t *testing.T) {
|
||||
invalid := 6
|
||||
_, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{Quadrant: &invalid})
|
||||
if err == nil {
|
||||
t.Fatalf("期望 quadrant 越界时报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_DateRange 验证时间上下界可被正确解析。
|
||||
func TestNormalizeTaskQueryToolInput_DateRange(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{
|
||||
DeadlineAfter: "2026-03-01 08:00",
|
||||
DeadlineBefore: "2026-03-31",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.DeadlineAfter == nil || req.DeadlineBefore == nil {
|
||||
t.Fatalf("时间上下界不应为空: %+v", req)
|
||||
}
|
||||
if req.DeadlineAfter.After(*req.DeadlineBefore) {
|
||||
t.Fatalf("时间上下界关系异常: after=%v before=%v", req.DeadlineAfter, req.DeadlineBefore)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,172 @@
|
||||
package agentprompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
const (
|
||||
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行;
|
||||
// 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
|
||||
// 3. 不负责做排程计算,不负责做工具调用。
|
||||
SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
|
||||
请根据用户输入,提取排程意图与约束条件。
|
||||
|
||||
必须完成以下任务:
|
||||
1) 用一句话概括用户的排程意图(intent)。
|
||||
2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。
|
||||
3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。
|
||||
4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。
|
||||
5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
|
||||
6) 尝试给任务打认知标签 task_tags(可选):
|
||||
- 推荐键:task_item_id(字符串形式,例如 "12")
|
||||
- 兼容键:任务名称(例如 "高数复习")
|
||||
- 值只能是:High-Logic / Memory / Review / General
|
||||
- 如果无法判断,输出空对象 {}
|
||||
7) 判定本轮是否要求“强制重排” restart:
|
||||
- 用户明确表达“重新排/推倒重来/忽略之前方案/全部重来”时,restart=true;
|
||||
- 否则 restart=false。
|
||||
8) 判定微调力度 adjustment_scope(small / medium / large):
|
||||
- small:局部微调,通常只改少量时段,不需要重建全局。
|
||||
- medium:中等调整,需要周级再平衡,但不必全量重粗排。
|
||||
- large:大范围调整,或首次创建排程,或约束变化很大,需要完整重排。
|
||||
9) 输出 reason(简短中文理由,<=30字)与 confidence(0~1)。
|
||||
|
||||
输出要求:
|
||||
- 仅输出 JSON,不要 markdown,不要解释。
|
||||
- 格式如下:
|
||||
{
|
||||
"intent": "用户排程意图摘要",
|
||||
"constraints": ["约束1", "约束2"],
|
||||
"task_class_ids": [12, 13],
|
||||
"task_class_id": 12,
|
||||
"strategy": "steady",
|
||||
"task_tags": {"12":"High-Logic","英语阅读":"Memory"},
|
||||
"restart": false,
|
||||
"adjustment_scope": "medium",
|
||||
"reason": "本次只调整局部时段",
|
||||
"confidence": 0.86
|
||||
}`
|
||||
|
||||
// SchedulePlanDailyReactPrompt 用于 daily_refine 节点。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“单天”数据,避免跨天决策污染;
|
||||
// 2. 通过工具调用做小步调整;
|
||||
// 3. 不负责周级配平,不负责最终总结。
|
||||
SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。
|
||||
|
||||
你将收到一天内的日程安排(JSON 数组),其中:
|
||||
- status="existing":已确定的课程或任务,不可移动
|
||||
- status="suggested":粗排算法建议的学习任务,你可以调整
|
||||
- context_tag:任务认知类型(High-Logic/Memory/Review/General)
|
||||
|
||||
你的目标是优化这一天内 suggested 任务的时间安排。
|
||||
|
||||
## 优化原则
|
||||
1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。
|
||||
2. 时段适配性:
|
||||
- 第1-4节(上午):适合 High-Logic(数学、编程)
|
||||
- 第5-8节(下午):适合中等强度(专业课、阅读)
|
||||
- 第9-12节(晚间):适合 Memory 和 Review
|
||||
3. 学习效率曲线:避免连续超过 4 节高强度学习。
|
||||
4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。
|
||||
|
||||
## 可用工具
|
||||
1. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a(task_item_id),task_b(task_item_id)
|
||||
2. Move — 将一个 suggested 任务移动到新时间(仅限当天)
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
3. TimeAvailable — 检查时段是否可用
|
||||
参数:week, day_of_week, section_from, section_to
|
||||
4. GetAvailableSlots — 获取可用时段
|
||||
参数:week
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
调用工具时:
|
||||
{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明优化理由"}
|
||||
|
||||
重要:只修改 suggested 任务,不要尝试移动 existing 条目。`
|
||||
|
||||
// SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。
|
||||
//
|
||||
// 设计重点:
|
||||
// 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done;
|
||||
// 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑;
|
||||
// 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突;
|
||||
// 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。
|
||||
SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。
|
||||
|
||||
单日内的排程已优化完毕,你当前只负责“单周微调”。
|
||||
|
||||
## 数据可靠性前提(必须接受)
|
||||
1. 你收到的混合日程 JSON 已经过后端硬冲突检查。
|
||||
2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。
|
||||
3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。
|
||||
4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。
|
||||
5. 字段语义补充:
|
||||
- existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务;
|
||||
- suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。
|
||||
|
||||
## 预算语义(必须遵守,且必须严格区分)
|
||||
1. 总动作预算(剩余):{{action_total_remaining}}
|
||||
2. 总动作预算(固定):{{action_total_budget}}
|
||||
3. 总动作预算(已用):{{action_total_used}}
|
||||
4. 有效动作预算(剩余):{{action_effective_remaining}}
|
||||
5. 有效动作预算(固定):{{action_effective_budget}}
|
||||
6. 有效动作预算(已用):{{action_effective_used}}
|
||||
7. 规则:
|
||||
- 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”;
|
||||
- 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。
|
||||
8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。
|
||||
|
||||
## 约束
|
||||
1. 只允许在当前周内优化(禁止跨周移动)。
|
||||
2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。
|
||||
3. 严格遵守用户约束(如有)。
|
||||
4. 每个任务最多变动一次位置。
|
||||
|
||||
## 优化目标
|
||||
1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。
|
||||
2. 间隔重复:同一科目任务适当分散到不同天。
|
||||
3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。
|
||||
4. 总量均衡:各天 suggested 数量大致均匀。
|
||||
|
||||
## 执行节奏(降低无效思考)
|
||||
1. 想一步做一步:本轮只做“一个最有价值动作”。
|
||||
2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。
|
||||
3. 如果当前方案已经足够好,直接 done,不要空转。
|
||||
4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。
|
||||
|
||||
## 可用工具
|
||||
1. Move — 将一个 suggested 任务移动到当前周的另一天/时段
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
注意:节次跨度必须与原任务一致
|
||||
2. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a, task_b(task_item_id)
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
调用工具时(注意:tool_calls 里只能有 1 个元素):
|
||||
{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}`
|
||||
|
||||
// SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做读数据总结,不参与工具调用与状态修改;
|
||||
// 2. 输出面向用户的自然语言;
|
||||
// 3. 失败由上层兜底文案处理。
|
||||
SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。
|
||||
你的任务是为用户生成一段友好、自然的排程总结。
|
||||
|
||||
要求:
|
||||
1. 用 2-3 句话概括方案亮点。
|
||||
2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
|
||||
3. 若用户有约束,说明方案如何满足这些约束。
|
||||
4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
|
||||
5. 语气温暖自然。
|
||||
6. 只输出纯文本,不要输出 JSON。`
|
||||
)
|
||||
|
||||
const scheduleSystemPrompt = `
|
||||
你是 SmartFlow 的智能排程助手。
|
||||
你的职责是把用户的排程目标转成结构化计划,并与后端粗排/硬校验能力配合完成排程。
|
||||
|
||||
当前 agent2 还没正式迁移 schedule 相关旧代码,
|
||||
因此这里先定义 prompt 收口点,确保后面迁移时不会再把大段提示词散写回 nodes.go。
|
||||
`
|
||||
|
||||
// BuildScheduleSystemPrompt 返回排程系统提示词骨架。
|
||||
func BuildScheduleSystemPrompt() string {
|
||||
return strings.TrimSpace(scheduleSystemPrompt)
|
||||
}
|
||||
|
||||
// BuildScheduleUserPrompt 构造排程用户提示词骨架。
|
||||
func BuildScheduleUserPrompt(nowText, userInput string) string {
|
||||
return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput))
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// TestNormalizeConversationTitle
|
||||
// 目的:确保标题清洗逻辑能去掉引号/前缀并裁剪到上限长度。
|
||||
func TestNormalizeConversationTitle(t *testing.T) {
|
||||
raw := "标题:\"明天上午去机场接人并顺路取快递,记得提前出门\""
|
||||
got := normalizeConversationTitle(raw)
|
||||
if strings.HasPrefix(got, "标题") {
|
||||
t.Fatalf("标题前缀未清洗,got=%s", got)
|
||||
}
|
||||
if len([]rune(got)) > conversationTitleMaxChars {
|
||||
t.Fatalf("标题长度超限,got=%s", got)
|
||||
}
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatalf("清洗后标题不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildConversationTitleUserPrompt
|
||||
// 目的:确保 prompt 构造时能正确标注用户/助手角色并包含有效内容。
|
||||
func TestBuildConversationTitleUserPrompt(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
{Role: schema.User, Content: "明天早上九点去机场接人"},
|
||||
{Role: schema.Assistant, Content: "收到,我帮你记下了。"},
|
||||
}
|
||||
prompt := buildConversationTitleUserPrompt(msgs)
|
||||
if !strings.Contains(prompt, "用户:明天早上九点去机场接人") {
|
||||
t.Fatalf("prompt 未包含用户内容,prompt=%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "助手:收到,我帮你记下了。") {
|
||||
t.Fatalf("prompt 未包含助手内容,prompt=%s", prompt)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
|
||||
)
|
||||
|
||||
// TestParseQuickNoteRouteControlTag_QuickNote
|
||||
// 目的:
|
||||
// 1. 验证旧 quick note 兼容入口仍然可以解析控制码;
|
||||
// 2. 验证旧 action=quick_note 会被统一映射到新动作 quick_note_create;
|
||||
// 3. 验证 reason 仍然会被保留下来,方便上层做阶段提示与排障。
|
||||
func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
|
||||
nonce := "abc123nonce"
|
||||
raw := `<SMARTFLOW_ROUTE nonce="abc123nonce" action="quick_note"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>用户明确在请求未来提醒</SMARTFLOW_REASON>`
|
||||
|
||||
decision, err := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != agentrouter.ActionQuickNoteCreate {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action)
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
t.Fatalf("reason 不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRouteControlTag_TaskQuery
|
||||
// 目的:验证通用分流控制码在 action=task_query 时可以被稳定解析。
|
||||
func TestParseRouteControlTag_TaskQuery(t *testing.T) {
|
||||
nonce := "taskquerynonce"
|
||||
raw := `<SMARTFLOW_ROUTE nonce="taskquerynonce" action="task_query"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>用户在查最紧急任务</SMARTFLOW_REASON>`
|
||||
|
||||
decision, err := agentrouter.ParseRouteControlTag(raw, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if decision == nil {
|
||||
t.Fatalf("decision 不应为空")
|
||||
}
|
||||
if decision.Action != agentrouter.ActionTaskQuery {
|
||||
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseQuickNoteRouteControlTag_NonceMismatch
|
||||
// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求。
|
||||
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
|
||||
raw := `<SMARTFLOW_ROUTE nonce="wrongnonce" action="chat"></SMARTFLOW_ROUTE>`
|
||||
if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
|
||||
t.Fatalf("期望 nonce 不匹配时报错,但未报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
|
||||
// 目的:
|
||||
// 1. 即使状态被错误标记为 Persisted=true;
|
||||
// 2. 只要没有有效 task_id,就不能回成功文案;
|
||||
// 3. 避免出现“回复成功但库里没数据”的假成功体验。
|
||||
func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
|
||||
state := &agentmodel.QuickNoteState{
|
||||
Persisted: true,
|
||||
PersistedTaskID: 0,
|
||||
ExtractedTitle: "去下馆子",
|
||||
}
|
||||
|
||||
reply := buildQuickNoteFinalReply(nil, nil, "我今天晚上6点要去下馆子,记得喊我", state)
|
||||
if strings.Contains(reply, "给你安排上了") || strings.Contains(reply, "已安排") {
|
||||
t.Fatalf("不应返回成功文案,实际回复=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickNoteFinalReply_UseExtractedBanter
|
||||
// 目的:
|
||||
// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用;
|
||||
// 2. 避免为了润色再次调用模型,增加不必要时延。
|
||||
func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
|
||||
state := &agentmodel.QuickNoteState{
|
||||
Persisted: true,
|
||||
PersistedTaskID: 12,
|
||||
ExtractedTitle: "明天去取快递",
|
||||
ExtractedPriority: 2,
|
||||
ExtractedBanter: "取件路上注意保暖,别被风吹懵了。",
|
||||
}
|
||||
|
||||
reply := buildQuickNoteFinalReply(nil, nil, "明天上午12点我要去取快递,到时候记得q我", state)
|
||||
if !strings.Contains(reply, "取件路上注意保暖") {
|
||||
t.Fatalf("期望复用 ExtractedBanter,实际回复=%s", reply)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
|
||||
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
@@ -107,7 +109,7 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
// 4. 执行 graph 主流程。
|
||||
// 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。
|
||||
// 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。
|
||||
state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
|
||||
state := agentmodel.NewSchedulePlanState(traceID, userID, chatID)
|
||||
// 4.3 连续对话微调注入:
|
||||
// 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state;
|
||||
// 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。
|
||||
@@ -118,10 +120,10 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems)
|
||||
state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans)
|
||||
}
|
||||
finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
|
||||
finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{
|
||||
Model: selectedModel,
|
||||
State: state,
|
||||
Deps: scheduleplan.SchedulePlanToolDeps{
|
||||
Deps: agentnode.SchedulePlanToolDeps{
|
||||
SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc,
|
||||
HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc,
|
||||
ResolvePlanningWindow: s.ResolvePlanningWindowFunc,
|
||||
@@ -155,6 +157,6 @@ func (s *AgentService) runSchedulePlanFlow(
|
||||
// 6. 旁路写入排程预览缓存(结构化 JSON),给查询接口拉取。
|
||||
// 6.1 失败只记日志,不影响本次对话回复;
|
||||
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
|
||||
s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
|
||||
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, finalState)
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
)
|
||||
@@ -69,6 +71,62 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
|
||||
}
|
||||
}
|
||||
|
||||
// saveSchedulePlanPreviewAgent2 把 agent2 的 schedule_plan 结果写入 Redis 预览与 MySQL 快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承接“新 agent2 首次排程链路”的最终状态;
|
||||
// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写;
|
||||
// 3. 不负责 refine 状态转换,refine 仍继续走旧链路的 saveSchedulePlanPreview。
|
||||
func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
|
||||
// 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。
|
||||
if s == nil || finalState == nil {
|
||||
return
|
||||
}
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if normalizedChatID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 组装缓存快照。
|
||||
// 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
|
||||
// 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片;
|
||||
// 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。
|
||||
summary := strings.TrimSpace(finalState.FinalSummary)
|
||||
if summary == "" {
|
||||
summary = "排程流程已完成,但未生成结果摘要。"
|
||||
}
|
||||
preview := &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: normalizedChatID,
|
||||
TraceID: strings.TrimSpace(finalState.TraceID),
|
||||
Summary: summary,
|
||||
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
|
||||
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
|
||||
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
|
||||
AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。
|
||||
// 3.1 Redis 是“快路径”;
|
||||
// 3.2 失败只记录日志,不中断聊天主链路。
|
||||
if s.cacheDAO != nil {
|
||||
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
|
||||
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。
|
||||
// 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖;
|
||||
// 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。
|
||||
if s.repo != nil {
|
||||
snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
|
||||
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
|
||||
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -137,62 +195,17 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
|
||||
|
||||
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
|
||||
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||||
for _, week := range src {
|
||||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||||
copy(eventsCopy, week.Events)
|
||||
dst = append(dst, model.UserWeekSchedule{
|
||||
Week: week.Week,
|
||||
Events: eventsCopy,
|
||||
})
|
||||
}
|
||||
return dst
|
||||
return agentshared.CloneWeekSchedules(src)
|
||||
}
|
||||
|
||||
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
|
||||
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.HybridScheduleEntry, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
return agentshared.CloneHybridEntries(src)
|
||||
}
|
||||
|
||||
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
|
||||
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.TaskClassItem, 0, len(src))
|
||||
for _, item := range src {
|
||||
copied := item
|
||||
if item.CategoryID != nil {
|
||||
v := *item.CategoryID
|
||||
copied.CategoryID = &v
|
||||
}
|
||||
if item.Order != nil {
|
||||
v := *item.Order
|
||||
copied.Order = &v
|
||||
}
|
||||
if item.Content != nil {
|
||||
v := *item.Content
|
||||
copied.Content = &v
|
||||
}
|
||||
if item.Status != nil {
|
||||
v := *item.Status
|
||||
copied.Status = &v
|
||||
}
|
||||
if item.EmbeddedTime != nil {
|
||||
t := *item.EmbeddedTime
|
||||
copied.EmbeddedTime = &t
|
||||
}
|
||||
dst = append(dst, copied)
|
||||
}
|
||||
return dst
|
||||
return agentshared.CloneTaskClassItems(src)
|
||||
}
|
||||
|
||||
// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。
|
||||
@@ -224,6 +237,35 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s
|
||||
}
|
||||
}
|
||||
|
||||
// buildSchedulePlanSnapshotFromAgent2State 把 agent2 的排程状态映射成可持久化快照 DTO。
|
||||
//
|
||||
// 调用目的:
|
||||
// 1. 这轮只迁移 schedule_plan,不动 refine;
|
||||
// 2. 因此 preview/快照协议继续复用老结构,但要补一个“agent2 state -> snapshot DTO”的映射层;
|
||||
// 3. 这样可以做到:计划创建链路切到 agent2,而 refine / 预览查询链路暂时无需大改。
|
||||
func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.SchedulePlanStateSnapshot{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
StateVersion: model.SchedulePlanStateVersionV1,
|
||||
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
|
||||
Constraints: append([]string(nil), st.Constraints...),
|
||||
HybridEntries: cloneHybridEntries(st.HybridEntries),
|
||||
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
|
||||
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
|
||||
UserIntent: strings.TrimSpace(st.UserIntent),
|
||||
Strategy: strings.TrimSpace(st.Strategy),
|
||||
AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
|
||||
RestartRequested: st.RestartRequested,
|
||||
FinalSummary: strings.TrimSpace(st.FinalSummary),
|
||||
Completed: st.Completed,
|
||||
TraceID: strings.TrimSpace(st.TraceID),
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。
|
||||
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
|
||||
if snapshot == nil {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/schedulerefine"
|
||||
)
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewSkipsFailedCompositeRoute(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: true,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: false,
|
||||
},
|
||||
}
|
||||
|
||||
if shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望复合分支终审失败时不覆盖上一版预览")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewAllowsPassedCompositeRoute(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: true,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: true,
|
||||
},
|
||||
}
|
||||
|
||||
if !shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望复合分支终审通过时允许覆盖预览")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistScheduleRefinePreviewKeepsReactPathBehavior(t *testing.T) {
|
||||
st := &schedulerefine.ScheduleRefineState{
|
||||
CompositeRouteSucceeded: false,
|
||||
HardCheck: schedulerefine.HardCheckReport{
|
||||
PhysicsPassed: true,
|
||||
OrderPassed: true,
|
||||
IntentPassed: false,
|
||||
},
|
||||
}
|
||||
|
||||
if !shouldPersistScheduleRefinePreview(st) {
|
||||
t.Fatalf("期望非复合直出分支继续沿用原有预览持久化策略")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user