Version: 0.9.34.dev.260421

后端:
1. 旧 Agent 管线(agent/)全面下线,共享逻辑迁移至 newAgent/
- 删除 backend/agent/ 整个目录(44 个 Go 文件),5 条旧专用流程已由 newAgent 统一 graph 取代
- 共享逻辑迁入 newAgent/:clone(shared/clone.go)、时间解析(shared/deadline.go)、优先级常量(shared/task_priority.go)、TaskQuery 类型(model/taskquery_types.go)、SystemPrompt(prompt/system.go)、Usage 合并(stream/usage.go)
2. service 层清除 agent/ 全部依赖
- 删除 4 个旧流程入口文件(agent_route / agent_quick_note / agent_schedule_plan / agent_schedule_refine)
- agent_task_query.go 删除 runTaskQueryFlow,参数类型切到 newagentmodel
- agent.go / agent_newagent.go / agent_schedule_preview.go / agent_schedule_state.go / cmd/start.go / quicknote.go:agent* 引用全部替换为 newagent*
3. 流式降级回退路径内联到 service 层(agent_stream_fallback.go),消除最后一条 agent/chat 依赖

前端:
1. ScheduleFineTuneModal 幂等键追加 classId 后缀,修复多任务类并行保存 key 重复
This commit is contained in:
LoveLosita
2026-04-21 20:10:16 +08:00
parent b309a32a98
commit 73ab0f43aa
60 changed files with 560 additions and 15261 deletions

View File

@@ -1,41 +0,0 @@
package agent
import (
"context"
"errors"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
)
// Service 是 agent 模块的总入口。
//
// 职责边界:
// 1. 负责接住一次完整的 Agent 请求,并把请求交给统一路由器分流;
// 2. 负责维护“路由器 + 各 skill handler”的装配关系
// 3. 不负责具体 skill 的 graph 连线,也不负责节点内部业务实现。
type Service struct {
dispatcher *agentrouter.Dispatcher
}
// NewService 创建 agent 总入口服务。
func NewService(resolver agentrouter.Resolver) *Service {
return &Service{
dispatcher: agentrouter.NewDispatcher(resolver),
}
}
// RegisterHandler 注册某个 skill 的执行入口。
func (s *Service) RegisterHandler(action agentrouter.Action, handler agentrouter.SkillHandler) error {
if s == nil || s.dispatcher == nil {
return errors.New("agent service is not initialized")
}
return s.dispatcher.Register(action, handler)
}
// Handle 是 agent 的统一处理入口。
func (s *Service) Handle(ctx context.Context, req *agentrouter.AgentRequest) (*agentrouter.AgentResponse, error) {
if s == nil || s.dispatcher == nil {
return nil, errors.New("agent service is not initialized")
}
return s.dispatcher.Dispatch(ctx, req)
}

View File

@@ -1,122 +0,0 @@
package agentgraph
import (
"context"
"errors"
"strings"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/cloudwego/eino/compose"
)
const (
// QuickNoteGraphName 是随口记图编排的稳定标识。
//
// 职责边界:
// 1. 仅用于 graph 编译和链路标识,方便日志与排障统一定位。
// 2. 不参与意图判断,也不承载任务写库的业务语义。
QuickNoteGraphName = "quick_note"
)
// RunQuickNoteGraph 负责执行“随口记 -> 判断 -> 提取 -> 落库 -> 收口”的整条图链路。
//
// 职责边界:
// 1. 负责输入兜底、工具装配、节点注册与 graph 运行。
// 2. 不负责每个节点的具体业务决策,节点内部逻辑由 node 层实现。
// 3. 返回的 state 表示整条链路的最终状态,供上层继续拼接响应或写日志。
func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInput) (*agentmodel.QuickNoteState, error) {
// 1. 先校验最基础依赖,避免图已经启动后才发现模型或状态为空。
if input.Model == nil {
return nil, errors.New("quick note graph: model is nil")
}
if input.State == nil {
return nil, errors.New("quick note graph: state is nil")
}
if err := input.Deps.Validate(); err != nil {
return nil, err
}
// 2. 补齐当前请求时间,保证后续提示词、时间解析和落库字段都基于同一时刻。
if input.State.RequestNow.IsZero() {
input.State.RequestNow = agentshared.NowToMinute()
}
if strings.TrimSpace(input.State.RequestNowText) == "" {
input.State.RequestNowText = agentshared.FormatMinute(input.State.RequestNow)
}
// 3. 图运行前统一准备工具与节点容器,避免节点内部重复做依赖解析。
toolBundle, err := agentnode.BuildQuickNoteToolBundle(ctx, input.Deps)
if err != nil {
return nil, err
}
createTaskTool, err := agentnode.GetInvokableToolByName(toolBundle, agentnode.ToolNameQuickNoteCreateTask)
if err != nil {
return nil, err
}
nodes, err := agentnode.NewQuickNoteNodes(input, createTaskTool)
if err != nil {
return nil, err
}
// 4. 主链路保持“意图识别 -> 优先级评估 -> 持久化 -> 退出”,中间通过 branch 决定是否提前结束或重试写库。
graph := compose.NewGraph[*agentmodel.QuickNoteState, *agentmodel.QuickNoteState]()
if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeIntent, compose.InvokableLambda(nodes.Intent)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeRank, compose.InvokableLambda(nodes.Priority)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodePersist, compose.InvokableLambda(nodes.Persist)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil {
return nil, err
}
if err = graph.AddEdge(compose.START, agentnode.QuickNoteGraphNodeIntent); err != nil {
return nil, err
}
if err = graph.AddBranch(agentnode.QuickNoteGraphNodeIntent, compose.NewGraphBranch(
nodes.NextAfterIntent,
map[string]bool{
agentnode.QuickNoteGraphNodeRank: true,
agentnode.QuickNoteGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.QuickNoteGraphNodeExit, compose.END); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.QuickNoteGraphNodeRank, agentnode.QuickNoteGraphNodePersist); err != nil {
return nil, err
}
if err = graph.AddBranch(agentnode.QuickNoteGraphNodePersist, compose.NewGraphBranch(
nodes.NextAfterPersist,
map[string]bool{
agentnode.QuickNoteGraphNodePersist: true,
compose.END: true,
},
)); err != nil {
return nil, err
}
// 5. persist 节点允许有限次重试,因此最大步数要覆盖首次执行与重试回路。
maxSteps := input.State.MaxToolRetry + 10
if maxSteps < 12 {
maxSteps = 12
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName(QuickNoteGraphName),
compose.WithMaxRunSteps(maxSteps),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
return runnable.Invoke(ctx, input.State)
}

View File

@@ -1,202 +0,0 @@
package agentgraph
import (
"context"
"errors"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/cloudwego/eino/compose"
)
const (
SchedulePlanGraphName = "schedule_plan"
ScheduleRefineGraphName = "schedule_refine"
)
func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.SchedulePlanState, error) {
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
}
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]()
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
}
if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil {
return nil, err
}
if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch(
nodes.NextAfterPlan,
map[string]bool{
agentnode.SchedulePlanGraphNodeRoughBuild: true,
agentnode.SchedulePlanGraphNodeExit: true,
},
)); err != nil {
return nil, err
}
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
}
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
}
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)
}
func RunScheduleRefineGraph(ctx context.Context, input agentnode.ScheduleRefineGraphRunInput) (*agentnode.ScheduleRefineState, error) {
if input.Model == nil {
return nil, errors.New("schedule refine graph: model is nil")
}
if input.State == nil {
return nil, errors.New("schedule refine graph: state is nil")
}
nodes, err := agentnode.NewScheduleRefineNodes(input)
if err != nil {
return nil, err
}
graph := compose.NewGraph[*agentmodel.ScheduleRefineState, *agentmodel.ScheduleRefineState]()
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeContract, compose.InvokableLambda(nodes.Contract)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSlice, compose.InvokableLambda(nodes.Slice)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeRoute, compose.InvokableLambda(nodes.Route)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeReact, compose.InvokableLambda(nodes.React)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeHardCheck, compose.InvokableLambda(nodes.HardCheck)); err != nil {
return nil, err
}
if err = graph.AddLambdaNode(agentnode.ScheduleRefineGraphNodeSummary, compose.InvokableLambda(nodes.Summary)); err != nil {
return nil, err
}
if err = graph.AddEdge(compose.START, agentnode.ScheduleRefineGraphNodeContract); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeContract, agentnode.ScheduleRefineGraphNodePlan); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodePlan, agentnode.ScheduleRefineGraphNodeSlice); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSlice, agentnode.ScheduleRefineGraphNodeRoute); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeRoute, agentnode.ScheduleRefineGraphNodeReact); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeReact, agentnode.ScheduleRefineGraphNodeHardCheck); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeHardCheck, agentnode.ScheduleRefineGraphNodeSummary); err != nil {
return nil, err
}
if err = graph.AddEdge(agentnode.ScheduleRefineGraphNodeSummary, compose.END); err != nil {
return nil, err
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName(ScheduleRefineGraphName),
compose.WithMaxRunSteps(20),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return nil, err
}
return runnable.Invoke(ctx, input.State)
}

View File

@@ -1,126 +0,0 @@
package agentgraph
import (
"context"
"errors"
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/cloudwego/eino/compose"
)
const (
// TaskQueryGraphName 是任务查询图编排的稳定标识。
//
// 职责边界:
// 1. 仅用于 graph 编译、日志和排障时标识当前链路。
// 2. 不承载路由判断,也不负责描述具体业务含义。
TaskQueryGraphName = "task_query"
)
// RunTaskQueryGraph 负责串起任务查询图,并返回最终给用户的回复文本。
//
// 职责边界:
// 1. 负责做图运行前的依赖校验、默认值补齐、节点装配与 graph 编译执行。
// 2. 不负责实现单个节点的业务细节,这些逻辑由 node 层承接。
// 3. 返回值中的 string 是最终可直接透传给上层的回复error 仅表示链路级失败。
func RunTaskQueryGraph(ctx context.Context, input agentnode.TaskQueryGraphRunInput) (string, error) {
// 1. 先拦住空模型、空状态和依赖缺失,避免 graph 运行到一半才出现不可恢复错误。
if input.Model == nil {
return "", errors.New("task query graph: model is nil")
}
if input.State == nil {
return "", errors.New("task query graph: state is nil")
}
if err := input.Deps.Validate(); err != nil {
return "", err
}
// 2. 请求时间缺失时补齐当前时间,保证后续时间锚定与提示词上下文稳定。
if strings.TrimSpace(input.State.RequestNowText) == "" {
input.State.RequestNowText = time.Now().In(time.Local).Format("2006-01-02 15:04")
}
// 3. 先准备工具,再构造节点容器;这样 graph 中每个节点都能拿到已校验好的依赖。
toolBundle, err := agentnode.BuildTaskQueryToolBundle(ctx, input.Deps)
if err != nil {
return "", err
}
queryTool, err := agentnode.GetTaskQueryInvokableToolByName(toolBundle, agentnode.ToolNameTaskQueryTasks)
if err != nil {
return "", err
}
nodes, err := agentnode.NewTaskQueryNodes(input, queryTool)
if err != nil {
return "", err
}
// 4. 注册节点与边,保持“计划 -> 归一化 -> 时间锚定 -> 查询 -> 反思”的单向主链。
graph := compose.NewGraph[*agentmodel.TaskQueryState, *agentmodel.TaskQueryState]()
if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuadrant, compose.InvokableLambda(nodes.NormalizeQuadrant)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeTimeAnchor, compose.InvokableLambda(nodes.AnchorTime)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeQuery, compose.InvokableLambda(nodes.Query)); err != nil {
return "", err
}
if err = graph.AddLambdaNode(agentnode.TaskQueryGraphNodeReflect, compose.InvokableLambda(nodes.Reflect)); err != nil {
return "", err
}
if err = graph.AddEdge(compose.START, agentnode.TaskQueryGraphNodePlan); err != nil {
return "", err
}
if err = graph.AddEdge(agentnode.TaskQueryGraphNodePlan, agentnode.TaskQueryGraphNodeQuadrant); err != nil {
return "", err
}
if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuadrant, agentnode.TaskQueryGraphNodeTimeAnchor); err != nil {
return "", err
}
if err = graph.AddEdge(agentnode.TaskQueryGraphNodeTimeAnchor, agentnode.TaskQueryGraphNodeQuery); err != nil {
return "", err
}
if err = graph.AddEdge(agentnode.TaskQueryGraphNodeQuery, agentnode.TaskQueryGraphNodeReflect); err != nil {
return "", err
}
if err = graph.AddBranch(agentnode.TaskQueryGraphNodeReflect, compose.NewGraphBranch(nodes.NextAfterReflect, map[string]bool{
agentnode.TaskQueryGraphNodeQuery: true,
compose.END: true,
})); err != nil {
return "", err
}
// 5. 反思节点支持按配置重试,因此最大步数需要覆盖“首次查询 + 多轮回看”的上限。
maxSteps := 24 + input.State.MaxReflectRetry*4
if maxSteps < 24 {
maxSteps = 24
}
runnable, err := graph.Compile(ctx,
compose.WithGraphName(TaskQueryGraphName),
compose.WithMaxRunSteps(maxSteps),
compose.WithNodeTriggerMode(compose.AnyPredecessor),
)
if err != nil {
return "", err
}
finalState, err := runnable.Invoke(ctx, input.State)
if err != nil {
return "", err
}
if finalState == nil {
return "", errors.New("task query graph: final state is nil")
}
// 6. 最终回复为空时给一个稳定兜底,避免上层拿到空字符串后再次拼接出异常文案。
reply := strings.TrimSpace(finalState.FinalReply)
if reply == "" {
reply = "我这边暂时没整理出稳定结果,你可以换一个更具体的筛选条件再试一次。"
}
return reply, nil
}

View File

@@ -1,83 +0,0 @@
package agentllm
import (
"context"
"errors"
"strings"
"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"
)
// ArkCallOptions 是基于 ark.ChatModel 的通用调用选项。
//
// 设计目的:
// 1. 当前 route / quicknote 都还直接持有 *ark.ChatModel
// 2. 在它们完全收敛到更抽象的 Client 前,先把重复的 ark 调用样板抽成公共层;
// 3. 这样本轮就能先删除 route/quicknote 里那几份重复的 Generate 样板代码。
type ArkCallOptions struct {
Temperature float64
MaxTokens int
Thinking ThinkingMode
}
// CallArkText 调用 ark 模型并返回纯文本。
//
// 职责边界:
// 1. 负责拼 system + user 两段消息;
// 2. 负责统一配置 thinking / temperature / maxTokens
// 3. 负责拦截空响应;
// 4. 不负责 JSON 解析,不负责业务字段校验。
func CallArkText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) {
if chatModel == nil {
return "", errors.New("ark model is nil")
}
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
resp, err := chatModel.Generate(ctx, messages, buildArkOptions(options)...)
if err != nil {
return "", err
}
if resp == nil {
return "", errors.New("模型返回为空")
}
text := strings.TrimSpace(resp.Content)
if text == "" {
return "", errors.New("模型返回内容为空")
}
return text, nil
}
// CallArkJSON 调用 ark 模型并直接解析 JSON。
func CallArkJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) {
raw, err := CallArkText(ctx, chatModel, systemPrompt, userPrompt, options)
if err != nil {
return nil, "", err
}
parsed, err := ParseJSONObject[T](raw)
if err != nil {
return nil, raw, err
}
return parsed, raw, nil
}
func buildArkOptions(options ArkCallOptions) []einoModel.Option {
thinkingType := arkModel.ThinkingTypeDisabled
if options.Thinking == ThinkingModeEnabled {
thinkingType = arkModel.ThinkingTypeEnabled
}
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: thinkingType}),
einoModel.WithTemperature(float32(options.Temperature)),
}
if options.MaxTokens > 0 {
opts = append(opts, einoModel.WithMaxTokens(options.MaxTokens))
}
return opts
}

View File

@@ -1,216 +0,0 @@
package agentllm
import (
"context"
"errors"
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// ThinkingMode 描述本次模型调用对 thinking 的期望。
//
// 职责边界:
// 1. 这里只表达“调用方希望怎样配置推理模式”;
// 2. 不直接绑定某个具体模型厂商的参数枚举;
// 3. 真正如何把它翻译成 ark / OpenAI / 其他 provider 的 option由后续适配层负责。
type ThinkingMode string
const (
ThinkingModeDefault ThinkingMode = "default"
ThinkingModeEnabled ThinkingMode = "enabled"
ThinkingModeDisabled ThinkingMode = "disabled"
)
// GenerateOptions 是 Agent 内部统一的模型调用选项。
//
// 设计目的:
// 1. 先把“每个 skill 都会反复传的参数”收敛成一份结构;
// 2. 让 node 层以后只表达“我要什么”,不再自己重复组织 option
// 3. 暂时不追求覆盖所有 provider 参数,先把最常用的几个公共位抽出来。
type GenerateOptions struct {
Temperature float64
MaxTokens int
Thinking ThinkingMode
Metadata map[string]any
}
// TextResult 是统一文本生成结果。
//
// 职责边界:
// 1. Text 保存模型最终返回的纯文本;
// 2. Usage 保存本次调用的 token 使用量,供后续统一统计;
// 3. 不负责 JSON 解析,不负责业务字段映射。
type TextResult struct {
Text string
Usage *schema.TokenUsage
}
// StreamReader 抽象了“可逐块 Recv 的流式返回器”。
//
// 之所以不直接依赖某个具体 SDK 的 reader 类型,是因为 Agent 现在还在建骨架阶段,
// 后续接 ark、OpenAI 兼容层还是别的 provider都可以往这个最小接口上适配。
type StreamReader interface {
Recv() (*schema.Message, error)
Close() error
}
// TextGenerateFunc 是文本生成的统一适配函数签名。
type TextGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error)
// StreamGenerateFunc 是流式生成的统一适配函数签名。
type StreamGenerateFunc func(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error)
// Client 是 Agent 里的统一模型客户端门面。
//
// 职责边界:
// 1. 负责把 node 层的“模型调用意图”收敛到统一入口;
// 2. 负责统一参数校验、空响应防御、GenerateJSON 复用;
// 3. 不负责写 prompt不负责业务 fallback也不直接持有具体厂商 SDK 细节。
type Client struct {
generateText TextGenerateFunc
streamText StreamGenerateFunc
}
// NewClient 创建统一模型客户端。
func NewClient(generateText TextGenerateFunc, streamText StreamGenerateFunc) *Client {
return &Client{
generateText: generateText,
streamText: streamText,
}
}
// GenerateText 执行一次统一文本生成。
//
// 职责边界:
// 1. 负责做最小必要的入参校验;
// 2. 负责统一拦截“模型空响应”这类公共问题;
// 3. 不负责业务 prompt 拼接,也不负责把文本再映射成业务结构。
func (c *Client) GenerateText(ctx context.Context, messages []*schema.Message, options GenerateOptions) (*TextResult, error) {
if c == nil || c.generateText == nil {
return nil, errors.New("agent llm client is not ready")
}
if len(messages) == 0 {
return nil, errors.New("llm messages is empty")
}
result, err := c.generateText(ctx, messages, options)
if err != nil {
return nil, err
}
if result == nil {
return nil, errors.New("llm result is nil")
}
if strings.TrimSpace(result.Text) == "" {
return nil, errors.New("llm returned empty text")
}
return result, nil
}
// GenerateJSON 先走统一文本生成,再走统一 JSON 解析。
//
// 设计说明:
// 1. 旧 agent 里每个 skill 都各自写了一份“Generate -> 提取 JSON -> 反序列化”;
// 2. 这里先把这一整段收敛成公共链路,后续 quicknote/taskquery/schedule 都直接复用;
// 3. 返回 parsed + rawResult方便上层既能拿结构化字段也能在打点/回退时保留原文。
// 4. 这里做成泛型函数而不是方法,是因为 Go 不支持“方法自带类型参数”。
func GenerateJSON[T any](ctx context.Context, client *Client, messages []*schema.Message, options GenerateOptions) (*T, *TextResult, error) {
result, err := client.GenerateText(ctx, messages, options)
if err != nil {
return nil, nil, err
}
parsed, err := ParseJSONObject[T](result.Text)
if err != nil {
return nil, result, err
}
return parsed, result, nil
}
// Stream 打开统一流式调用入口。
//
// 职责边界:
// 1. 只负责把“流式生成能力”暴露给上层;
// 2. 不负责 chunk 到 OpenAI 协议的转换,那部分应放在 stream/
// 3. 不负责累计全文,也不负责 token 统计落库。
func (c *Client) Stream(ctx context.Context, messages []*schema.Message, options GenerateOptions) (StreamReader, error) {
if c == nil || c.streamText == nil {
return nil, errors.New("agent llm stream client is not ready")
}
if len(messages) == 0 {
return nil, errors.New("llm messages is empty")
}
return c.streamText(ctx, messages, options)
}
// BuildSystemUserMessages 构造最常见的“system + history + user”消息列表。
//
// 设计说明:
// 1. 这是旧 agent 中高频重复片段,几乎每个 skill 都会拼一次;
// 2. 这里先把最稳定的消息编排方式沉淀下来,减少 node 层样板代码;
// 3. 只做消息切片装配,不做 prompt 生成。
func BuildSystemUserMessages(systemPrompt string, history []*schema.Message, userPrompt string) []*schema.Message {
messages := make([]*schema.Message, 0, len(history)+2)
if strings.TrimSpace(systemPrompt) != "" {
messages = append(messages, schema.SystemMessage(systemPrompt))
}
if len(history) > 0 {
messages = append(messages, history...)
}
if strings.TrimSpace(userPrompt) != "" {
messages = append(messages, schema.UserMessage(userPrompt))
}
return messages
}
// CloneUsage 深拷贝 token usage避免后续多处累加时共享同一指针。
func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage {
if usage == nil {
return nil
}
copied := *usage
return &copied
}
// MergeUsage 合并两段 usage。
//
// 合并策略:
// 1. 对“同一次调用不同流分片”的场景,取更大值作为最终值;
// 2. 对“多次独立调用累计”的场景,应由上层显式做加法,而不是用这个函数;
// 3. 该函数只适用于“同一次调用的分块 usage 收敛”。
func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage {
if incoming == nil {
return CloneUsage(base)
}
if base == nil {
return CloneUsage(incoming)
}
merged := *base
if incoming.PromptTokens > merged.PromptTokens {
merged.PromptTokens = incoming.PromptTokens
}
if incoming.CompletionTokens > merged.CompletionTokens {
merged.CompletionTokens = incoming.CompletionTokens
}
if incoming.TotalTokens > merged.TotalTokens {
merged.TotalTokens = incoming.TotalTokens
}
if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens {
merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens
}
if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens {
merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens
}
return &merged
}
// FormatEmptyResponseError 统一生成“模型返回空结果”的错误文案。
func FormatEmptyResponseError(scene string) error {
scene = strings.TrimSpace(scene)
if scene == "" {
scene = "unknown"
}
return fmt.Errorf("模型在 %s 场景返回空结果", scene)
}

View File

@@ -1,112 +0,0 @@
package agentllm
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// ParseJSONObject 解析模型返回中的 JSON 对象。
//
// 职责边界:
// 1. 负责处理“模型输出前后夹杂解释文字 / markdown 代码块”的常见情况;
// 2. 负责提取最外层 JSON object 并反序列化为目标结构;
// 3. 不负责业务字段合法性校验,例如 priority 是否在 1~4应由上层 node 再校验。
func ParseJSONObject[T any](raw string) (*T, error) {
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("模型返回为空,无法解析 JSON")
}
objectText := ExtractJSONObject(clean)
if objectText == "" {
return nil, fmt.Errorf("模型返回中未找到 JSON 对象: %s", truncateForError(clean))
}
var out T
if err := json.Unmarshal([]byte(objectText), &out); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return &out, nil
}
// ExtractJSONObject 从混合文本里提取第一个完整 JSON 对象。
//
// 设计说明:
// 1. LLM 很容易输出“这里是结果:{...}”这种半结构化文本;
// 2. 这里用括号计数而不是正则,避免嵌套对象一多就误截断;
// 3. 目前只提取 object不提取 array因为当前 agent 的路由/规划契约基本都是对象。
func ExtractJSONObject(text string) string {
clean := trimMarkdownCodeFence(strings.TrimSpace(text))
if clean == "" {
return ""
}
start := strings.Index(clean, "{")
if start < 0 {
return ""
}
depth := 0
inString := false
escaped := false
for idx := start; idx < len(clean); idx++ {
ch := clean[idx]
if escaped {
escaped = false
continue
}
if ch == '\\' && inString {
escaped = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return clean[start : idx+1]
}
}
}
return ""
}
func trimMarkdownCodeFence(text string) string {
trimmed := strings.TrimSpace(text)
if !strings.HasPrefix(trimmed, "```") {
return trimmed
}
lines := strings.Split(trimmed, "\n")
if len(lines) == 0 {
return trimmed
}
// 1. 去掉首行 ```json / ```
// 2. 若末行是 ```,一并去掉;
// 3. 中间正文保持原样,避免破坏 JSON 的换行结构。
body := lines[1:]
if len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "```" {
body = body[:len(body)-1]
}
return strings.TrimSpace(strings.Join(body, "\n"))
}
func truncateForError(text string) string {
if len(text) <= 160 {
return text
}
return text[:160] + "..."
}

View File

@@ -1,170 +0,0 @@
package agentllm
import (
"context"
"fmt"
"strings"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// QuickNoteIntentOutput 是“随口记意图识别”模型契约。
type QuickNoteIntentOutput struct {
IsQuickNote bool `json:"is_quick_note"`
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
Reason string `json:"reason"`
}
// QuickNotePriorityOutput 是“随口记优先级评估”模型契约。
type QuickNotePriorityOutput struct {
PriorityGroup int `json:"priority_group"`
Reason string `json:"reason"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
}
// QuickNotePlanOutput 是“随口记单请求聚合规划”模型契约。
type QuickNotePlanOutput struct {
Title string `json:"title"`
DeadlineAt string `json:"deadline_at"`
UrgencyThresholdAt string `json:"urgency_threshold_at"`
PriorityGroup int `json:"priority_group"`
PriorityReason string `json:"priority_reason"`
Banter string `json:"banter"`
}
// IdentifyQuickNoteIntent 调用模型识别“是否随口记”。
func IdentifyQuickNoteIntent(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNoteIntentOutput, error) {
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"is_quick_note": boolean,
"title": string,
"deadline_at": string,
"reason": string
}
字段约束:
1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。
2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。
3) 如果用户没有提及时间deadline_at 输出空字符串。`,
nowText,
userInput,
)
parsed, _, err := CallArkJSON[QuickNoteIntentOutput](ctx, chatModel, agentprompt.QuickNoteIntentPrompt, prompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 256,
Thinking: ThinkingModeDisabled,
})
return parsed, err
}
// PlanQuickNotePriority 调用模型评估优先级与紧急分界线。
func PlanQuickNotePriority(ctx context.Context, chatModel *ark.ChatModel, nowText, title, userInput, deadlineClue, deadlineText string) (*QuickNotePriorityOutput, error) {
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
请对以下任务评估优先级:
- 任务标题:%s
- 用户原始输入:%s
- 时间线索原文:%s
- 归一化截止时间:%s
请仅输出 JSON不要 markdown不要解释
{
"priority_group": 1|2|3|4,
"reason": "简短理由",
"urgency_threshold_at": "yyyy-MM-dd HH:mm 或空字符串"
}
额外约束:
1) urgency_threshold_at 表示“何时从不紧急象限自动平移到紧急象限”;
2) 若该任务不需要自动平移,可输出空字符串;
3) 若任务已在紧急象限priority_group=1 或 3优先输出空字符串
4) 若输出非空时间,必须是绝对时间,且不晚于归一化截止时间(若有)。`,
nowText,
title,
userInput,
deadlineClue,
deadlineText,
)
parsed, _, err := CallArkJSON[QuickNotePriorityOutput](ctx, chatModel, agentprompt.QuickNotePriorityPrompt, prompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 256,
Thinking: ThinkingModeDisabled,
})
return parsed, err
}
// PlanQuickNoteInSingleCall 一次性完成标题/时间/优先级/banter 聚合规划。
func PlanQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*QuickNotePlanOutput, error) {
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
请仅输出 JSON不要 markdown不要解释字段如下
{
"title": string,
"deadline_at": string,
"urgency_threshold_at": string,
"priority_group": 1|2|3|4,
"priority_reason": string,
"banter": string
}
约束:
1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
2) urgency_threshold_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
3) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
4) 若任务不需要自动平移,可让 urgency_threshold_at 为空;
5) banter 只允许一句中文不超过30字不得改动任务事实。`,
nowText,
strings.TrimSpace(userInput),
)
parsed, _, err := CallArkJSON[QuickNotePlanOutput](ctx, chatModel, agentprompt.QuickNotePlanPrompt, prompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 220,
Thinking: ThinkingModeDisabled,
})
return parsed, err
}
// GenerateQuickNoteBanter 生成成功写入后的轻松跟进句。
func GenerateQuickNoteBanter(ctx context.Context, chatModel *ark.ChatModel, userMessage, title, priorityText, deadlineText string) (string, error) {
if chatModel == nil {
return "", fmt.Errorf("model is nil")
}
prompt := fmt.Sprintf(`用户原话:%s
已确认事实:
- 任务标题:%s
- %s
- %s
请输出一句轻松自然的跟进话术(仅一句)。`,
strings.TrimSpace(userMessage),
strings.TrimSpace(title),
strings.TrimSpace(priorityText),
strings.TrimSpace(deadlineText),
)
text, err := CallArkText(ctx, chatModel, agentprompt.QuickNoteReplyBanterPrompt, prompt, ArkCallOptions{
Temperature: 0.7,
MaxTokens: 72,
Thinking: ThinkingModeDisabled,
})
if err != nil {
return "", err
}
text = strings.TrimSpace(text)
text = strings.Trim(text, "\"'“”‘’")
if text == "" {
return "", fmt.Errorf("empty content")
}
if idx := strings.Index(text, "\n"); idx >= 0 {
text = strings.TrimSpace(text[:idx])
}
return text, nil
}

View File

@@ -1,50 +0,0 @@
package agentllm
import (
"strings"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
)
// RouteDecisionOutput 是一级路由模型的结构化输出契约。
//
// 说明:
// 1. 这里只定义“模型应该吐什么 JSON”
// 2. 真正的 prompt 归 prompt/ 管;
// 3. 真正的业务分发归 router/ 管。
type RouteDecisionOutput struct {
Action string `json:"action"`
TrustRoute bool `json:"trust_route"`
Detail string `json:"detail"`
Confidence float64 `json:"confidence"`
}
// ToDecision 把模型契约输出映射成 Agent 内部统一路由结果。
func (o *RouteDecisionOutput) ToDecision() *agentmodel.RouteDecision {
if o == nil {
return &agentmodel.RouteDecision{Action: agentmodel.ActionChat}
}
action := normalizeRouteAction(o.Action)
return &agentmodel.RouteDecision{
Action: action,
TrustRoute: o.TrustRoute,
Detail: strings.TrimSpace(o.Detail),
Confidence: o.Confidence,
}
}
func normalizeRouteAction(raw string) agentmodel.AgentAction {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "quick_note", "quick_note_create":
return agentmodel.ActionQuickNoteCreate
case "task_query":
return agentmodel.ActionTaskQuery
case "schedule_plan", "schedule_plan_create":
return agentmodel.ActionSchedulePlanCreate
case "schedule_refine", "schedule_plan_refine":
return agentmodel.ActionSchedulePlanRefine
default:
return agentmodel.ActionChat
}
}

View File

@@ -1,175 +0,0 @@
package agentllm
import (
"context"
"encoding/json"
"fmt"
"strings"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/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"`
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"`
}
// ReactToolCall 是 LLM 输出的单个工具调用。
type ReactToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
// 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,
})
}

View File

@@ -1,132 +0,0 @@
package agentllm
import (
"context"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
)
const scheduleRefineNodeTimeout = 120 * time.Second
type ScheduleRefineContractOutput struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []ScheduleRefineAssertionLite `json:"hard_assertions"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
type ScheduleRefineAssertionLite struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value"`
Min int `json:"min"`
Max int `json:"max"`
Week int `json:"week"`
TargetWeek int `json:"target_week"`
}
type ScheduleRefinePlannerOutput struct {
Summary string `json:"summary"`
Steps []string `json:"steps"`
}
type ScheduleRefineToolCall struct {
Tool string `json:"tool"`
Params map[string]any `json:"params"`
}
type ScheduleRefineReactOutput struct {
Done bool `json:"done"`
Summary string `json:"summary"`
GoalCheck string `json:"goal_check"`
Decision string `json:"decision"`
MissingInfo []string `json:"missing_info,omitempty"`
ToolCalls []ScheduleRefineToolCall `json:"tool_calls"`
}
type ScheduleRefinePostReflectOutput struct {
Reflection string `json:"reflection"`
NextStrategy string `json:"next_strategy"`
ShouldStop bool `json:"should_stop"`
}
type ScheduleRefineReviewOutput struct {
Pass bool `json:"pass"`
Reason string `json:"reason"`
Unmet []string `json:"unmet"`
}
func GenerateScheduleRefineContract(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineContractOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefineContractOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 260,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefinePlanner(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (*ScheduleRefinePlannerOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefinePlannerOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: maxTokens,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineReact(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, useThinking bool, maxTokens int) (string, error) {
thinking := ThinkingModeDisabled
if useThinking {
thinking = ThinkingModeEnabled
}
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: maxTokens,
Thinking: thinking,
})
}
func GenerateScheduleRefinePostReflect(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefinePostReflectOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefinePostReflectOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 220,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineReview(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (*ScheduleRefineReviewOutput, string, error) {
return callScheduleRefineJSON[ScheduleRefineReviewOutput](ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 240,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineSummary(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0.35,
MaxTokens: 280,
Thinking: ThinkingModeDisabled,
})
}
func GenerateScheduleRefineRepair(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
return callScheduleRefineText(ctx, chatModel, systemPrompt, userPrompt, ArkCallOptions{
Temperature: 0.15,
MaxTokens: 240,
Thinking: ThinkingModeDisabled,
})
}
func callScheduleRefineText(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (string, error) {
nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout)
defer cancel()
return CallArkText(nodeCtx, chatModel, systemPrompt, userPrompt, options)
}
func callScheduleRefineJSON[T any](ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, options ArkCallOptions) (*T, string, error) {
nodeCtx, cancel := context.WithTimeout(ctx, scheduleRefineNodeTimeout)
defer cancel()
return CallArkJSON[T](nodeCtx, chatModel, systemPrompt, userPrompt, options)
}

View File

@@ -1,83 +0,0 @@
package agentllm
import (
"context"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// TaskQueryPlanOutput 描述计划节点返回的结构化查询方案。
//
// 职责边界:
// 1. 只承接模型输出,不在这里做合法性校验。
// 2. 字段为空或非法时,由 node 层继续归一化与兜底。
type TaskQueryPlanOutput struct {
UserGoal string `json:"user_goal"`
Quadrants []int `json:"quadrants"`
SortBy string `json:"sort_by"`
Order string `json:"order"`
Limit int `json:"limit"`
IncludeCompleted *bool `json:"include_completed"`
Keyword string `json:"keyword"`
DeadlineBefore string `json:"deadline_before"`
DeadlineAfter string `json:"deadline_after"`
}
// TaskQueryRetryPatch 描述反思节点允许回写的计划补丁。
//
// 输入输出语义:
// 1. 指针字段为 nil 表示“不改这个字段”。
// 2. 非 nil 但值为空字符串,表示显式清空该条件。
type TaskQueryRetryPatch struct {
Quadrants *[]int `json:"quadrants,omitempty"`
SortBy *string `json:"sort_by,omitempty"`
Order *string `json:"order,omitempty"`
Limit *int `json:"limit,omitempty"`
IncludeCompleted *bool `json:"include_completed,omitempty"`
Keyword *string `json:"keyword,omitempty"`
DeadlineBefore *string `json:"deadline_before,omitempty"`
DeadlineAfter *string `json:"deadline_after,omitempty"`
}
// TaskQueryReflectOutput 描述反思节点对本轮查询结果的判定。
//
// 输入输出语义:
// 1. Satisfied=true 表示当前结果可直接收口。
// 2. NeedRetry=true 表示建议再跑一轮,但真正是否重试由 node 层结合次数上限决定。
// 3. Reply 是可直接给用户的候选文案,允许为空。
type TaskQueryReflectOutput struct {
Satisfied bool `json:"satisfied"`
NeedRetry bool `json:"need_retry"`
Reason string `json:"reason"`
Reply string `json:"reply"`
RetryPatch TaskQueryRetryPatch `json:"retry_patch"`
}
// PlanTaskQuery 负责调用模型,把自然语言查询规划成结构化检索参数。
//
// 职责边界:
// 1. 只负责模型调用与 JSON 解析。
// 2. 不负责结果兜底、限流裁剪或时间归一化。
func PlanTaskQuery(ctx context.Context, chatModel *ark.ChatModel, nowText, userInput string) (*TaskQueryPlanOutput, error) {
parsed, _, err := CallArkJSON[TaskQueryPlanOutput](ctx, chatModel, agentprompt.TaskQueryPlanPrompt, agentprompt.BuildTaskQueryPlanUserPrompt(nowText, userInput), ArkCallOptions{
Temperature: 0,
MaxTokens: 260,
Thinking: ThinkingModeDisabled,
})
return parsed, err
}
// ReflectTaskQuery 负责让模型判断当前查询结果是否满足用户意图。
//
// 职责边界:
// 1. 只负责反思提示词调用与结构化解析。
// 2. 不负责实际执行重试,也不负责拼接最终兜底回复。
func ReflectTaskQuery(ctx context.Context, chatModel *ark.ChatModel, prompt string) (*TaskQueryReflectOutput, error) {
parsed, _, err := CallArkJSON[TaskQueryReflectOutput](ctx, chatModel, agentprompt.TaskQueryReflectPrompt, prompt, ArkCallOptions{
Temperature: 0,
MaxTokens: 380,
Thinking: ThinkingModeDisabled,
})
return parsed, err
}

View File

@@ -1,17 +0,0 @@
package agentmodel
// AgentRequest 是 Agent 总入口接收的统一请求结构。
type AgentRequest struct {
UserID int
ConversationID string
UserMessage string
ModelName string
Extra map[string]any
}
// AgentResponse 是 Agent 总入口返回的统一响应结构。
type AgentResponse struct {
Action AgentAction
Reply string
Meta map[string]any
}

View File

@@ -1,102 +0,0 @@
package agentmodel
import (
"time"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
)
const (
// QuickNoteDatetimeMinuteLayout 是随口记链路统一使用的分钟级时间格式。
QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04"
// QuickNoteTimezoneName 是随口记时间解析与展示优先使用的时区。
QuickNoteTimezoneName = "Asia/Shanghai"
QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent
QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent
QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant
QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant
)
// QuickNoteState 是随口记图在节点间流转的完整状态。
//
// 职责边界:
// 1. 负责保存意图识别、任务提取、工具重试和最终回复所需状态。
// 2. 不负责图编排,也不直接映射数据库任务实体。
type QuickNoteState struct {
TraceID string
UserID int
ConversationID string
RequestNow time.Time
RequestNowText string
UserInput string
IsQuickNoteIntent bool
IntentJudgeReason string
ExtractedTitle string
ExtractedDeadline *time.Time
ExtractedDeadlineText string
ExtractedUrgencyThreshold *time.Time
ExtractedPriority int
ExtractedBanter string
PlannedBySingleCall bool
ExtractedPriorityReason string
DeadlineValidationError string
ToolAttemptCount int
MaxToolRetry int
LastToolError string
PersistedTaskID int
Persisted bool
AssistantReply string
}
// NewQuickNoteState 负责创建随口记图的初始状态。
//
// 输入输出语义:
// 1. RequestNow 与 RequestNowText 会在创建时同步写入,保证整条链路共用同一时间基准。
// 2. MaxToolRetry 默认给 3避免上层未配置时完全失去重试能力。
func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState {
requestNow := agentshared.NowToMinute()
return &QuickNoteState{
TraceID: traceID,
UserID: userID,
ConversationID: conversationID,
RequestNow: requestNow,
RequestNowText: agentshared.FormatMinute(requestNow),
UserInput: userInput,
MaxToolRetry: 3,
}
}
// CanRetryTool 返回当前是否还允许再次调用持久化工具。
//
// 输入输出语义:
// 1. true 表示“尚未达到最大重试次数”,调用方仍可继续重试。
// 2. false 表示必须收口,避免无限重试。
func (s *QuickNoteState) CanRetryTool() bool {
return s.ToolAttemptCount < s.MaxToolRetry
}
// RecordToolError 记录一次工具失败,并推进重试计数。
//
// 职责边界:
// 1. 只更新与工具失败相关的状态。
// 2. 不决定是否继续重试,是否重试由节点分支逻辑判断。
func (s *QuickNoteState) RecordToolError(errMsg string) {
s.ToolAttemptCount++
s.LastToolError = errMsg
}
// RecordToolSuccess 记录一次工具成功结果。
//
// 输入输出语义:
// 1. taskID 必须是持久化后的真实任务 ID。
// 2. 成功后会清空 LastToolError表示当前链路已进入稳定态。
func (s *QuickNoteState) RecordToolSuccess(taskID int) {
s.ToolAttemptCount++
s.PersistedTaskID = taskID
s.Persisted = true
s.LastToolError = ""
}

View File

@@ -1,20 +0,0 @@
package agentmodel
// AgentAction 表示一级路由动作。
type AgentAction string
const (
ActionChat AgentAction = "chat"
ActionQuickNoteCreate AgentAction = "quick_note_create"
ActionTaskQuery AgentAction = "task_query"
ActionSchedulePlanCreate AgentAction = "schedule_plan_create"
ActionSchedulePlanRefine AgentAction = "schedule_plan_refine"
)
// RouteDecision 是统一一级分流结果。
type RouteDecision struct {
Action AgentAction
TrustRoute bool
Detail string
Confidence float64
}

View File

@@ -1,200 +0,0 @@
package agentmodel
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
}
// 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)
}

View File

@@ -1,344 +0,0 @@
package agentmodel
import (
"sort"
"strings"
"time"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
datetimeLayout = agentshared.MinuteLayout
ScheduleRefineDefaultPlanMax = 2
ScheduleRefineDefaultExecuteMax = 24
ScheduleRefineDefaultPerTaskBudget = 4
ScheduleRefineDefaultReplanMax = 2
ScheduleRefineDefaultCompositeRetry = 2
ScheduleRefineDefaultRepairReserve = 1
)
const (
defaultPlanMax = ScheduleRefineDefaultPlanMax
defaultExecuteMax = ScheduleRefineDefaultExecuteMax
defaultPerTaskBudget = ScheduleRefineDefaultPerTaskBudget
defaultReplanMax = ScheduleRefineDefaultReplanMax
defaultCompositeRetry = ScheduleRefineDefaultCompositeRetry
defaultRepairReserve = ScheduleRefineDefaultRepairReserve
)
// RefineContract 表示本轮微调意图契约。
type RefineContract struct {
Intent string `json:"intent"`
Strategy string `json:"strategy"`
HardRequirements []string `json:"hard_requirements"`
HardAssertions []RefineAssertion `json:"hard_assertions,omitempty"`
KeepRelativeOrder bool `json:"keep_relative_order"`
OrderScope string `json:"order_scope"`
}
// RefineAssertion 表示可由后端直接判定的结构化硬断言。
//
// 字段说明:
// 1. Metric断言指标名例如 source_move_ratio_percent
// 2. Operator比较操作符支持 == / <= / >= / between
// 3. Value/Min/Max阈值
// 4. Week/TargetWeek可选周次上下文。
type RefineAssertion struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Value int `json:"value,omitempty"`
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Week int `json:"week,omitempty"`
TargetWeek int `json:"target_week,omitempty"`
}
// HardCheckReport 表示终审硬校验结果。
type HardCheckReport struct {
PhysicsPassed bool `json:"physics_passed"`
PhysicsIssues []string `json:"physics_issues,omitempty"`
IntentPassed bool `json:"intent_passed"`
IntentReason string `json:"intent_reason,omitempty"`
IntentUnmet []string `json:"intent_unmet,omitempty"`
OrderPassed bool `json:"order_passed"`
OrderIssues []string `json:"order_issues,omitempty"`
RepairTried bool `json:"repair_tried"`
}
// ReactRoundObservation 记录每轮 ReAct 的关键观察。
type ReactRoundObservation struct {
Round int `json:"round"`
GoalCheck string `json:"goal_check,omitempty"`
Decision string `json:"decision,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolParams map[string]any `json:"tool_params,omitempty"`
ToolSuccess bool `json:"tool_success"`
ToolErrorCode string `json:"tool_error_code,omitempty"`
ToolResult string `json:"tool_result,omitempty"`
Reflect string `json:"reflect,omitempty"`
}
// PlannerPlan 表示 Planner 生成的阶段执行计划。
type PlannerPlan struct {
Summary string `json:"summary"`
Steps []string `json:"steps,omitempty"`
}
// RefineSlicePlan 表示切片节点输出。
type RefineSlicePlan struct {
WeekFilter []int `json:"week_filter,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RefineObjective 表示"可执行且可校验"的目标约束。
//
// 设计说明:
// 1. 由 contract/slice 从自然语言编译得到;
// 2. 执行阶段done 收口与终审阶段hard_check共用同一份约束
// 3. 避免"执行逻辑与终审逻辑各说各话"。
type RefineObjective struct {
Mode string `json:"mode,omitempty"` // none | move_all | move_ratio
SourceWeeks []int `json:"source_weeks,omitempty"`
TargetWeeks []int `json:"target_weeks,omitempty"`
SourceDays []int `json:"source_days,omitempty"`
TargetDays []int `json:"target_days,omitempty"`
ExcludeSections []int `json:"exclude_sections,omitempty"`
BaselineSourceTaskCount int `json:"baseline_source_task_count,omitempty"`
RequiredMoveMin int `json:"required_move_min,omitempty"`
RequiredMoveMax int `json:"required_move_max,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ScheduleRefineState 是连续微调图的统一状态。
type ScheduleRefineState struct {
// 1) 请求上下文
TraceID string
UserID int
ConversationID string
UserMessage string
RequestNow time.Time
RequestNowText string
// 2) 继承自预览快照的数据
TaskClassIDs []int
Constraints []string
// InitialHybridEntries 保存本轮微调开始前的基线,用于终审做"前后对比"。
// 说明:
// 1. 只读语义,不参与执行期改写;
// 2. 终审只基于它判断"来源任务是否真正迁移到目标区域"。
InitialHybridEntries []model.HybridScheduleEntry
HybridEntries []model.HybridScheduleEntry
AllocatedItems []model.TaskClassItem
CandidatePlans []model.UserWeekSchedule
// 3) 本轮执行状态
UserIntent string
Contract RefineContract
PlanMax int
PerTaskBudget int
ExecuteMax int
ReplanMax int
// CompositeRetryMax 表示复合路由失败后的最大重试次数(不含首次尝试)。
CompositeRetryMax int
PlanUsed int
ReplanUsed int
MaxRounds int
RepairReserve int
RoundUsed int
ActionLogs []string
ConsecutiveFailures int
ThinkingBoostArmed bool
ObservationHistory []ReactRoundObservation
CurrentPlan PlannerPlan
BatchMoveAllowed bool
// DisableCompositeTools=true 表示已进入 ReAct 兜底,禁止再调用复合工具。
DisableCompositeTools bool
// CompositeRouteTried 标记是否尝试过"复合批处理路由"。
CompositeRouteTried bool
// CompositeRouteSucceeded 标记复合批处理路由是否已完成"复合分支出站"。
//
// 说明:
// 1. true 表示当前链路可以跳过 ReAct 兜底,直接进入 hard_check
// 2. 它不等价于"终审已通过",终审是否通过仍以后续 HardCheck 结果为准;
// 3. 这样区分是为了避免"复合工具已成功执行,但业务目标要等终审裁决"时被误判为失败。
CompositeRouteSucceeded bool
TaskActionUsed map[int]int
EntriesVersion int
SeenSlotQueries map[string]struct{}
// RequiredCompositeTool 表示本轮策略要求"必须至少成功一次"的复合工具。
// 取值约定:"" | "SpreadEven" | "MinContextSwitch"。
RequiredCompositeTool string
// CompositeToolCalled 记录复合工具是否至少调用过一次(不区分成功失败)。
CompositeToolCalled map[string]bool
// CompositeToolSuccess 记录复合工具是否至少成功过一次。
CompositeToolSuccess map[string]bool
SlicePlan RefineSlicePlan
Objective RefineObjective
WorksetTaskIDs []int
WorksetCursor int
CurrentTaskID int
CurrentTaskAttempt int
LastFailedCallSignature string
OriginOrderMap map[int]int
// 4) 终审状态
HardCheck HardCheckReport
// 5) 最终输出
FinalSummary string
Completed bool
}
// NewScheduleRefineState 基于上一版预览快照初始化状态。
//
// 职责边界:
// 1. 负责初始化预算、上下文字段与可变状态容器;
// 2. 负责拷贝 preview 数据,避免跨请求引用污染;
// 3. 不负责做任何调度动作。
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
now := nowToMinute()
st := &ScheduleRefineState{
TraceID: strings.TrimSpace(traceID),
UserID: userID,
ConversationID: strings.TrimSpace(conversationID),
UserMessage: strings.TrimSpace(userMessage),
RequestNow: now,
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
PlanMax: defaultPlanMax,
PerTaskBudget: defaultPerTaskBudget,
ExecuteMax: defaultExecuteMax,
ReplanMax: defaultReplanMax,
CompositeRetryMax: defaultCompositeRetry,
RepairReserve: defaultRepairReserve,
MaxRounds: defaultExecuteMax + defaultRepairReserve,
ActionLogs: make([]string, 0, 32),
ObservationHistory: make([]ReactRoundObservation, 0, 24),
TaskActionUsed: make(map[int]int),
SeenSlotQueries: make(map[string]struct{}),
OriginOrderMap: make(map[int]int),
CompositeToolCalled: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CompositeToolSuccess: map[string]bool{
"SpreadEven": false,
"MinContextSwitch": false,
},
CurrentPlan: PlannerPlan{
Summary: "initialized, waiting for planner output",
},
SlicePlan: RefineSlicePlan{
Reason: "尚未切片",
},
}
if preview == nil {
return st
}
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
st.InitialHybridEntries = cloneHybridEntries(preview.HybridEntries)
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
return st
}
func loadLocation() *time.Location {
return agentshared.ShanghaiLocation()
}
func nowToMinute() time.Time {
return agentshared.NowToMinute()
}
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
return agentshared.CloneHybridEntries(src)
}
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return agentshared.CloneTaskClassItems(src)
}
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return agentshared.CloneWeekSchedules(src)
}
// buildOriginOrderMap 构建 suggested 任务的初始顺序基线task_item_id -> rank
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
orderMap := make(map[int]int)
if len(entries) == 0 {
return orderMap
}
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
for _, entry := range entries {
if isMovableSuggestedTask(entry) {
suggested = append(suggested, entry)
}
}
sort.SliceStable(suggested, func(i, j int) bool {
left := suggested[i]
right := suggested[j]
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
if left.SectionFrom != right.SectionFrom {
return left.SectionFrom < right.SectionFrom
}
if left.SectionTo != right.SectionTo {
return left.SectionTo < right.SectionTo
}
return left.TaskItemID < right.TaskItemID
})
for i, entry := range suggested {
orderMap[entry.TaskItemID] = i + 1
}
return orderMap
}
// FinalHardCheckPassed 判断"最终终审"是否整体通过。
//
// 职责边界:
// 1. 负责聚合 physics/order/intent 三类硬校验结果,给服务层与总结阶段统一复用;
// 2. 不负责触发终审,也不负责推送修复动作;
// 3. nil state 视为未通过,避免上层把缺失结果误判为成功。
func FinalHardCheckPassed(st *ScheduleRefineState) bool {
if st == nil {
return false
}
return st.HardCheck.PhysicsPassed && st.HardCheck.OrderPassed && st.HardCheck.IntentPassed
}
func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool {
if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 {
return false
}
if strings.EqualFold(strings.TrimSpace(entry.Type), "course") {
return false
}
return true
}

View File

@@ -1,87 +0,0 @@
package agentmodel
import "time"
const (
// DefaultTaskQueryLimit 是任务查询默认返回条数。
DefaultTaskQueryLimit = 5
// MaxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制模型输出范围。
MaxTaskQueryLimit = 20
// DefaultTaskQueryReflectRetry 是任务查询反思节点的默认重试次数。
DefaultTaskQueryReflectRetry = 2
)
// TaskQueryItem 是任务查询链路最终展示给模型和用户的轻量任务视图。
//
// 职责边界:
// 1. 只承载展示和反思所需字段,避免把底层数据库结构直接暴露给图层。
// 2. 不负责描述完整任务实体,也不负责持久化。
type TaskQueryItem struct {
ID int `json:"id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
IsCompleted bool `json:"is_completed"`
DeadlineAt string `json:"deadline_at,omitempty"`
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
}
// TaskQueryPlan 是计划节点产出的内部查询方案。
//
// 输入输出语义:
// 1. DeadlineBeforeText / DeadlineAfterText 保留原始文本,便于继续透传给工具和日志。
// 2. DeadlineBefore / DeadlineAfter 是归一化后的时间对象,仅供执行期使用。
// 3. IncludeCompleted=true 表示允许把已完成任务纳入候选集。
type TaskQueryPlan struct {
Quadrants []int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBeforeText string
DeadlineAfterText string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryState 是任务查询图在各节点之间流转的完整状态。
//
// 职责边界:
// 1. 负责保存用户输入、结构化计划、工具结果和反思过程状态。
// 2. 不负责图编排本身,也不直接绑定外部数据库实体。
type TaskQueryState struct {
UserMessage string
RequestNowText string
UserGoal string
Plan TaskQueryPlan
ExplicitLimit int
LastQueryItems []TaskQueryItem
LastQueryTotal int
AutoBroadenApplied bool
RetryCount int
MaxReflectRetry int
NeedRetry bool
ReflectReason string
FinalReply string
}
// NewTaskQueryState 负责创建任务查询图的初始状态。
//
// 输入输出语义:
// 1. maxReflectRetry <= 0 时会自动回退到默认值,避免上层遗漏配置导致无法重试。
// 2. 返回的状态对象已初始化空切片,可直接进入 graph 执行。
func NewTaskQueryState(userMessage, requestNowText string, maxReflectRetry int) *TaskQueryState {
if maxReflectRetry <= 0 {
maxReflectRetry = DefaultTaskQueryReflectRetry
}
return &TaskQueryState{
UserMessage: userMessage,
RequestNowText: requestNowText,
MaxReflectRetry: maxReflectRetry,
LastQueryItems: make([]TaskQueryItem, 0),
AutoBroadenApplied: false,
}
}

View File

@@ -1,504 +0,0 @@
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
)
const (
// QuickNoteGraphNodeIntent 是随口记图中的“意图识别”节点名。
QuickNoteGraphNodeIntent = "quick_note_intent"
// QuickNoteGraphNodeRank 是随口记图中的“优先级评估”节点名。
QuickNoteGraphNodeRank = "quick_note_priority"
// QuickNoteGraphNodePersist 是随口记图中的“持久化写库”节点名。
QuickNoteGraphNodePersist = "quick_note_persist"
// QuickNoteGraphNodeExit 是随口记图中的“提前退出”节点名。
QuickNoteGraphNodeExit = "quick_note_exit"
)
// QuickNoteGraphRunInput 描述一次随口记图运行所需的请求级依赖。
//
// 职责边界:
// 1. 负责把模型、初始状态、工具依赖和阶段回调打包给 graph 层。
// 2. 不负责做依赖校验,校验逻辑由 graph/node 构造阶段处理。
type QuickNoteGraphRunInput struct {
Model *ark.ChatModel
State *agentmodel.QuickNoteState
Deps QuickNoteToolDeps
SkipIntentVerification bool
EmitStage func(stage, detail string)
}
// QuickNoteNodes 是随口记图的节点容器。
//
// 职责边界:
// 1. 负责承接节点运行时依赖,并向 graph 暴露可直接挂载的方法。
// 2. 不负责 graph 编译,也不负责 service 层接口接线。
type QuickNoteNodes struct {
input QuickNoteGraphRunInput
createTaskTool tool.InvokableTool
emitStage func(stage, detail string)
}
// NewQuickNoteNodes 负责构造随口记节点容器。
//
// 输入输出语义:
// 1. createTaskTool 不能为空,否则 persist 节点无法落库。
// 2. EmitStage 为空时会回退到空实现,避免节点内部到处判空。
func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) {
if createTaskTool == nil {
return nil, errors.New("quick note nodes: createTaskTool is nil")
}
emitStage := input.EmitStage
if emitStage == nil {
emitStage = func(stage, detail string) {}
}
return &QuickNoteNodes{
input: input,
createTaskTool: createTaskTool,
emitStage: emitStage,
}, nil
}
// Exit 是图中的显式退出节点。
//
// 职责边界:
// 1. 仅作为图收口占位,保持状态原样透传。
// 2. 不做额外业务处理,避免退出节点再引入副作用。
func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
_ = ctx
return st, nil
}
// NextAfterIntent 根据意图识别结果决定 intent 节点后的分支走向。
//
// 步骤说明:
// 1. 非随口记意图时直接退出,避免误把普通聊天写成任务。
// 2. 截止时间校验失败时同样直接退出,让上层优先把错误提示给用户。
// 3. 只有意图成立且时间合法,才进入优先级评估节点。
func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) {
_ = ctx
if st == nil || !st.IsQuickNoteIntent {
return QuickNoteGraphNodeExit, nil
}
if st.DeadlineValidationError != "" {
return QuickNoteGraphNodeExit, nil
}
return QuickNoteGraphNodeRank, nil
}
// NextAfterPersist 根据持久化结果决定 persist 节点后的分支走向。
//
// 输入输出语义:
// 1. Persisted=true 表示已经成功写库,可以直接结束。
// 2. Persisted=false 且 CanRetryTool()=true 表示继续重试写库。
// 3. 重试用尽后会补齐兜底回复,再结束链路,避免用户拿到空响应。
func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) {
_ = ctx
if st == nil {
return compose.END, nil
}
if st.Persisted {
return compose.END, nil
}
if st.CanRetryTool() {
return QuickNoteGraphNodePersist, nil
}
if st.AssistantReply == "" {
st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。"
}
return compose.END, nil
}
// Intent 负责“意图识别 + 聚合规划 + 时间校验”。
//
// 职责边界:
// 1. 负责判断本次请求是否属于随口记;
// 2. 负责把模型规划结果回填到 state
// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL
// 4. 不负责真正写库。
func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 若上游路由已经高置信命中 quick_note则直接进入单次聚合规划。
// 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内
// 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。
if n.input.SkipIntentVerification {
n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, n.input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
if plan.Deadline != nil {
st.ExtractedDeadline = plan.Deadline
}
st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
if plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if agentmodel.IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.4 最后一定要做一轮本地时间硬校验。
// 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间;
// 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先做一次意图识别,再做本地时间硬校验。
n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput)
if callErr != nil {
// 2.1 这里不直接返回 error而是把它视为“本次未能确认是 quick note”交给上层回退普通聊天。
st.IsQuickNoteIntent = false
st.IntentJudgeReason = "意图识别失败,回退普通聊天"
return st, nil
}
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
return st, nil
}
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
}
st.ExtractedTitle = title
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// 2.2 先尝试吃模型返回的 deadline_at用于减少后续重复推理。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := ParseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// 2.3 再强制对用户原句做一次时间线索校验。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
st.AssistantReply = "我识别到你给了时间信息但这个时间格式我没法准确解析请改成例如2026-03-20 18:30、明天下午3点、下周一上午9点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
// 2.4 若模型没提到 deadline但用户原句能解析出来则以用户原句为准补齐。
if st.ExtractedDeadline == nil && userDeadline != nil {
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// Priority 负责“优先级评估”。
//
// 职责边界:
// 1. 负责在 intent 节点之后补齐 priority_group
// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型;
// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走;
// 4. 不负责写库。
func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。
if agentmodel.IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 2. 单请求聚合路径若没有给出合法 priority则直接走本地兜底优先保证低时延。
if n.input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
// Persist 负责“调工具写库 + 有限次重试状态回填”。
//
// 职责边界:
// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参;
// 2. 负责调用 createTaskTool 执行真正写库;
// 3. 负责把成功/失败结果回填到 state供后续分支与回复使用
// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。
func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
n.emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !agentmodel.IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(QuickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(QuickNoteLocation()).Format(time.RFC3339)
}
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
n.emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
n.emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
n.emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 1. 只有拿到有效 task_id才视为真正写入成功
// 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText string
PriorityGroup int
PriorityReason string
Banter string
}
// planQuickNoteInSingleCall 在一次模型调用里完成“时间 / 优先级 / banter”聚合规划。
func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, nowText string, now time.Time, userInput string) (*quickNotePlannedResult, error) {
parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput)
if err != nil {
return nil, err
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
PriorityGroup: parsed.PriorityGroup,
PriorityReason: strings.TrimSpace(parsed.PriorityReason),
Banter: strings.TrimSpace(parsed.Banter),
}
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
if result.DeadlineText != "" {
if deadline, deadlineErr := ParseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
}
}
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *agentmodel.QuickNoteState) int {
if st == nil {
return agentmodel.QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
}
return agentmodel.QuickNotePrioritySimpleNotImportant
}
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一个", "记个", "帮我记一个",
}
for _, prefix := range prefixes {
if strings.HasPrefix(text, prefix) {
text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
break
}
}
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
for _, sep := range suffixSeparators {
if idx := strings.Index(text, sep); idx > 0 {
text = strings.TrimSpace(text[:idx])
break
}
}
text = strings.Trim(text, ",。?! ")
if text == "" {
return strings.TrimSpace(userInput)
}
return text
}

View File

@@ -1,589 +0,0 @@
package agentnode
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的稳定名称。
ToolNameQuickNoteCreateTask = "quick_note_create_task"
// ToolDescQuickNoteCreateTask 是给大模型看的工具职责说明。
ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级"
)
var (
quickNoteDeadlineLayouts = []string{
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04:05",
"2006/01/02 15:04",
"2006.01.02 15:04:05",
"2006.01.02 15:04",
"2006-01-02",
"2006/01/02",
"2006.01.02",
}
quickNoteDateOnlyLayouts = map[string]struct{}{
"2006-01-02": {},
"2006/01/02": {},
"2006.01.02": {},
}
quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[:]\s*(\d{1,2})`)
quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
quickNoteRelativeTokens = []string{
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
}
)
// QuickNoteToolDeps 描述随口记工具所需的外部依赖。
type QuickNoteToolDeps struct {
ResolveUserID func(ctx context.Context) (int, error)
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
}
func (d QuickNoteToolDeps) Validate() error {
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
return nil
}
// QuickNoteToolBundle 是随口记工具集合。
type QuickNoteToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// QuickNoteCreateTaskRequest 是工具层传给业务层的内部请求。
type QuickNoteCreateTaskRequest struct {
UserID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskResult 是业务层回给工具层的结构化结果。
type QuickNoteCreateTaskResult struct {
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskToolInput 是暴露给模型的工具入参。
type QuickNoteCreateTaskToolInput struct {
Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"`
// PriorityGroup 与 tasks.priority 保持一致,取值 1~4。
PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4复杂不重要)"`
// DeadlineAt 支持绝对时间与常见中文相对时间。
DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"`
// UrgencyThresholdAt 表示何时自动进入紧急象限。
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间支持与deadline_at相同格式"`
}
// QuickNoteCreateTaskToolOutput 是返回给模型的结构化结果。
type QuickNoteCreateTaskToolOutput struct {
TaskID int `json:"task_id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
DeadlineAt string `json:"deadline_at,omitempty"`
Message string `json:"message"`
}
// BuildQuickNoteToolBundle 构建随口记工具包。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
if err := deps.Validate(); err != nil {
return nil, err
}
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
if input == nil {
return nil, errors.New("工具参数不能为空")
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
}
if !agentmodel.IsValidTaskPriority(input.PriorityGroup) {
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
}
deadline, err := ParseOptionalDeadline(input.DeadlineAt)
if err != nil {
return nil, err
}
urgencyThresholdAt, err := ParseOptionalDeadline(input.UrgencyThresholdAt)
if err != nil {
return nil, err
}
userID, err := deps.ResolveUserID(ctx)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
}
if userID <= 0 {
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UrgencyThresholdAt: urgencyThresholdAt,
})
if err != nil {
return nil, err
}
if result == nil || result.TaskID <= 0 {
return nil, errors.New("写入任务后返回结果异常")
}
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
}
finalPriority := input.PriorityGroup
if agentmodel.IsValidTaskPriority(result.PriorityGroup) {
finalPriority = result.PriorityGroup
}
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(QuickNoteLocation()).Format(time.RFC3339)
} else if deadline != nil {
deadlineStr = deadline.In(QuickNoteLocation()).Format(time.RFC3339)
}
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
PriorityGroup: finalPriority,
PriorityLabel: agentmodel.PriorityLabelCN(finalPriority),
DeadlineAt: deadlineStr,
Message: fmt.Sprintf("已记录:%s%s", finalTitle, agentmodel.PriorityLabelCN(finalPriority)),
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &QuickNoteToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
// GetInvokableToolByName 通过工具名提取可执行工具实例。
func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
if bundle == nil {
return nil, errors.New("tool bundle is nil")
}
return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name)
}
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
// 调用目的:新链路 quick_note_create 工具复用旧链路成熟的时间解析能力,支持中文相对时间。
func ParseOptionalDeadline(raw string) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return deadline, nil
}
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 调用目的:旧链路 intent/priority 节点在已知 now 基准下解析时间,供新链路复用。
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, _, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
return nil, err
}
if deadline == nil {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
return nil, true, err
}
return nil, false, nil
}
if deadline == nil {
if hasHint {
return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return nil, false, nil
}
return deadline, true, nil
}
// parseOptionalDeadlineFromText 是内部通用时间解析器。
func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
if strings.TrimSpace(value) == "" {
return nil, false, nil
}
loc := QuickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
}
return rel, true, nil
}
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, false, nil
}
func normalizeDeadlineInput(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
replacer := strings.NewReplacer(
"", ":",
"", ",",
"。", ".",
" ", " ",
)
return strings.TrimSpace(replacer.Replace(trimmed))
}
func hasDeadlineHint(value string) bool {
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
quickNoteMDRegex.MatchString(value) ||
quickNoteDateSepRegex.MatchString(value) ||
quickNoteWeekdayRegex.MatchString(value) {
return true
}
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
}
}
return false
}
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
for _, layout := range quickNoteDeadlineLayouts {
var (
parsed time.Time
err error
)
if layout == time.RFC3339 {
parsed, err = time.Parse(layout, value)
if err == nil {
parsed = parsed.In(loc)
}
} else {
parsed, err = time.ParseInLocation(layout, value, loc)
}
if err != nil {
continue
}
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
} else {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
}
return &parsed, true
}
return nil, false
}
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
}
if !hasExplicitClock {
hour, minute = defaultClockByHint(value)
}
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
return &deadline, true, nil
}
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
day, _ := strconv.Atoi(matched[3])
if isValidDate(year, month, day) {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
}
}
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
year := now.Year()
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
if candidate.Before(startOfDay(now)) {
year++
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
}
return candidate, true
}
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
if ok {
return resolveWeekdayDate(now, prefix, target), true
}
}
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
return today.AddDate(0, 0, 3), true
case strings.Contains(value, "后天"):
return today.AddDate(0, 0, 2), true
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
return today.AddDate(0, 0, 1), true
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
return today, true
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
return today.AddDate(0, 0, -1), true
default:
return time.Time{}, false
}
}
func extractClock(value string) (int, int, bool, error) {
hour := 0
minute := 0
hasClock := false
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
if errH != nil || errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = 0
hasClock = true
if len(matched) >= 3 {
if matched[2] == "半" {
minute = 30
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
if errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
minute = m
}
}
}
if !hasClock {
return 0, 0, false, nil
}
if isPMHint(value) && hour < 12 {
hour += 12
}
if isNoonHint(value) && hour >= 1 && hour <= 10 {
hour += 12
}
if strings.Contains(value, "凌晨") && hour == 12 {
hour = 0
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
}
return hour, minute, true, nil
}
func defaultClockByHint(value string) (int, int) {
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
return 9, 0
case strings.Contains(value, "中午"):
return 12, 0
case strings.Contains(value, "下午"):
return 15, 0
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
return 20, 0
default:
return 23, 59
}
}
func isPMHint(value string) bool {
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
}
func toWeekday(chinese string) (time.Weekday, bool) {
switch chinese {
case "一":
return time.Monday, true
case "二":
return time.Tuesday, true
case "三":
return time.Wednesday, true
case "四":
return time.Thursday, true
case "五":
return time.Friday, true
case "六":
return time.Saturday, true
case "日", "天":
return time.Sunday, true
default:
return time.Sunday, false
}
}
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
return candidateThisWeek
default:
if candidateThisWeek.Before(today) {
return candidateThisWeek.AddDate(0, 0, 7)
}
return candidateThisWeek
}
}
// QuickNoteLocation 返回随口记使用的时区Asia/Shanghai
// 调用目的:新链路 quick_note_create 工具格式化时间输出时复用。
func QuickNoteLocation() *time.Location {
loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName)
if err != nil {
return time.Local
}
return loc
}
func quickNoteNowToMinute() time.Time {
return agentshared.NowToMinute()
}
func formatQuickNoteTimeToMinute(t time.Time) string {
return agentshared.FormatMinute(t.In(QuickNoteLocation()))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,571 +0,0 @@
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
agentllm "github.com/LoveLosita/smartflow/backend/agent/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]) + "..."
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,729 +0,0 @@
package agentnode
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentprompt "github.com/LoveLosita/smartflow/backend/agent/prompt"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
)
const (
TaskQueryGraphNodePlan = "task_query.plan"
TaskQueryGraphNodeQuadrant = "task_query.quadrant"
TaskQueryGraphNodeTimeAnchor = "task_query.time_anchor"
TaskQueryGraphNodeQuery = "task_query.query"
TaskQueryGraphNodeReflect = "task_query.reflect"
)
var (
explicitLimitPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\btop\s*(\d{1,2})\b`),
regexp.MustCompile(`前\s*(\d{1,2})\s*(个|条|项)?`),
regexp.MustCompile(`(\d{1,2})\s*(个|条|项)?\s*任务`),
regexp.MustCompile(`给我\s*(\d{1,2})\s*(个|条|项)?`),
}
chineseDigitMap = map[rune]int{
'一': 1,
'二': 2,
'两': 2,
'三': 3,
'四': 4,
'五': 5,
'六': 6,
'七': 7,
'八': 8,
'九': 9,
'十': 10,
}
)
// TaskQueryGraphRunInput 描述一次任务查询图运行需要的依赖。
type TaskQueryGraphRunInput struct {
Model *ark.ChatModel
State *agentmodel.TaskQueryState
Deps TaskQueryToolDeps
EmitStage func(stage, detail string)
}
// TaskQueryNodes 是任务查询图的节点容器。
//
// 职责边界:
// 1. 负责承接请求级依赖,并向 graph 暴露可直接挂载的方法。
// 2. 不负责 graph 编译、service 接线和持久化。
type TaskQueryNodes struct {
input TaskQueryGraphRunInput
queryTool tool.InvokableTool
emitStage agentstream.StageEmitter
}
func NewTaskQueryNodes(input TaskQueryGraphRunInput, queryTool tool.InvokableTool) (*TaskQueryNodes, error) {
if input.Model == nil {
return nil, fmt.Errorf("task query nodes: model is nil")
}
if input.State == nil {
return nil, fmt.Errorf("task query nodes: state is nil")
}
if err := input.Deps.Validate(); err != nil {
return nil, err
}
if queryTool == nil {
return nil, fmt.Errorf("task query nodes: queryTool is nil")
}
return &TaskQueryNodes{
input: input,
queryTool: queryTool,
emitStage: agentstream.WrapStageEmitter(input.EmitStage),
}, nil
}
// Plan 负责把用户原话规划成结构化查询计划。
func (n *TaskQueryNodes) Plan(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) {
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in plan node")
}
n.emitStage("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。")
planned, err := agentllm.PlanTaskQuery(ctx, n.input.Model, st.RequestNowText, st.UserMessage)
if err != nil || planned == nil {
st.UserGoal = "查询任务"
st.Plan = defaultTaskQueryPlan()
return st, nil
}
st.UserGoal = strings.TrimSpace(planned.UserGoal)
if st.UserGoal == "" {
st.UserGoal = "查询任务"
}
st.Plan = normalizeTaskQueryPlan(*planned)
// 1. 若用户原话里明确指定了返回条数,则以后端识别结果为准。
// 2. 这样可以避免规划模型漏掉数量要求,或后续反思 patch 意外改写 limit。
if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found {
st.ExplicitLimit = explicitLimit
st.Plan.Limit = explicitLimit
}
return st, nil
}
// NormalizeQuadrant 负责把象限参数去重并统一成稳定顺序。
func (n *TaskQueryNodes) NormalizeQuadrant(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) {
_ = ctx
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in quadrant node")
}
n.emitStage("task_query.quadrant.routing", "正在归一化象限筛选范围。")
st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants)
return st, nil
}
// AnchorTime 负责把时间文本边界解析成可执行时间对象。
func (n *TaskQueryNodes) AnchorTime(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) {
_ = ctx
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in time anchor node")
}
n.emitStage("task_query.time.anchoring", "正在锁定时间过滤边界。")
applyTimeAnchorOnPlan(&st.Plan)
return st, nil
}
// Query 负责真正调用工具查询任务。
func (n *TaskQueryNodes) Query(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) {
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in query node")
}
n.emitStage("task_query.tool.querying", "正在查询任务数据。")
items, err := n.executePlanByTool(ctx, st.Plan)
if err != nil {
st.LastQueryItems = make([]agentmodel.TaskQueryItem, 0)
st.LastQueryTotal = 0
st.ReflectReason = "查询工具执行失败"
return st, nil
}
st.LastQueryItems = items
st.LastQueryTotal = len(items)
// 1. 如果首轮为空且还没自动放宽过,则做一次可解释的自动放宽。
// 2. 放宽范围仅限关键词、完成状态、时间边界,不主动改象限与 limit避免语义漂移。
if st.LastQueryTotal == 0 && !st.AutoBroadenApplied {
broadenedPlan, changed := autoBroadenPlan(st.Plan)
if changed {
st.AutoBroadenApplied = true
st.Plan = broadenedPlan
n.emitStage("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。")
retryItems, retryErr := n.executePlanByTool(ctx, st.Plan)
if retryErr == nil {
st.LastQueryItems = retryItems
st.LastQueryTotal = len(retryItems)
}
}
}
return st, nil
}
// Reflect 负责判断当前结果是否满足用户诉求,并决定是否重试。
func (n *TaskQueryNodes) Reflect(ctx context.Context, st *agentmodel.TaskQueryState) (*agentmodel.TaskQueryState, error) {
if st == nil {
return nil, fmt.Errorf("task query graph: nil state in reflect node")
}
n.emitStage("task_query.reflecting", "正在判断结果是否贴合你的需求。")
reflectPrompt := agentprompt.BuildTaskQueryReflectUserPrompt(
st.RequestNowText,
st.UserMessage,
st.UserGoal,
summarizeTaskQueryPlan(st.Plan),
st.RetryCount,
st.MaxReflectRetry,
summarizeTaskQueryItems(st.LastQueryItems, 6),
)
reflectResult, err := agentllm.ReflectTaskQuery(ctx, n.input.Model, reflectPrompt)
if err != nil || reflectResult == nil {
st.NeedRetry = false
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
return st, nil
}
st.ReflectReason = strings.TrimSpace(reflectResult.Reason)
if reflectResult.Satisfied {
st.NeedRetry = false
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
return st, nil
}
if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry {
st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit)
st.RetryCount++
st.NeedRetry = true
if reply := strings.TrimSpace(reflectResult.Reply); reply != "" {
st.FinalReply = reply
}
return st, nil
}
st.NeedRetry = false
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
return st, nil
}
func (n *TaskQueryNodes) NextAfterReflect(ctx context.Context, st *agentmodel.TaskQueryState) (string, error) {
_ = ctx
if st != nil && st.NeedRetry {
return TaskQueryGraphNodeQuery, nil
}
return compose.END, nil
}
func (n *TaskQueryNodes) executePlanByTool(ctx context.Context, plan agentmodel.TaskQueryPlan) ([]agentmodel.TaskQueryItem, error) {
if n.queryTool == nil {
return nil, fmt.Errorf("task query tool is nil")
}
merged := make([]agentmodel.TaskQueryItem, 0, plan.Limit)
seen := make(map[int]struct{}, plan.Limit*2)
runOne := func(quadrant *int) error {
input := TaskQueryToolInput{
Quadrant: quadrant,
SortBy: plan.SortBy,
Order: plan.Order,
Limit: plan.Limit,
Keyword: plan.Keyword,
DeadlineBefore: plan.DeadlineBeforeText,
DeadlineAfter: plan.DeadlineAfterText,
}
includeCompleted := plan.IncludeCompleted
input.IncludeCompleted = &includeCompleted
rawInput, err := json.Marshal(input)
if err != nil {
return err
}
rawOutput, err := n.queryTool.InvokableRun(ctx, string(rawInput))
if err != nil {
return err
}
parsed, err := agentllm.ParseJSONObject[TaskQueryToolOutput](rawOutput)
if err != nil {
return err
}
for _, item := range parsed.Items {
if _, exists := seen[item.ID]; exists {
continue
}
seen[item.ID] = struct{}{}
merged = append(merged, item)
}
return nil
}
if len(plan.Quadrants) == 0 {
if err := runOne(nil); err != nil {
return nil, err
}
} else {
for _, quadrant := range plan.Quadrants {
q := quadrant
if err := runOne(&q); err != nil {
return nil, err
}
}
}
sortTaskQueryItems(merged, plan)
if len(merged) > plan.Limit {
merged = merged[:plan.Limit]
}
return merged, nil
}
func defaultTaskQueryPlan() agentmodel.TaskQueryPlan {
return agentmodel.TaskQueryPlan{
SortBy: "deadline",
Order: "asc",
Limit: agentmodel.DefaultTaskQueryLimit,
IncludeCompleted: false,
}
}
func normalizeTaskQueryPlan(raw agentllm.TaskQueryPlanOutput) agentmodel.TaskQueryPlan {
plan := defaultTaskQueryPlan()
plan.Quadrants = normalizeQuadrants(raw.Quadrants)
if sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy)); sortBy == "deadline" || sortBy == "priority" || sortBy == "id" {
plan.SortBy = sortBy
}
if order := strings.ToLower(strings.TrimSpace(raw.Order)); order == "asc" || order == "desc" {
plan.Order = order
}
if raw.Limit > 0 {
plan.Limit = raw.Limit
}
if plan.Limit > agentmodel.MaxTaskQueryLimit {
plan.Limit = agentmodel.MaxTaskQueryLimit
}
if plan.Limit <= 0 {
plan.Limit = agentmodel.DefaultTaskQueryLimit
}
if raw.IncludeCompleted != nil {
plan.IncludeCompleted = *raw.IncludeCompleted
}
plan.Keyword = strings.TrimSpace(raw.Keyword)
plan.DeadlineBeforeText = strings.TrimSpace(raw.DeadlineBefore)
plan.DeadlineAfterText = strings.TrimSpace(raw.DeadlineAfter)
applyTimeAnchorOnPlan(&plan)
return plan
}
func normalizeQuadrants(quadrants []int) []int {
if len(quadrants) == 0 {
return nil
}
seen := make(map[int]struct{}, len(quadrants))
result := make([]int, 0, len(quadrants))
for _, quadrant := range quadrants {
if quadrant < 1 || quadrant > 4 {
continue
}
if _, exists := seen[quadrant]; exists {
continue
}
seen[quadrant] = struct{}{}
result = append(result, quadrant)
}
sort.Ints(result)
if len(result) == 0 || len(result) == 4 {
return nil
}
return result
}
func applyTimeAnchorOnPlan(plan *agentmodel.TaskQueryPlan) {
if plan == nil {
return
}
before, errBefore := parseTaskQueryBoundaryTime(plan.DeadlineBeforeText, true)
after, errAfter := parseTaskQueryBoundaryTime(plan.DeadlineAfterText, false)
if errBefore != nil {
plan.DeadlineBefore = nil
plan.DeadlineBeforeText = ""
} else {
plan.DeadlineBefore = before
}
if errAfter != nil {
plan.DeadlineAfter = nil
plan.DeadlineAfterText = ""
} else {
plan.DeadlineAfter = after
}
if plan.DeadlineBefore != nil && plan.DeadlineAfter != nil && plan.DeadlineAfter.After(*plan.DeadlineBefore) {
plan.DeadlineBefore = nil
plan.DeadlineAfter = nil
plan.DeadlineBeforeText = ""
plan.DeadlineAfterText = ""
}
}
func autoBroadenPlan(plan agentmodel.TaskQueryPlan) (agentmodel.TaskQueryPlan, bool) {
broadened := plan
changed := false
if strings.TrimSpace(broadened.Keyword) != "" {
broadened.Keyword = ""
changed = true
}
if !broadened.IncludeCompleted {
broadened.IncludeCompleted = true
changed = true
}
if broadened.DeadlineBefore != nil || broadened.DeadlineAfter != nil || broadened.DeadlineBeforeText != "" || broadened.DeadlineAfterText != "" {
broadened.DeadlineBefore = nil
broadened.DeadlineAfter = nil
broadened.DeadlineBeforeText = ""
broadened.DeadlineAfterText = ""
changed = true
}
return broadened, changed
}
func applyRetryPatch(plan agentmodel.TaskQueryPlan, patch agentllm.TaskQueryRetryPatch, explicitLimit int) agentmodel.TaskQueryPlan {
next := plan
changed := false
if patch.Quadrants != nil {
next.Quadrants = normalizeQuadrants(*patch.Quadrants)
changed = true
}
if patch.SortBy != nil {
sortBy := strings.ToLower(strings.TrimSpace(*patch.SortBy))
if sortBy == "deadline" || sortBy == "priority" || sortBy == "id" {
next.SortBy = sortBy
changed = true
}
}
if patch.Order != nil {
order := strings.ToLower(strings.TrimSpace(*patch.Order))
if order == "asc" || order == "desc" {
next.Order = order
changed = true
}
}
if patch.Limit != nil && explicitLimit <= 0 {
limit := *patch.Limit
if limit <= 0 {
limit = agentmodel.DefaultTaskQueryLimit
}
if limit > agentmodel.MaxTaskQueryLimit {
limit = agentmodel.MaxTaskQueryLimit
}
next.Limit = limit
changed = true
}
if patch.IncludeCompleted != nil {
next.IncludeCompleted = *patch.IncludeCompleted
changed = true
}
if patch.Keyword != nil {
next.Keyword = strings.TrimSpace(*patch.Keyword)
changed = true
}
if patch.DeadlineBefore != nil {
next.DeadlineBeforeText = strings.TrimSpace(*patch.DeadlineBefore)
changed = true
}
if patch.DeadlineAfter != nil {
next.DeadlineAfterText = strings.TrimSpace(*patch.DeadlineAfter)
changed = true
}
if changed {
applyTimeAnchorOnPlan(&next)
}
if explicitLimit > 0 {
next.Limit = explicitLimit
}
return next
}
func summarizeTaskQueryPlan(plan agentmodel.TaskQueryPlan) string {
quadrants := "全部象限"
if len(plan.Quadrants) > 0 {
parts := make([]string, 0, len(plan.Quadrants))
for _, quadrant := range plan.Quadrants {
parts = append(parts, strconv.Itoa(quadrant))
}
quadrants = strings.Join(parts, ",")
}
return fmt.Sprintf(
"quadrants=%s sort=%s/%s limit=%d include_completed=%t keyword=%s before=%s after=%s",
quadrants,
plan.SortBy,
plan.Order,
plan.Limit,
plan.IncludeCompleted,
emptyToDash(plan.Keyword),
emptyToDash(plan.DeadlineBeforeText),
emptyToDash(plan.DeadlineAfterText),
)
}
func summarizeTaskQueryItems(items []agentmodel.TaskQueryItem, max int) string {
if len(items) == 0 {
return "无结果"
}
if max <= 0 {
max = 5
}
if len(items) > max {
items = items[:max]
}
lines := make([]string, 0, len(items))
for _, item := range items {
lines = append(lines, fmt.Sprintf(
"- #%d %s | 象限=%d | 完成=%t | 截止=%s",
item.ID,
item.Title,
item.PriorityGroup,
item.IsCompleted,
emptyToDash(item.DeadlineAt),
))
}
return strings.Join(lines, "\n")
}
func buildTaskQueryFallbackReply(items []agentmodel.TaskQueryItem) string {
if len(items) == 0 {
return "我这边暂时没找到匹配的任务。你可以再补一句,比如“按截止时间最早的前 3 个”或“只看简单不重要”。"
}
preview := items
if len(preview) > 3 {
preview = preview[:3]
}
lines := make([]string, 0, len(preview))
for _, item := range preview {
lines = append(lines, fmt.Sprintf("%s%s", item.Title, item.PriorityLabel))
}
return fmt.Sprintf("我先给你筛到这些:%s。要不要我再按“更紧急”或“更简单”继续细化", strings.Join(lines, "、"))
}
func buildTaskQueryFinalReply(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan, llmReply string) string {
if len(items) == 0 {
base := buildTaskQueryFallbackReply(items)
if strings.TrimSpace(llmReply) == "" {
return base
}
return strings.TrimSpace(llmReply) + "\n" + base
}
desired := plan.Limit
if desired <= 0 {
desired = agentmodel.DefaultTaskQueryLimit
}
if desired > agentmodel.MaxTaskQueryLimit {
desired = agentmodel.MaxTaskQueryLimit
}
showCount := desired
if len(items) < showCount {
showCount = len(items)
}
preview := items[:showCount]
lines := make([]string, 0, len(preview))
for idx, item := range preview {
deadline := strings.TrimSpace(item.DeadlineAt)
if deadline == "" {
deadline = "无明确截止时间"
}
status := "未完成"
if item.IsCompleted {
status = "已完成"
}
lines = append(lines, fmt.Sprintf(
"%d. %s%s%s截止%s",
idx+1,
item.Title,
item.PriorityLabel,
status,
deadline,
))
}
header := fmt.Sprintf("给你整理了 %d 条任务:", showCount)
if lead := extractSafeReplyLead(llmReply); lead != "" {
header = lead + "\n" + header
}
reply := header + "\n" + strings.Join(lines, "\n")
if len(items) > showCount {
reply += fmt.Sprintf("\n另外还有 %d 条匹配任务,要不要我继续往下列?", len(items)-showCount)
}
return reply
}
func extractSafeReplyLead(llmReply string) string {
text := strings.TrimSpace(llmReply)
if text == "" {
return ""
}
lower := strings.ToLower(text)
if strings.Contains(text, "\n") ||
strings.Contains(text, "#") ||
strings.Contains(lower, "1.") ||
strings.Contains(text, "1、") ||
strings.Contains(text, "以下是") {
return ""
}
if len([]rune(text)) > 30 {
return ""
}
return text
}
func sortTaskQueryItems(items []agentmodel.TaskQueryItem, plan agentmodel.TaskQueryPlan) {
if len(items) <= 1 {
return
}
sortBy := strings.ToLower(strings.TrimSpace(plan.SortBy))
order := strings.ToLower(strings.TrimSpace(plan.Order))
if order != "desc" {
order = "asc"
}
sort.SliceStable(items, func(i, j int) bool {
left := items[i]
right := items[j]
switch sortBy {
case "priority":
if left.PriorityGroup != right.PriorityGroup {
if order == "desc" {
return left.PriorityGroup > right.PriorityGroup
}
return left.PriorityGroup < right.PriorityGroup
}
return left.ID > right.ID
case "id":
if order == "desc" {
return left.ID > right.ID
}
return left.ID < right.ID
default:
leftTime, leftOK := parseTaskQueryItemDeadline(left.DeadlineAt)
rightTime, rightOK := parseTaskQueryItemDeadline(right.DeadlineAt)
if leftOK && rightOK {
if !leftTime.Equal(rightTime) {
if order == "desc" {
return leftTime.After(rightTime)
}
return leftTime.Before(rightTime)
}
return left.ID > right.ID
}
if leftOK && !rightOK {
return true
}
if !leftOK && rightOK {
return false
}
return left.ID > right.ID
}
})
}
func parseTaskQueryItemDeadline(raw string) (time.Time, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return time.Time{}, false
}
parsed, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local)
if err != nil {
return time.Time{}, false
}
return parsed, true
}
func emptyToDash(text string) string {
if strings.TrimSpace(text) == "" {
return "-"
}
return strings.TrimSpace(text)
}
// extractExplicitLimitFromUser 从用户原话里提取显式条数要求。
//
// 步骤说明:
// 1. 先识别阿拉伯数字表达例如“前3个”“给我5条”“top 10”。
// 2. 再识别中文数字表达,例如“前五个”“来三个”。
// 3. 最终统一约束到 1~20 范围内。
func extractExplicitLimitFromUser(userMessage string) (int, bool) {
text := strings.TrimSpace(userMessage)
if text == "" {
return 0, false
}
for _, pattern := range explicitLimitPatterns {
matched := pattern.FindStringSubmatch(text)
if len(matched) < 2 {
continue
}
number, err := strconv.Atoi(strings.TrimSpace(matched[1]))
if err != nil {
continue
}
return normalizeExplicitLimit(number)
}
for _, prefix := range []string{"前", "来", "给我"} {
for digit, number := range chineseDigitMap {
token := prefix + string(digit)
if strings.Contains(text, token) {
return normalizeExplicitLimit(number)
}
for _, suffix := range []string{"个", "条", "项"} {
if strings.Contains(text, token+suffix) {
return normalizeExplicitLimit(number)
}
}
}
}
return 0, false
}
func normalizeExplicitLimit(number int) (int, bool) {
if number <= 0 {
return 0, false
}
if number > agentmodel.MaxTaskQueryLimit {
number = agentmodel.MaxTaskQueryLimit
}
return number, true
}

View File

@@ -1,286 +0,0 @@
package agentnode
import (
"context"
"errors"
"fmt"
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
ToolNameTaskQueryTasks = "query_tasks"
ToolDescTaskQueryTasks = "按象限、关键词、截止时间筛选并排序任务,返回结构化任务列表"
)
var taskQueryTimeLayouts = []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
// TaskQueryToolDeps 描述任务查询工具依赖的外部查询能力。
//
// 职责边界:
// 1. QueryTasks 负责读取真实任务数据。
// 2. 工具层只负责参数校验、归一化和结构化输出,不直接耦合 DAO。
type TaskQueryToolDeps struct {
QueryTasks func(ctx context.Context, req TaskQueryRequest) ([]TaskQueryTaskRecord, error)
}
// Validate 负责校验任务查询工具依赖是否齐全。
func (d TaskQueryToolDeps) Validate() error {
if d.QueryTasks == nil {
return errors.New("task query tool deps: QueryTasks is nil")
}
return nil
}
// TaskQueryToolBundle 同时返回工具实例和工具元信息。
//
// 职责边界:
// 1. Tools 给执行节点使用。
// 2. ToolInfos 给模型注册 schema 使用。
type TaskQueryToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// TaskQueryRequest 是工具层传给业务层的内部查询请求。
type TaskQueryRequest struct {
UserID int
Quadrant *int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryTaskRecord 是业务层返回给工具层的任务记录。
type TaskQueryTaskRecord struct {
ID int
Title string
PriorityGroup int
IsCompleted bool
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// TaskQueryToolInput 是暴露给大模型的工具入参。
type TaskQueryToolInput struct {
Quadrant *int `json:"quadrant,omitempty" jsonschema:"description=可选象限(1~4)"`
SortBy string `json:"sort_by,omitempty" jsonschema:"description=排序字段(deadline|priority|id)"`
Order string `json:"order,omitempty" jsonschema:"description=排序方向(asc|desc)"`
Limit int `json:"limit,omitempty" jsonschema:"description=返回条数默认5上限20"`
IncludeCompleted *bool `json:"include_completed,omitempty" jsonschema:"description=是否包含已完成任务默认false"`
Keyword string `json:"keyword,omitempty" jsonschema:"description=可选标题关键词,模糊匹配"`
DeadlineBefore string `json:"deadline_before,omitempty" jsonschema:"description=可选截止时间上界支持RFC3339或yyyy-MM-dd HH:mm"`
DeadlineAfter string `json:"deadline_after,omitempty" jsonschema:"description=可选截止时间下界支持RFC3339或yyyy-MM-dd HH:mm"`
}
// TaskQueryToolOutput 是返回给模型的结构化结果。
type TaskQueryToolOutput struct {
Total int `json:"total"`
Items []agentmodel.TaskQueryItem `json:"items"`
}
// BuildTaskQueryToolBundle 负责构建任务查询工具包。
//
// 步骤说明:
// 1. 先校验依赖是否完整,避免生成一个运行时必定失败的工具。
// 2. 再把输入归一化成内部请求,调用业务查询函数拿到真实数据。
// 3. 最后把业务记录转换成统一的轻量任务视图,供模型和反思节点复用。
func BuildTaskQueryToolBundle(ctx context.Context, deps TaskQueryToolDeps) (*TaskQueryToolBundle, error) {
if err := deps.Validate(); err != nil {
return nil, err
}
queryTool, err := toolutils.InferTool(
ToolNameTaskQueryTasks,
ToolDescTaskQueryTasks,
func(ctx context.Context, input *TaskQueryToolInput) (*TaskQueryToolOutput, error) {
req, err := normalizeTaskQueryToolInput(input)
if err != nil {
return nil, err
}
records, err := deps.QueryTasks(ctx, req)
if err != nil {
return nil, err
}
items := make([]agentmodel.TaskQueryItem, 0, len(records))
for _, record := range records {
items = append(items, agentmodel.TaskQueryItem{
ID: record.ID,
Title: record.Title,
PriorityGroup: record.PriorityGroup,
PriorityLabel: agentmodel.PriorityLabelCN(record.PriorityGroup),
IsCompleted: record.IsCompleted,
DeadlineAt: formatTaskQueryTime(record.DeadlineAt),
UrgencyThresholdAt: formatTaskQueryTime(record.UrgencyThresholdAt),
})
}
return &TaskQueryToolOutput{
Total: len(items),
Items: items,
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建任务查询工具失败: %w", err)
}
tools := []tool.BaseTool{queryTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &TaskQueryToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
// GetTaskQueryInvokableToolByName 按工具名提取可执行工具。
func GetTaskQueryInvokableToolByName(bundle *TaskQueryToolBundle, name string) (tool.InvokableTool, error) {
if bundle == nil {
return nil, errors.New("task query tool bundle is nil")
}
return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name)
}
// normalizeTaskQueryToolInput 负责参数默认值回填与合法性校验。
//
// 步骤说明:
// 1. 先准备默认值,保证空参数也能执行一次合理查询。
// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。
// 3. 若上下界冲突,则直接返回错误,避免查出必为空的结果。
func normalizeTaskQueryToolInput(input *TaskQueryToolInput) (TaskQueryRequest, error) {
req := TaskQueryRequest{
SortBy: "deadline",
Order: "asc",
Limit: agentmodel.DefaultTaskQueryLimit,
IncludeCompleted: false,
}
if input == nil {
return req, nil
}
if input.Quadrant != nil {
if *input.Quadrant < 1 || *input.Quadrant > 4 {
return TaskQueryRequest{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", *input.Quadrant)
}
quadrant := *input.Quadrant
req.Quadrant = &quadrant
}
if sortBy := strings.ToLower(strings.TrimSpace(input.SortBy)); sortBy != "" {
req.SortBy = sortBy
}
switch req.SortBy {
case "deadline", "priority", "id":
default:
return TaskQueryRequest{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", req.SortBy)
}
if order := strings.ToLower(strings.TrimSpace(input.Order)); order != "" {
req.Order = order
}
switch req.Order {
case "asc", "desc":
default:
return TaskQueryRequest{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", req.Order)
}
if input.Limit > 0 {
req.Limit = input.Limit
}
if req.Limit > agentmodel.MaxTaskQueryLimit {
req.Limit = agentmodel.MaxTaskQueryLimit
}
if req.Limit <= 0 {
req.Limit = agentmodel.DefaultTaskQueryLimit
}
if input.IncludeCompleted != nil {
req.IncludeCompleted = *input.IncludeCompleted
}
req.Keyword = strings.TrimSpace(input.Keyword)
before, err := parseTaskQueryBoundaryTime(input.DeadlineBefore, true)
if err != nil {
return TaskQueryRequest{}, err
}
after, err := parseTaskQueryBoundaryTime(input.DeadlineAfter, false)
if err != nil {
return TaskQueryRequest{}, err
}
req.DeadlineBefore = before
req.DeadlineAfter = after
if req.DeadlineBefore != nil && req.DeadlineAfter != nil && req.DeadlineAfter.After(*req.DeadlineBefore) {
return TaskQueryRequest{}, errors.New("deadline_after 不能晚于 deadline_before")
}
return req, nil
}
// parseTaskQueryBoundaryTime 解析截止时间上下界。
//
// 职责边界:
// 1. isUpper=true 时,纯日期补到当天 23:59:59。
// 2. isUpper=false 时,纯日期补到当天 00:00:00。
// 3. 不支持的格式直接返回错误,由调用方决定是否回退。
func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, nil
}
loc := time.Local
for _, layout := range taskQueryTimeLayouts {
var (
parsed time.Time
err error
)
if layout == time.RFC3339 {
parsed, err = time.Parse(layout, text)
if err == nil {
parsed = parsed.In(loc)
}
} else {
parsed, err = time.ParseInLocation(layout, text, loc)
}
if err != nil {
continue
}
if layout == "2006-01-02" {
if isUpper {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc)
} else {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc)
}
}
return &parsed, nil
}
return nil, fmt.Errorf("时间格式不支持: %s", text)
}
// formatTaskQueryTime 负责把内部时间格式化为给模型展示的分钟级文本。
func formatTaskQueryTime(value *time.Time) string {
if value == nil {
return ""
}
return value.In(time.Local).Format("2006-01-02 15:04")
}

View File

@@ -1,74 +0,0 @@
package agentnode
import (
"context"
"errors"
"fmt"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// collectToolInfos 负责批量提取工具元信息,供模型注册与工具索引复用。
//
// 职责边界:
// 1. 只负责调用 tool.Info 并聚合返回结果。
// 2. 不负责校验工具是否可执行,也不负责按名称检索工具。
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, currentTool := range tools {
info, err := currentTool.Info(ctx)
if err != nil {
return nil, fmt.Errorf("读取工具信息失败: %w", err)
}
infos = append(infos, info)
}
return infos, nil
}
// buildInvokableToolMap 负责把工具列表转换成“工具名 -> 可执行工具”的索引表。
//
// 步骤说明:
// 1. 先校验 tools 与 infos 是否一一对应,避免后续按下标取值时出现错配。
// 2. 再校验每个工具都带有合法名字,并且确实实现了 InvokableTool 接口。
// 3. 任一步失败都立即返回错误,避免 graph 在运行期拿到半残缺的工具集合。
func buildInvokableToolMap(tools []tool.BaseTool, infos []*schema.ToolInfo) (map[string]tool.InvokableTool, error) {
if len(tools) == 0 || len(infos) == 0 {
return nil, errors.New("tool bundle is empty")
}
if len(tools) != len(infos) {
return nil, errors.New("tool bundle mismatch")
}
result := make(map[string]tool.InvokableTool, len(tools))
for idx, currentTool := range tools {
info := infos[idx]
if info == nil || strings.TrimSpace(info.Name) == "" {
return nil, errors.New("tool info is invalid")
}
invokable, ok := currentTool.(tool.InvokableTool)
if !ok {
return nil, fmt.Errorf("tool %s is not invokable", info.Name)
}
result[info.Name] = invokable
}
return result, nil
}
// getInvokableToolByName 负责从工具集合中提取指定名称的可执行工具。
//
// 职责边界:
// 1. 负责复用统一索引逻辑,避免各业务链路重复写名称查找代码。
// 2. 不负责兜底选择其他工具;未命中时直接返回错误,由上层决定如何处理。
func getInvokableToolByName(tools []tool.BaseTool, infos []*schema.ToolInfo, name string) (tool.InvokableTool, error) {
invokableMap, err := buildInvokableToolMap(tools, infos)
if err != nil {
return nil, err
}
invokable, ok := invokableMap[name]
if !ok {
return nil, fmt.Errorf("tool %s not found", name)
}
return invokable, nil
}

View File

@@ -1,46 +0,0 @@
package agentprompt
const (
// QuickNotePlanPrompt 用于“单请求聚合规划”。
QuickNotePlanPrompt = `你是 SmartMate 的任务聚合规划器。
你将基于用户输入,一次性输出任务规划结果,供后端直接写库。
必须完成以下五件事:
1) 提取任务标题 title简洁明确
2) 归一化截止时间 deadline_at若存在时间线索必须输出绝对时间
3) 评估紧急分界时间 urgency_threshold_at当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,不可为空)。
4) 评估优先级 priority_group1~4
5) 生成一句轻松跟进句 banter不超过30字
输出要求:
- 仅输出 JSON不要 markdown不要解释。
- deadline_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- urgency_threshold_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。
- priority_group 仅允许 1|2|3|4。
- banter 不得新增或修改任务事实(任务名、时间、优先级)。`
// QuickNoteIntentPrompt 用于第一阶段:判断用户输入是否属于“随口记”。
QuickNoteIntentPrompt = `你是 SmartMate 的“随口记分诊器”。
请判断用户输入是否表达了“帮我记一个任务/日程”的需求。
- 若是,请提取任务标题与时间线索。
- 时间处理必须严谨:若出现相对时间(如明天/后天/下周一/今晚),必须基于上文给出的“当前时间”换算为绝对时间。
- 若不是,请明确返回“非随口记意图”。
- 不要声称已经写入数据库。`
// QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级,并评估紧急分界线。
QuickNotePriorityPrompt = `你是 SmartMate 的任务优先级评估器。
根据任务内容、时间约束和执行成本,输出优先级 priority_group
1=重要且紧急2=重要不紧急3=简单不重要4=不简单不重要。
请给出简短理由,理由必须可解释。
若你认为该任务需要后续自动平移,请额外输出 urgency_threshold_at绝对时间yyyy-MM-dd HH:mm否则输出空字符串。`
// QuickNoteReplyBanterPrompt 用于随口记成功后的“轻松跟进句”生成。
QuickNoteReplyBanterPrompt = `你是 SmartMate 的中文口语化回复润色助手。
请根据用户原话生成一句轻松自然的跟进话术,让回复更有温度。
要求:
- 只输出一句中文不超过30字。
- 顺着用户创建提醒的主题延伸,就像聊天时友好的问候一样,记得动用你知道的对应领域的知识。例如(注意,只是例子):用户说提醒他明天早上吃麦当劳,你润色回复应该类似这样:"薯饼记得趁热吃哦~"。
- 可以轻微调侃,但语气友好,不刻薄。
- 不得新增或修改任务事实(任务名、时间、优先级)。
- 不要输出 markdown、编号、引号。`
)

View File

@@ -1,24 +0,0 @@
package agentprompt
import (
"fmt"
"strings"
)
const routeSystemPrompt = `
你是 SmartMate 的一级路由助手。
你的职责不是回答用户,而是判断这条消息更适合走哪条能力链路。
当前 Agent 仍在逐批迁移阶段,因此这里只先保留 prompt 落点与职责说明。
真正迁移旧 route 提示词时,应把正式版本收敛到这里,而不是散落在 node 或 service 中。
`
// BuildRouteSystemPrompt 返回一级路由系统提示词。
func BuildRouteSystemPrompt() string {
return strings.TrimSpace(routeSystemPrompt)
}
// BuildRouteUserPrompt 构造一级路由用户提示词。
func BuildRouteUserPrompt(userInput string) string {
return fmt.Sprintf("用户输入:%s", strings.TrimSpace(userInput))
}

View File

@@ -1,172 +0,0 @@
package agentprompt
const (
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
//
// 职责边界:
// 1. 负责把自然语言转成结构化 JSON供后端节点分流与执行
// 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
// 3. 不负责做排程计算,不负责做工具调用。
SchedulePlanIntentPrompt = `你是 SmartMate 的排程意图分析器。
请根据用户输入,提取排程意图与约束条件。
必须完成以下任务:
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_scopesmall / medium / large
- small局部微调通常只改少量时段不需要重建全局。
- medium中等调整需要周级再平衡但不必全量重粗排。
- large大范围调整或首次创建排程或约束变化很大需要完整重排。
9) 输出 reason简短中文理由<=30字与 confidence0~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 = `你是 SmartMate 日内排程优化器。
你将收到一天内的日程安排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_atask_item_idtask_btask_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 = `你是 SmartMate 周级排程配平器。
单日内的排程已优化完毕,你当前只负责“单周微调”。
## 数据可靠性前提(必须接受)
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_btask_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 = `你是 SmartMate 排程方案总结专家。
你的任务是为用户生成一段友好、自然的排程总结。
要求:
1. 用 2-3 句话概括方案亮点。
2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
3. 若用户有约束,说明方案如何满足这些约束。
4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
5. 语气温暖自然。
6. 只输出纯文本,不要输出 JSON。`
)

View File

@@ -1,188 +0,0 @@
package agentprompt
const (
// ScheduleRefineContractPrompt 负责把用户自然语言微调请求抽取为结构化契约。
ScheduleRefineContractPrompt = `你是 SmartMate 的排程微调契约分析器。
你会收到:当前时间、用户请求、已有排程摘要。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"intent": "一句话概括本轮微调目标",
"strategy": "local_adjust|keep",
"hard_requirements": ["必须满足的硬性要求1","必须满足的硬性要求2"],
"hard_assertions": [
{
"metric": "source_move_ratio_percent|all_source_tasks_in_target_scope|source_remaining_count",
"operator": "==|<=|>=|between",
"value": 50,
"min": 50,
"max": 50,
"week": 17,
"target_week": 16
}
],
"keep_relative_order": true,
"order_scope": "global|week"
}
规则:
1. 除非用户明确表达“允许打乱顺序/顺序无所谓”keep_relative_order 默认 true。
2. 仅当用户明确放宽顺序时keep_relative_order 才允许为 falseorder_scope 默认 "global"。
3. 只要涉及移动任务strategy 必须是 local_adjust仅在无需改动时才用 keep。
4. hard_requirements 必须可验证,避免空泛描述。
5. hard_assertions 必须尽量结构化,避免只给自然语言目标。`
// ScheduleRefinePlannerPrompt 只负责生成“执行路径”,不直接执行动作。
ScheduleRefinePlannerPrompt = `你是 SmartMate 的排程微调 Planner。
你会收到:用户请求、契约、最近动作观察。
请只输出 JSON不要 Markdown不要解释不要代码块
{
"summary": "本阶段执行策略一句话",
"steps": ["步骤1","步骤2","步骤3"]
}
规则:
1. steps 保持 3~4 条,优先“先取证再动作”。
2. summary <= 36 字,单步 <= 28 字。
3. 若目标是“均匀分散”steps 必须体现 SpreadEven 且包含“成功后才收口”的硬条件。
4. 若目标是“上下文切换最少/同科目连续”steps 必须体现 MinContextSwitch 且包含“成功后才收口”的硬条件。
5. 不要输出半截 JSON。`
// ScheduleRefineReactPrompt 用于“单任务微步 ReAct”执行器。
ScheduleRefineReactPrompt = `你是 SmartMate 的单任务微步 ReAct 执行器。
当前只处理一个任务CURRENT_TASK不能发散到其它任务的主动改动。
你每轮只能做两件事之一:
1) 调用一个工具(基础工具或复合工具)
2) 输出 done=true 结束当前任务
工具分组:
- 基础工具QueryTargetTasks / QueryAvailableSlots / Move / Swap / BatchMove / Verify
- 复合工具SpreadEven / MinContextSwitch
工具说明(按职责):
1. QueryTargetTasks查询候选任务集合只读
常用参数week/week_filter/day_of_week/task_item_ids/status。
适用:先摸清“有哪些任务可动、当前在哪”。
2. QueryAvailableSlots查询可放置坑位只读默认先纯空位必要时补可嵌入位
常用参数week/week_filter/day_of_week/span/limit/allow_embed/exclude_sections。
适用Move 前先拿可落点清单。
3. Move移动单个任务到目标坑位写操作
必要参数task_item_id,to_week,to_day,to_section_from,to_section_to。
适用:单任务精确挪动。
4. Swap交换两个任务坑位写操作
必要参数task_a,task_b。
适用:两个任务互换位置比单独 Move 更稳时。
5. BatchMove批量原子移动写操作
必要参数:{"moves":[{Move参数...},{Move参数...}]}。
适用:一轮要改多个任务且要求“要么全成要么全回滚”。
6. Verify执行确定性校验只读
常用参数:可空;也可传 task_item_id + 目标坐标做定点核验。
适用:收尾前快速自检是否符合确定性约束。
7. SpreadEven复合按“均匀铺开”目标一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“把任务在时间上分散开,避免扎堆”。
8. MinContextSwitch复合按“最少上下文切换”一次规划并执行多任务移动写操作
必要参数task_item_ids必须包含 CURRENT_TASK.task_item_id
可选参数week/week_filter/day_of_week/allow_embed/limit。
适用:目标是“同科目/同认知标签尽量连续,减少切换成本”。
请严格输出 JSON不要 Markdown不要解释
{
"done": false,
"summary": "",
"goal_check": "本轮先检查什么",
"decision": "本轮为何这么做",
"missing_info": ["缺口信息1","缺口信息2"],
"tool_calls": [
{
"tool": "QueryTargetTasks|QueryAvailableSlots|Move|Swap|BatchMove|SpreadEven|MinContextSwitch|Verify",
"params": {}
}
]
}
硬规则:
1. 每轮最多 1 个 tool_call。
2. done=true 时tool_calls 必须为空数组。
3. done=false 时tool_calls 必须恰好 1 条。
4. 只能修改 status="suggested" 的任务,禁止修改 existing。
5. 不要把“顺序约束”当作执行期阻塞条件;你只需把坑位分布排好,顺序由后端统一收口。
6. 若上轮失败,必须依据 LAST_TOOL_OBSERVATION.error_code 调整策略,不能重复上轮失败动作。
7. Move 参数优先使用task_item_id,to_week,to_day,to_section_from,to_section_to。
8. BatchMove 参数格式必须是:{"moves":[{...},{...}]};任一步失败会整批回滚。
9. day_of_week 映射固定1周一,2周二,3周三,4周四,5周五,6周六,7周日。
10. 优先使用“纯空位”;仅在空位不足时再考虑可嵌入课程位(第二优先级)。
11. 如果 SOURCE_WEEK_FILTER 非空,只允许改写这些来源周里的任务,禁止主动改写其它周任务。
12. CURRENT_TASK 是本轮唯一可改写任务;如果它已满足目标,立刻 done=true不要提前处理下一个任务。
13. 禁止发明工具名(如 GetCurrentTask、AdjustTaskTime只能用白名单工具。
14. 优先使用后端注入的 ENV_SLOT_HINT 进行落点决策,非必要不要重复 QueryAvailableSlots。
15. 若 REQUIRED_COMPOSITE_TOOL 非空且 COMPOSITE_REQUIRED_SUCCESS=false本轮必须优先调用 REQUIRED_COMPOSITE_TOOL禁止先调用 Move/Swap/BatchMove。
16. 若使用 SpreadEven/MinContextSwitch必须在参数中提供 task_item_ids且包含 CURRENT_TASK.task_item_id
17. 若 COMPOSITE_TOOLS_ALLOWED=false禁止调用 SpreadEven/MinContextSwitch只能使用基础工具逐步处理。
18. 为保证解析稳定goal_check<=50字decision<=90字summary<=60字。`
// ScheduleRefinePostReflectPrompt 要求模型基于真实工具结果做复盘,不允许“脑补成功”。
ScheduleRefinePostReflectPrompt = `你是 SmartMate 的 ReAct 复盘器。
你会收到:本轮工具参数、后端真实执行结果、上一轮上下文。
请只输出 JSON不要 Markdown不要解释
{
"reflection": "基于真实结果的复盘",
"next_strategy": "下一轮建议动作",
"should_stop": false
}
规则:
1. 若 tool_success=falsereflection 必须明确失败原因(优先引用 error_code
2. 若 error_code 属于 ORDER_VIOLATION/SLOT_CONFLICT/REPEAT_FAILED_ACTIONnext_strategy 必须给出规避方法。
3. should_stop=true 仅用于“目标已满足”或“继续收益很低”。`
// ScheduleRefineReviewPrompt 用于终审语义校验。
ScheduleRefineReviewPrompt = `你是 SmartMate 的终审校验器。
请判断“当前排程”是否满足“本轮用户微调请求 + 契约硬要求”。
只输出 JSON
{
"pass": true,
"reason": "中文简短结论",
"unmet": []
}
规则:
1. pass=true 时 unmet 必须为空数组。
2. pass=false 时 reason 必须给出核心差距。`
// ScheduleRefineSummaryPrompt 用于最终面向用户的自然语言总结。
ScheduleRefineSummaryPrompt = `你是 SmartMate 的排程结果解读助手。
请基于输入输出 2~4 句中文总结:
1) 先说明本轮改了什么;
2) 再说明改动收益;
3) 若终审未完全通过,明确还差什么。
不要输出 JSON。`
// ScheduleRefineRepairPrompt 用于终审失败后的单次修复动作。
ScheduleRefineRepairPrompt = `你是 SmartMate 的修复执行器。
当前方案未通过终审,请根据“未满足点”只做一次修复动作。
只允许输出一个 tool_callMove 或 Swap不允许 done。
输出格式(严格 JSON
{
"done": false,
"summary": "",
"goal_check": "本轮修复目标",
"decision": "修复决策依据",
"missing_info": [],
"tool_calls": [
{
"tool": "Move|Swap",
"params": {}
}
]
}
Move 参数必须使用标准键:
- task_item_id
- to_week
- to_day
- to_section_from
- to_section_to
禁止使用 new_week/new_day/section_from 等别名。`
)

View File

@@ -1,79 +0,0 @@
package agentprompt
import (
"fmt"
"strings"
)
const TaskQueryPlanPrompt = `你是 SmartMate 的任务查询规划器。请根据用户原话,输出结构化查询计划 JSON供后端直接执行。
只允许输出 JSON不要输出解释、代码块或多余文字。
输出字段:
{
"user_goal": "一句话总结用户诉求",
"quadrants": [1,2,3,4],
"sort_by": "deadline|priority|id",
"order": "asc|desc",
"limit": 1-20,
"include_completed": false,
"keyword": "可选关键词,或空字符串",
"deadline_before": "yyyy-MM-dd HH:mm 或空字符串",
"deadline_after": "yyyy-MM-dd HH:mm 或空字符串"
}
规则:
1. quadrants 为空数组表示“全部象限”。
2. 用户未提排序时,默认 sort_by=deadline 且 order=asc。
3. 用户未提数量时limit 默认 5。
4. 时间字段必须输出绝对时间或空字符串,不要输出“明天”“下周一”这类相对时间。
5. 如果用户语义更偏向“我还有什么要做”“看看待办”,优先考虑 1、2 象限;如果 1、2 象限为空,再考虑 3、4 象限。
6. 如果用户语义更偏向“来点事做做”“给我点轻松的任务”,优先考虑 3、4 象限。
7. 允许多选象限。`
const TaskQueryReflectPrompt = `你是 SmartMate 的任务查询结果审阅器。你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。
请只输出 JSON不要输出解释、代码块或多余文字。
输出字段:
{
"satisfied": true,
"need_retry": false,
"reason": "一句话原因",
"reply": "可直接给用户看的中文回复",
"retry_patch": {
"quadrants": [1,2,3,4],
"sort_by": "deadline|priority|id",
"order": "asc|desc",
"limit": 1-20,
"include_completed": true,
"keyword": "可选关键词,或空字符串",
"deadline_before": "yyyy-MM-dd HH:mm 或空字符串",
"deadline_after": "yyyy-MM-dd HH:mm 或空字符串"
}
}
规则:
1. 如果当前结果已经满足用户诉求,返回 satisfied=true 且 need_retry=false。
2. 如果当前结果不满足,但仍值得再查一次,返回 need_retry=true并尽量只给最小必要 patch。
3. 如果不建议再试,返回 need_retry=false并在 reply 里说明当前最接近的结果。
4. reply 应该是自然中文,不要输出表格。`
func BuildTaskQueryPlanUserPrompt(nowText, userInput string) string {
return fmt.Sprintf(
"当前时间(北京时间,精确到分钟):%s\n用户输入%s\n\n请输出任务查询计划 JSON。",
strings.TrimSpace(nowText),
strings.TrimSpace(userInput),
)
}
func BuildTaskQueryReflectUserPrompt(nowText, userInput, userGoal, planSummary string, retryCount, maxRetry int, resultSummary string) string {
return fmt.Sprintf(
"当前时间:%s\n用户原话%s\n用户目标%s\n当前查询计划%s\n当前重试%d/%d\n查询结果摘要\n%s",
strings.TrimSpace(nowText),
strings.TrimSpace(userInput),
strings.TrimSpace(userGoal),
strings.TrimSpace(planSummary),
retryCount,
maxRetry,
strings.TrimSpace(resultSummary),
)
}

View File

@@ -1,272 +0,0 @@
package agentrouter
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/google/uuid"
)
const (
// ControlTimeout 表示“路由控制码”阶段的额外超时预算。
// 说明:
// 1. 设为 0 表示完全继承父 ctx 的 deadline不额外截断。
// 2. 若后续观察到路由阶段偶发超时,可按需配置一个小预算(例如 2s
ControlTimeout = 0 * time.Second
)
var (
// routeHeaderRegex 用于解析控制码头部。
// 支持动作:
// 1. quick_note_create新增随口记任务。
// 2. task_query任务查询。
// 3. schedule_plan_create新建排程。
// 4. schedule_plan_refine连续对话微调排程。
// 5. schedule_plan历史兼容动作解析后映射到 schedule_plan_create
// 6. quick_note历史兼容动作解析后映射到 quick_note_create
// 7. chat普通聊天。
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|schedule_plan|quick_note|chat)["']?[^>]*>`)
// routeReasonRegex 用于提取可选 reason便于日志排障。
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
)
const routeControlPrompt = `你是 SmartMate 的请求分流控制器。
你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。
动作定义:
1) quick_note_create用户明确要“帮我记一下/安排一个未来要做的事/提醒我”。
2) task_query用户要“查任务、筛任务、按条件列任务”。
3) schedule_plan_create用户要“新建/生成一份排程方案”。
4) schedule_plan_refine用户要“基于已有排程做连续微调”如挪动某天、限制某时段、局部改动
5) chat其余普通聊天与讨论。
优先级(冲突时按顺序):
1) quick_note_create
2) task_query
3) schedule_plan_refine
4) schedule_plan_create
5) chat
输出格式必须严格如下(两行):
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
// Action 是 Agent 路由层对业务动作的统一命名。
//
// 这里直接定义在 router 包,而不是复用旧 route 包:
// 1. 当前这轮迁移要求只有 router 可以保留对旧链路的兼容语义;
// 2. chat / quicknote 已经要完全切到 Agent自然不该再依赖旧包常量
// 3. schedule/taskquery 尚未搬迁完成时,也能继续靠这些常量在 service 层做统一分发。
type Action string
const (
ActionChat Action = "chat"
ActionQuickNoteCreate Action = "quick_note_create"
ActionTaskQuery Action = "task_query"
ActionSchedulePlanCreate Action = "schedule_plan_create"
ActionSchedulePlanRefine Action = "schedule_plan_refine"
// ActionSchedulePlan 是历史兼容动作值。
// 说明:旧模型可能返回 schedule_plan解析后统一映射到 schedule_plan_create。
ActionSchedulePlan Action = "schedule_plan"
// ActionQuickNote 是历史兼容动作值,解析后统一映射到 quick_note_create。
ActionQuickNote Action = "quick_note"
)
// ControlDecision 表示“模型控制码解析结果”。
type ControlDecision struct {
Action Action
Reason string
Raw string
}
// RoutingDecision 是服务层使用的统一分流结果。
// 职责边界:
// 1. Action最终动作chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine
// 2. TrustRoute是否允许下游跳过二次意图判定。
// 3. Detail可选说明用于阶段提示或日志。
// 4. RouteFailed标记“控制码路由是否失败”供上层决定是否直接报错。
type RoutingDecision struct {
Action Action
TrustRoute bool
Detail string
RouteFailed bool
}
// DecideActionRouting 通过“模型控制码”决定本次请求走向。
// 返回语义:
// 1. Action=quick_note_create进入随口记链路。
// 2. Action=task_query进入任务查询链路。
// 3. Action=schedule_plan_create进入新建排程链路。
// 4. Action=schedule_plan_refine进入连续微调链路。
// 5. Action=chat进入普通聊天链路。
// 6. 路由失败时标记 RouteFailed=true由上层统一处理。
func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision, err := routeByModelControlTag(ctx, selectedModel, userMessage)
if err != nil {
if deadline, ok := ctx.Deadline(); ok {
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
} else {
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d",
err, ControlTimeout.Milliseconds())
}
return RoutingDecision{
Action: ActionChat,
TrustRoute: false,
Detail: "",
RouteFailed: true,
}
}
switch decision.Action {
case ActionQuickNoteCreate:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到新增任务请求,准备执行随口记流程。"
}
return RoutingDecision{Action: ActionQuickNoteCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionTaskQuery:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到任务查询请求,准备执行任务查询流程。"
}
return RoutingDecision{Action: ActionTaskQuery, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionSchedulePlanCreate:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到新建排程请求,准备执行智能排程流程。"
}
return RoutingDecision{Action: ActionSchedulePlanCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionSchedulePlanRefine:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到排程微调请求,准备执行连续微调流程。"
}
return RoutingDecision{Action: ActionSchedulePlanRefine, TrustRoute: true, Detail: reason, RouteFailed: false}
case ActionChat:
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: false}
default:
log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw)
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: true}
}
}
func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) {
if selectedModel == nil {
return nil, fmt.Errorf("model is nil")
}
nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))
routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout)
defer cancel()
nowText := time.Now().In(time.Local).Format("2006-01-02 15:04")
userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
// 1. 调用目的:路由场景只需要稳定、短文本、禁用 thinking 的结构化输出。
// 2. 这里复用 Agent 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。
resp, err := agentllm.CallArkText(routeCtx, selectedModel, routeControlPrompt, userPrompt, agentllm.ArkCallOptions{
Temperature: 0,
MaxTokens: 120,
Thinking: agentllm.ThinkingModeDisabled,
})
if err != nil {
return nil, err
}
return ParseRouteControlTag(resp, nonce)
}
// deriveRouteControlContext 为“控制码路由”创建子上下文。
// 设计要点:
// 1. timeout<=0 时不加额外 deadline仅继承父上下文。
// 2. 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。
func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return context.WithCancel(parent)
}
if deadline, ok := parent.Deadline(); ok {
if time.Until(deadline) <= timeout {
return context.WithCancel(parent)
}
}
return context.WithTimeout(parent, timeout)
}
// ParseRouteControlTag 解析通用控制码返回。
// 容错策略:
// 1. 允许大小写、属性顺序、额外属性差异;
// 2. nonce 必须精确匹配;
// 3. 兼容旧 action 值schedule_plan/quick_note
func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, fmt.Errorf("route content is empty")
}
header := routeHeaderRegex.FindStringSubmatch(text)
if len(header) < 3 {
return nil, fmt.Errorf("route header not found: %s", text)
}
nonce := strings.ToLower(strings.TrimSpace(header[1]))
if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) {
return nil, fmt.Errorf("route nonce mismatch")
}
actionText := strings.ToLower(strings.TrimSpace(header[2]))
action := Action(actionText)
switch action {
case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlanCreate, ActionSchedulePlanRefine, ActionChat:
// 合法动作直接通过。
case ActionQuickNote:
action = ActionQuickNoteCreate
case ActionSchedulePlan:
action = ActionSchedulePlanCreate
default:
return nil, fmt.Errorf("invalid route action: %s", actionText)
}
reason := ""
reasonMatch := routeReasonRegex.FindStringSubmatch(text)
if len(reasonMatch) >= 2 {
reason = strings.TrimSpace(reasonMatch[1])
}
return &ControlDecision{
Action: action,
Reason: reason,
Raw: text,
}, nil
}
// DecideQuickNoteRouting 是历史兼容入口。
// 说明:
// 1. 旧代码只区分“是否进入 quick_note”
// 2. 新分流中 task_query/schedule_plan_* 都不应进入 quick_note。
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision := DecideActionRouting(ctx, selectedModel, userMessage)
if decision.Action == ActionQuickNoteCreate {
return decision
}
return RoutingDecision{
Action: ActionChat,
TrustRoute: false,
Detail: "",
RouteFailed: decision.RouteFailed,
}
}
// ParseQuickNoteRouteControlTag 是历史兼容解析入口。
// 说明:旧测试仍使用该方法名,内部统一委托 ParseRouteControlTag。
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
return ParseRouteControlTag(raw, expectedNonce)
}

View File

@@ -1,67 +0,0 @@
package agentrouter
import (
"context"
"errors"
"fmt"
)
// Dispatcher 是 Agent 的统一分发器。
type Dispatcher struct {
resolver Resolver
handlers map[Action]SkillHandler
}
// NewDispatcher 创建统一分发器。
func NewDispatcher(resolver Resolver) *Dispatcher {
return &Dispatcher{
resolver: resolver,
handlers: make(map[Action]SkillHandler),
}
}
// Register 注册某个动作的处理函数。
func (d *Dispatcher) Register(action Action, handler SkillHandler) error {
if d == nil {
return errors.New("dispatcher is nil")
}
if action == "" {
return errors.New("route action is empty")
}
if handler == nil {
return fmt.Errorf("handler for action %s is nil", action)
}
if _, exists := d.handlers[action]; exists {
return fmt.Errorf("handler for action %s already registered", action)
}
d.handlers[action] = handler
return nil
}
// Dispatch 执行“分流 -> skill handler”完整入口。
func (d *Dispatcher) Dispatch(ctx context.Context, req *AgentRequest) (*AgentResponse, error) {
if d == nil || d.resolver == nil {
return nil, errors.New("route dispatcher is not ready")
}
if req == nil {
return nil, errors.New("agent request is nil")
}
// 1. 调用目的:统一先走一级路由,让入口层只关心“请求来了”,
// 不需要提前知道这是普通聊天、随口记、任务查询还是后续排程。
decision, err := d.resolver.Resolve(ctx, req)
if err != nil {
return nil, err
}
if decision == nil {
return nil, errors.New("route decision is nil")
}
// 2. 路由结果出来后,只根据 action 查找对应 handler。
// 这里故意不做 skill 级 fallback避免路由层和 skill 内部职责再次缠在一起。
handler, exists := d.handlers[decision.Action]
if !exists {
return nil, fmt.Errorf("no handler registered for action %s", decision.Action)
}
return handler(ctx, req)
}

View File

@@ -1,34 +0,0 @@
package agentrouter
import (
"context"
)
// Resolver 定义一级路由器能力。
type Resolver interface {
Resolve(ctx context.Context, req *AgentRequest) (*RoutingDecision, error)
}
// SkillHandler 是某个 skill 的统一执行入口。
type SkillHandler func(ctx context.Context, req *AgentRequest) (*AgentResponse, error)
// AgentRequest 是 Agent 路由层可见的最小请求结构。
//
// 设计目的:
// 1. 让 router 层只依赖自己真正关心的字段;
// 2. 避免把整份 agentmodel 结构在迁移早期层层透传;
// 3. 后续若总入口还要追加别的字段,只需要在入口层做一次映射。
type AgentRequest struct {
UserID int
ConversationID string
UserMessage string
ModelName string
Extra map[string]any
}
// AgentResponse 是路由分发器对 skill handler 的统一响应外壳。
type AgentResponse struct {
Action Action
Reply string
Meta map[string]any
}

View File

@@ -1,85 +0,0 @@
package agentshared
import (
"context"
"time"
)
// RetryOptions 描述公共重试策略。
//
// 职责边界:
// 1. 这里只定义“是否重试、最多几次、间隔多久”;
// 2. 不关心具体业务是工具调用失败、模型 JSON 失败还是 DB 暂时不可用;
// 3. 真正的业务兜底文案仍应由上层 node 决定。
type RetryOptions struct {
MaxAttempts int
Interval time.Duration
ShouldRetry func(err error) bool
OnRetry func(attempt int, err error)
}
// Do 执行一个只返回 error 的重试任务。
//
// 执行规则:
// 1. 第一次执行也算一次 attempt
// 2. 任意一次成功即立即返回;
// 3. 上下文取消、达到最大次数、或 ShouldRetry=false 时立即停止。
func Do(ctx context.Context, options RetryOptions, fn func(attempt int) error) error {
_, err := DoValue[struct{}](ctx, options, func(attempt int) (struct{}, error) {
return struct{}{}, fn(attempt)
})
return err
}
// DoValue 执行一个带返回值的通用重试任务。
//
// 设计说明:
// 1. 旧 agent 里后续很多地方都会出现“失败重试 2~3 次”的模式;
// 2. 这里先把循环骨架统一,避免每个 skill 自己写 for + sleep + ctx.Done
// 3. 上层只需关心“本轮失败要不要继续”,而不是重复造轮子。
func DoValue[T any](ctx context.Context, options RetryOptions, fn func(attempt int) (T, error)) (T, error) {
var zero T
maxAttempts := options.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return zero, err
}
value, err := fn(attempt)
if err == nil {
return value, nil
}
// 1. 到最后一次了,直接返回原错误,避免无意义等待。
if attempt >= maxAttempts {
return zero, err
}
// 2. 业务显式声明“不值得重试”时,立刻停止。
if options.ShouldRetry != nil && !options.ShouldRetry(err) {
return zero, err
}
// 3. 把重试钩子留给上层,用于打点或阶段提示。
if options.OnRetry != nil {
options.OnRetry(attempt, err)
}
// 4. 没有配置间隔则马上下一轮;配置了则等待,同时尊重 ctx 取消。
if options.Interval <= 0 {
continue
}
timer := time.NewTimer(options.Interval)
select {
case <-ctx.Done():
timer.Stop()
return zero, ctx.Err()
case <-timer.C:
}
}
return zero, nil
}

View File

@@ -1,49 +0,0 @@
package agentshared
import (
"sync"
"time"
)
const (
// MinuteLayout 是 Agent 内部统一的分钟级时间文本格式。
//
// 设计原因:
// 1. agent 里大量场景只需要精确到分钟;
// 2. 秒级精度会增加提示词噪声,也容易让“同一请求内的当前时间”出现抖动;
// 3. 先统一成一份常量,后续 quicknote / schedule 都直接复用。
MinuteLayout = "2006-01-02 15:04"
)
var (
shanghaiLocOnce sync.Once
shanghaiLoc *time.Location
)
// ShanghaiLocation 返回 Agent 内部统一使用的东八区时区。
func ShanghaiLocation() *time.Location {
shanghaiLocOnce.Do(func() {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
// 兜底使用固定东八区,避免极端环境下因为系统时区文件缺失导致整个链路失败。
loc = time.FixedZone("CST", 8*3600)
}
shanghaiLoc = loc
})
return shanghaiLoc
}
// NowToMinute 返回当前北京时间,并截断到分钟级。
func NowToMinute() time.Time {
return time.Now().In(ShanghaiLocation()).Truncate(time.Minute)
}
// NormalizeToMinute 把任意时间统一到北京时间分钟粒度。
func NormalizeToMinute(t time.Time) time.Time {
return t.In(ShanghaiLocation()).Truncate(time.Minute)
}
// FormatMinute 把时间格式化为统一分钟级文本。
func FormatMinute(t time.Time) string {
return NormalizeToMinute(t).Format(MinuteLayout)
}

View File

@@ -1,115 +0,0 @@
package agentstream
import (
"fmt"
"strings"
)
// PayloadEmitter 是真正向外层 SSE 管道写 chunk 的最小接口。
//
// 说明:
// 1. 这里刻意不用 chan/string 绑死实现;
// 2. 上层既可以传“写 channel”的函数也可以传“写 gin stream”的函数
// 3. 只要签名是 `func(string) error`,都能接进来。
type PayloadEmitter func(payload string) error
// StageEmitter 是 graph/node 对“当前阶段”进行推送的最小接口。
type StageEmitter func(stage, detail string)
// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。
func NoopPayloadEmitter() PayloadEmitter {
return func(string) error { return nil }
}
// NoopStageEmitter 返回一个空实现,避免 graph 在没有接前端时处处判空。
func NoopStageEmitter() StageEmitter {
return func(stage, detail string) {}
}
// WrapStageEmitter 把可空函数包装成稳定的 StageEmitter。
func WrapStageEmitter(fn func(stage, detail string)) StageEmitter {
if fn == nil {
return NoopStageEmitter()
}
return fn
}
// EmitStageAsReasoning 把“阶段提示”伪装成 reasoning chunk 推给前端。
//
// 设计背景:
// 1. 你当前 Apifox 只认思考块和正文块,因此阶段提示需要先借 reasoning_content 走通;
// 2. 这样后续真正前端上线时,只需要在这一层换协议,而不必回到各 skill 重改 graph
// 3. 这里不拼花哨格式,只给出稳定、可读、可 grep 的文本。
func EmitStageAsReasoning(emit PayloadEmitter, requestID, modelName string, created int64, stage, detail string, includeRole bool) error {
if emit == nil {
return nil
}
text := BuildStageReasoningText(stage, detail)
payload, err := ToOpenAIReasoningChunk(requestID, modelName, created, text, includeRole)
if err != nil {
return err
}
if payload == "" {
return nil
}
return emit(payload)
}
// EmitAssistantReply 把一段完整正文作为 assistant chunk 推出。
//
// 注意:
// 1. 这里是“整段发”,不是把文本强行拆碎;
// 2. 这样后续如果某条链路不需要真流式,也可以复用统一出口;
// 3. 真正按 token/chunk 细粒度流式输出,应由 llm.Stream + 上层循环处理。
func EmitAssistantReply(emit PayloadEmitter, requestID, modelName string, created int64, content string, includeRole bool) error {
if emit == nil {
return nil
}
payload, err := ToOpenAIAssistantChunk(requestID, modelName, created, content, includeRole)
if err != nil {
return err
}
if payload == "" {
return nil
}
return emit(payload)
}
// EmitFinish 统一输出 stop 结束块。
func EmitFinish(emit PayloadEmitter, requestID, modelName string, created int64) error {
if emit == nil {
return nil
}
payload, err := ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return err
}
if payload == "" {
return nil
}
return emit(payload)
}
// EmitDone 统一输出 OpenAI 兼容流式结束标记。
func EmitDone(emit PayloadEmitter) error {
if emit == nil {
return nil
}
return emit("[DONE]")
}
// BuildStageReasoningText 生成统一阶段提示文本。
func BuildStageReasoningText(stage, detail string) string {
stage = strings.TrimSpace(stage)
detail = strings.TrimSpace(detail)
switch {
case stage != "" && detail != "":
return fmt.Sprintf("阶段:%s\n%s", stage, detail)
case stage != "":
return fmt.Sprintf("阶段:%s", stage)
default:
return detail
}
}

View File

@@ -1,102 +0,0 @@
package agentstream
import (
"encoding/json"
"github.com/cloudwego/eino/schema"
)
// OpenAIChunkResponse 是 OpenAI 兼容的流式 chunk DTO。
//
// 之所以单独放到 Agent/stream
// 1. 未来无论 quicknote、taskquery 还是 schedule只要需要 SSE 都会复用这套协议壳;
// 2. 这样 node/graph 层只关注“我要推什么内容”,不再自己拼 JSON
// 3. 后续如果前端协议升级,也能在这里集中改。
type OpenAIChunkResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIChunkChoice `json:"choices"`
}
// OpenAIChunkChoice 对应 OpenAI choices[0]。
type OpenAIChunkChoice struct {
Index int `json:"index"`
Delta OpenAIChunkDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// OpenAIChunkDelta 是真正承载 role/content/reasoning 的位置。
type OpenAIChunkDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
// ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。
//
// 职责边界:
// 1. 负责把 chunk.Content / chunk.ReasoningContent 映射到协议字段;
// 2. 负责按 includeRole 决定是否在首块带上 assistant 角色;
// 3. 不负责发送,也不负责决定“这个 chunk 该不该推”。
func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) {
delta := OpenAIChunkDelta{}
if includeRole {
delta.Role = "assistant"
}
if chunk != nil {
delta.Content = chunk.Content
delta.ReasoningContent = chunk.ReasoningContent
}
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil)
}
// ToOpenAIReasoningChunk 直接构造一个 reasoning chunk。
func ToOpenAIReasoningChunk(requestID, modelName string, created int64, reasoning string, includeRole bool) (string, error) {
delta := OpenAIChunkDelta{ReasoningContent: reasoning}
if includeRole {
delta.Role = "assistant"
}
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil)
}
// ToOpenAIAssistantChunk 直接构造一个正文 chunk。
func ToOpenAIAssistantChunk(requestID, modelName string, created int64, content string, includeRole bool) (string, error) {
delta := OpenAIChunkDelta{Content: content}
if includeRole {
delta.Role = "assistant"
}
return buildOpenAIChunkPayload(requestID, modelName, created, delta, nil)
}
// ToOpenAIFinishStream 生成流式结束 chunkfinish_reason=stop
func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) {
stop := "stop"
return buildOpenAIChunkPayload(requestID, modelName, created, OpenAIChunkDelta{}, &stop)
}
func buildOpenAIChunkPayload(requestID, modelName string, created int64, delta OpenAIChunkDelta, finishReason *string) (string, error) {
// 1. 若既没有 role也没有正文/思考,也没有 finish_reason则视为“空块”直接跳过。
// 2. 这样可以避免上层每次都自己写一遍空块判断。
if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" && finishReason == nil {
return "", nil
}
dto := OpenAIChunkResponse{
ID: requestID,
Object: "chat.completion.chunk",
Created: created,
Model: modelName,
Choices: []OpenAIChunkChoice{{
Index: 0,
Delta: delta,
FinishReason: finishReason,
}},
}
data, err := json.Marshal(dto)
if err != nil {
return "", err
}
return string(data), nil
}

View File

@@ -1,176 +0,0 @@
# agent 通用能力接入文档
## 1. 文档目的
本文用于说明 `backend/agent` 目录下“通用能力”的职责边界、放置位置和接入约束,避免后续继续出现“同一类能力复制三份、四份”的情况。
这里的“通用能力”特指:
1. 会被两个及以上能力域复用,或者已经明确会继续扩散的基础能力。
2. 与具体业务语义弱耦合,抽出来后不会把某个 skill 的 prompt、状态字段、业务规则污染到其它模块。
3. 抽出后能显著减少样板代码、降低迁移成本,或者统一链路行为。
本文不负责描述某个具体 skill 的业务流程。业务流程、状态机、prompt 细节,仍应放在对应能力域自己的文件中。
## 2. 当前目录分层
```text
backend/agent/
entrance.go
chat/
graph/
llm/
model/
node/
prompt/
router/
shared/
stream/
```
### 2.1 `entrance.go`
职责:
1. 作为 `agent` 模块对上层 service 的统一入口。
2. 负责装配路由器与各能力 handler。
3. 不负责具体 graph 逻辑、不负责直接调模型、不负责工具执行。
### 2.2 `router/`
职责:
1. 负责一级分流,把请求映射到具体能力链路。
2. 维护统一的请求/响应结构和 action 定义。
3. 不承载具体 skill 的业务判断细节。
适合放入这里的能力:
1. 路由请求结构。
2. action 解析与分发。
3. 对上层稳定暴露的最小门面。
### 2.3 `graph/`
职责:
1. 只负责组图、连线和节点编排。
2. 文件里应尽量只出现节点挂载、分支和边定义。
3. 不直接写复杂业务逻辑、不直接调 DAO、不直接拼 prompt。
### 2.4 `node/`
职责:
1. 承接能力域的核心业务节点实现。
2. 按“节点逻辑文件 + 工具文件”的双文件格局组织复杂能力域。
3. 在确实存在多节点复用时,可下沉少量带业务语义的 node 内部公共 helper。
当前约定:
1. `schedule_plan.go` / `schedule_plan_tool.go` 为一组。
2. `schedule_refine.go` / `schedule_refine_tool.go` 为一组。
3. `quicknote.go` / `quicknote_tool.go``taskquery.go` / `taskquery_tool.go` 同理。
补充说明:
1. `node/tool_common.go` 是 node 层内部通用工具聚合点。
2. 这里只放“被两个及以上节点复用、但仍带一点节点上下文语义”的 helper。
3. 如果某个能力已经弱化到与业务无关,应继续下沉到 `shared/`,而不是长期堆在 `tool_common.go`
### 2.5 `llm/`
职责:
1. 统一封装模型调用、JSON 解析、推理参数和模型侧协议。
2. 让上层节点尽量只关心“要什么结果”,不重复实现 SDK 样板代码。
3. 不承载具体业务状态流转。
### 2.6 `model/`
职责:
1. 统一放置 agent 内部状态结构、输入输出 DTO、默认预算等模型无关定义。
2. 不在这里写业务执行逻辑。
### 2.7 `prompt/`
职责:
1. 维护系统提示词、结构化输出模板、路由提示词等文本资产。
2. 不在 prompt 文件中写节点控制流和工具编排。
### 2.8 `stream/`
职责:
1. 统一承接 SSE chunk 包装、阶段推送、OpenAI/Ark 流式适配。
2. 保证上层 service 不需要重复拼装流协议。
### 2.9 `shared/`
职责:
1. 放置跨能力域复用的纯工具能力,例如时间、重试、深拷贝等。
2. 要求业务语义尽量弱、依赖尽量少。
3. 一旦某类逻辑已经被第二处复用,必须优先评估是否放到这里。
## 3. 什么该抽成通用能力
满足以下任一条件时,必须优先评估抽公共层:
1. 同类逻辑已经出现第二份实现。
2. 不同 skill 的实现只有参数不同,控制流基本一致。
3. 上层 service 已经开始出现重复胶水代码。
4. 继续复制会增加迁移、测试或回归排查成本。
常见应优先考虑抽取的方向:
1. 模型调用门面。
2. JSON 容错解析。
3. SSE 阶段推送与 chunk 包装。
4. 深拷贝与快照转换。
5. 缓存快照读写辅助逻辑。
## 4. 什么不该抽成通用能力
以下内容默认不应抽到公共层:
1. 某个 skill 独有的 prompt 片段。
2. 只服务单一业务的状态字段映射。
3. 带强业务语义的 ReAct 决策规则。
4. 只在一个节点里短期使用、且没有第二处复用证据的 helper。
判断原则:
1. 若抽出来后名字仍然需要带明显业务词,通常说明它还不够通用。
2. 若抽出来会让其它模块被迫理解某个 skill 的内部规则,说明抽取层级过早。
## 5. 新增能力时的落点规则
1. 纯工具、弱业务语义、跨域复用:优先放 `shared/`
2. 只在路由阶段复用:放 `router/`
3. 只与模型协议相关:放 `llm/`
4. 只与流式输出相关:放 `stream/`
5. 只在 node 层内被多个节点复用,且带少量业务上下文:放 `node/tool_common.go` 或同层 helper。
6. 仍然明显属于某个能力域:留在对应 `node/``prompt/``model/` 文件中,不要硬抽。
## 6. 变更要求
后续若在 `backend/agent` 中新增、下沉、替换任何通用能力,必须同步完成以下动作:
1. 更新本文档,说明新能力放在哪一层、为什么放这里。
2. 说明是否替代了旧实现,旧实现是否已经删除。
3. 检查是否还残留第三份及以上重复实现。
4. 若本轮只是暂时无法抽公共层,必须在代码注释或文档里写明原因。
## 7. 当前结构结论
截至当前版本,`backend/agent` 已是唯一正式实现目录,`backend/service/agentsvc` 也已与历史旧路径完全解耦。
后续重构优先级建议:
1. 继续收口 node 层内部重复的查询/校验/移动辅助逻辑。
2. 持续把 service 层里可复用的旁路读写逻辑下沉到更稳定的公共层。
3. 保持 graph 只做编排、node 只做业务、shared 只做弱语义公共能力,避免重新堆回大杂烩结构。

View File

@@ -6,7 +6,6 @@ import (
"log"
"time"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/api"
"github.com/LoveLosita/smartflow/backend/dao"
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
@@ -20,6 +19,7 @@ import (
"github.com/LoveLosita/smartflow/backend/middleware"
"github.com/LoveLosita/smartflow/backend/model"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
"github.com/LoveLosita/smartflow/backend/pkg"
@@ -201,7 +201,7 @@ func Start() {
TaskQuery: newagenttools.TaskQueryDeps{
// 调用目的:桥接新工具参数到旧 service 层查询能力,复用已有的过滤/排序/紧急度提升逻辑。
QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) {
req := agentnode.TaskQueryRequest{
req := newagentmodel.TaskQueryRequest{
UserID: userID,
Quadrant: params.Quadrant,
SortBy: params.SortBy,

View File

@@ -0,0 +1,54 @@
# newAgent 优化待办 Handoff
> 日期2026-04-21
> 来源:迁移 agent/ → newAgent/ 完成后的架构审视
---
## 1. TaskQuery 紧急度提升统一
### 问题
LLM 工具查询任务(`AgentService.QueryTasksForTool`)使用 `applyReadTimeUrgencyPromotion` 只做内存态优先级提升,不触发 outbox 写 MySQL。
前端查询任务(`TaskService.GetUserTasks`)使用 `deriveTaskUrgencyForRead` + `tryEnqueueTaskUrgencyPromote`,会异步持久化。
两条路径行为不一致LLM 看到的优先级可能比 DB 里的高。
### 方案
1. `service/task.go` — 从 `GetUserTasks` 中提取公共方法(如 `GetTasksWithUrgencyPromotion`),返回已提升的 `[]model.Task` 并触发 outbox
2. `service/agentsvc/agent.go` — 新增 `taskSvc *service.TaskService` 字段
3. `service/agentsvc/agent_task_query.go` — 重写 `QueryTasksForTool`,调用 TaskService 公共方法;删除 `applyReadTimeUrgencyPromotion` 死代码
4. `cmd/start.go` — 注入 TaskService 到 AgentService
### 涉及文件
| 文件 | 改动 |
|------|------|
| `service/task.go` | 提取公共方法 |
| `service/agentsvc/agent.go` | 加 taskSvc 字段 |
| `service/agentsvc/agent_task_query.go` | 重写,删 `applyReadTimeUrgencyPromotion` |
| `cmd/start.go` | 注入 TaskService |
---
## 2. service/agentsvc 层瘦身(低优先级)
### 现状
`service/agentsvc/` 目前 11 个文件,大部分是 HTTP→DB 转接层,职责合理。但有两个纯逻辑文件理论上可下沉:
| 文件 | 内容 | 可移至 |
|------|------|--------|
| `agent_memory_render.go` | 纯文本转换,零 DB 交互 | `memory/` 包 |
| `agent_task_query.go``taskMatchesQueryFilter` / `sortTasksForQuery` | 纯过滤/排序 | `newAgent/tools/` |
### 判断
当前体量小(加起来约 200 行纯函数),搬出去收益不大,反而多一层 import 间接。如果未来这些函数膨胀再搬不迟。
---
## 3. go mod tidy
迁移完成后 `go.mod` 中有未使用的依赖(如 `github.com/bytedance/mockey`)。建议跑一次 `go mod tidy` 清理。

View File

@@ -0,0 +1,26 @@
package model
import "time"
// TaskQueryRequest 是任务查询工具的请求参数。
type TaskQueryRequest struct {
UserID int
Quadrant *int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskQueryTaskRecord 是任务查询工具返回的单条任务记录。
type TaskQueryTaskRecord struct {
ID int
Title string
PriorityGroup int
IsCompleted bool
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}

View File

@@ -1,4 +1,4 @@
package agentchat
package newagentprompt
const (
// SystemPrompt 全局系统人设:定义 SmartMate 的基本调性
@@ -6,6 +6,6 @@ const (
你擅长课表与任务安排任务管理学习规划和随口记也可以正常回答日常问答生活建议信息整理分析讨论等非排程问题
你的目标是像一个越用越懂用户的伙伴一样结合历史对话长期记忆和当前上下文给出贴心清晰可信的帮助
你的回复应当专业自然有陪伴感偶尔可以带一点轻松幽默
如果用户的问题与日程无关不要因为不属于排程就拒绝回避或强行转到任务安排只要不需要工具且你有把握就直接回答
重要约束你无法直接写入数据库除非系统明确告知任务已落库成功否则禁止使用已安排/已记录/已帮你记下等完成态表述`
如果用户的问题与日程无关不要因为"不属于排程"就拒绝回避或强行转到任务安排只要不需要工具且你有把握就直接回答
重要约束你无法直接写入数据库除非系统明确告知"任务已落库成功"否则禁止使用"已安排/已记录/已帮你记下"等完成态表述`
)

View File

@@ -1,13 +1,7 @@
package agentshared
package newagentshared
import "github.com/LoveLosita/smartflow/backend/model"
// CloneWeekSchedules 深拷贝周视图排程结果。
//
// 职责边界:
// 1. 负责断开 []UserWeekSchedule 与内部 Events 切片的引用共享;
// 2. 负责服务于“缓存 DTO / graph state / API 响应”之间的安全复制;
// 3. 不负责业务过滤,不负责排序。
func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
if len(src) == 0 {
return nil
@@ -25,7 +19,6 @@ func CloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return dst
}
// CloneHybridEntries 深拷贝混合排程条目切片。
func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
if len(src) == 0 {
return nil
@@ -35,12 +28,6 @@ func CloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleE
return dst
}
// CloneTaskClassItems 深拷贝任务块切片。
//
// 这里不能直接 copy
// 1. 因为 TaskClassItem 内部带若干指针字段;
// 2. 如果浅拷贝,后续某一步修改 EmbeddedTime / Status会污染原状态
// 3. 排程 graph 连续微调时,这种共享引用会非常难查,所以必须在公共层兜住。
func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
@@ -74,7 +61,6 @@ func CloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return dst
}
// CloneInts 深拷贝 int 切片。
func CloneInts(src []int) []int {
if len(src) == 0 {
return nil
@@ -84,7 +70,6 @@ func CloneInts(src []int) []int {
return dst
}
// CloneStrings 深拷贝 string 切片。
func CloneStrings(src []string) []string {
if len(src) == 0 {
return nil

View File

@@ -0,0 +1,366 @@
package newagentshared
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
deadlineLayouts = []string{
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04:05",
"2006/01/02 15:04",
"2006.01.02 15:04:05",
"2006.01.02 15:04",
"2006-01-02",
"2006/01/02",
"2006.01.02",
}
deadlineDateOnlyLayouts = map[string]struct{}{
"2006-01-02": {},
"2006/01/02": {},
"2006.01.02": {},
}
clockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[:]\s*(\d{1,2})`)
clockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
ymdRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
mdRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
dateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
weekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
relativeTokens = []string{
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
}
)
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
func ParseOptionalDeadline(raw string) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, hasHint, err := parseDeadlineFromText(value, NowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return deadline, nil
}
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, _, err := parseDeadlineFromText(value, now)
if err != nil {
return nil, err
}
if deadline == nil {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return deadline, nil
}
func parseDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
if strings.TrimSpace(value) == "" {
return nil, false, nil
}
loc := ShanghaiLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
}
return rel, true, nil
}
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, false, nil
}
func normalizeDeadlineInput(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
replacer := strings.NewReplacer(
"", ":",
"", ",",
"。", ".",
" ", " ",
)
return strings.TrimSpace(replacer.Replace(trimmed))
}
func hasDeadlineHint(value string) bool {
if clockHMRegex.MatchString(value) ||
clockCNRegex.MatchString(value) ||
ymdRegex.MatchString(value) ||
mdRegex.MatchString(value) ||
dateSepRegex.MatchString(value) ||
weekdayRegex.MatchString(value) {
return true
}
for _, token := range relativeTokens {
if strings.Contains(value, token) {
return true
}
}
return false
}
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
for _, layout := range deadlineLayouts {
var (
parsed time.Time
err error
)
if layout == time.RFC3339 {
parsed, err = time.Parse(layout, value)
if err == nil {
parsed = parsed.In(loc)
}
} else {
parsed, err = time.ParseInLocation(layout, value, loc)
}
if err != nil {
continue
}
if _, dateOnly := deadlineDateOnlyLayouts[layout]; dateOnly {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
} else {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
}
return &parsed, true
}
return nil, false
}
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
}
if !hasExplicitClock {
hour, minute = defaultClockByHint(value)
}
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
return &deadline, true, nil
}
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
if matched := ymdRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
day, _ := strconv.Atoi(matched[3])
if isValidDate(year, month, day) {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
}
}
if matched := mdRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
year := now.Year()
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
if candidate.Before(startOfDay(now)) {
year++
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
}
return candidate, true
}
if matched := weekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
if ok {
return resolveWeekdayDate(now, prefix, target), true
}
}
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
return today.AddDate(0, 0, 3), true
case strings.Contains(value, "后天"):
return today.AddDate(0, 0, 2), true
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
return today.AddDate(0, 0, 1), true
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
return today, true
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
return today.AddDate(0, 0, -1), true
default:
return time.Time{}, false
}
}
func extractClock(value string) (int, int, bool, error) {
hour := 0
minute := 0
hasClock := false
if matched := clockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
if errH != nil || errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = m
hasClock = true
} else if matched := clockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = 0
hasClock = true
if len(matched) >= 3 {
if matched[2] == "半" {
minute = 30
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
if errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
minute = m
}
}
}
if !hasClock {
return 0, 0, false, nil
}
if isPMHint(value) && hour < 12 {
hour += 12
}
if isNoonHint(value) && hour >= 1 && hour <= 10 {
hour += 12
}
if strings.Contains(value, "凌晨") && hour == 12 {
hour = 0
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
}
return hour, minute, true, nil
}
func defaultClockByHint(value string) (int, int) {
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
return 9, 0
case strings.Contains(value, "中午"):
return 12, 0
case strings.Contains(value, "下午"):
return 15, 0
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
return 20, 0
default:
return 23, 59
}
}
func isPMHint(value string) bool {
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
}
func toWeekday(chinese string) (time.Weekday, bool) {
switch chinese {
case "一":
return time.Monday, true
case "二":
return time.Tuesday, true
case "三":
return time.Wednesday, true
case "四":
return time.Thursday, true
case "五":
return time.Friday, true
case "六":
return time.Saturday, true
case "日", "天":
return time.Sunday, true
default:
return time.Sunday, false
}
}
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
return candidateThisWeek
default:
if candidateThisWeek.Before(today) {
return candidateThisWeek.AddDate(0, 0, 7)
}
return candidateThisWeek
}
}

View File

@@ -1,4 +1,4 @@
package agentmodel
package newagentshared
const (
TaskPriorityImportantUrgent = 1
@@ -7,20 +7,18 @@ const (
TaskPriorityComplexNotImportant = 4
)
// IsValidTaskPriority 用于校验任务优先级是否合法
//
// 职责边界:
// 1. 只负责判断 priority 是否落在系统支持的 1~4 范围内。
// 2. 不负责把自然语言映射成优先级,也不负责做业务兜底推断。
// QuickNote 优先级别名,保持与旧 agent/model 命名兼容
const (
QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent
QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent
QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant
QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant
)
func IsValidTaskPriority(priority int) bool {
return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant
}
// PriorityLabelCN 返回任务优先级对应的中文标签。
//
// 职责边界:
// 1. 只负责“优先级枚举 -> 中文展示文案”的稳定映射。
// 2. 不负责国际化、多语言切换或业务规则解释。
func PriorityLabelCN(priority int) string {
switch priority {
case TaskPriorityImportantUrgent:

View File

@@ -0,0 +1,41 @@
package newagentstream
import "github.com/cloudwego/eino/schema"
// CloneUsage 深拷贝 TokenUsage。
func CloneUsage(usage *schema.TokenUsage) *schema.TokenUsage {
if usage == nil {
return nil
}
copied := *usage
return &copied
}
// MergeUsage 合并两段 usage取更大值。
// 适用于同一次调用不同流分片的 usage 收敛。
func MergeUsage(base *schema.TokenUsage, incoming *schema.TokenUsage) *schema.TokenUsage {
if incoming == nil {
return CloneUsage(base)
}
if base == nil {
return CloneUsage(incoming)
}
merged := *base
if incoming.PromptTokens > merged.PromptTokens {
merged.PromptTokens = incoming.PromptTokens
}
if incoming.CompletionTokens > merged.CompletionTokens {
merged.CompletionTokens = incoming.CompletionTokens
}
if incoming.TotalTokens > merged.TotalTokens {
merged.TotalTokens = incoming.TotalTokens
}
if incoming.PromptTokenDetails.CachedTokens > merged.PromptTokenDetails.CachedTokens {
merged.PromptTokenDetails.CachedTokens = incoming.PromptTokenDetails.CachedTokens
}
if incoming.CompletionTokensDetails.ReasoningTokens > merged.CompletionTokensDetails.ReasoningTokens {
merged.CompletionTokensDetails.ReasoningTokens = incoming.CompletionTokensDetails.ReasoningTokens
}
return &merged
}

View File

@@ -6,8 +6,7 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
@@ -40,11 +39,11 @@ type QuickNoteCreateResult struct {
func quickNoteFallbackPriority(deadline *time.Time) int {
if deadline != nil {
if time.Until(*deadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
return newagentshared.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
return newagentshared.QuickNotePriorityImportantNotUrgent
}
return agentmodel.QuickNotePrioritySimpleNotImportant
return newagentshared.QuickNotePrioritySimpleNotImportant
}
// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。
@@ -81,7 +80,7 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
raw = strings.TrimSpace(raw)
if raw != "" {
// 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。
parsed, err := agentnode.ParseOptionalDeadline(raw)
parsed, err := newagentshared.ParseOptionalDeadline(raw)
if err != nil {
return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s。支持格式2026-04-20 18:00、明天下午3点、下周一上午9点。", err)
}
@@ -94,7 +93,7 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
if pg, ok := args["priority_group"].(float64); ok {
priorityGroup = int(pg)
}
if !agentmodel.IsValidTaskPriority(priorityGroup) {
if !newagentshared.IsValidTaskPriority(priorityGroup) {
priorityGroup = quickNoteFallbackPriority(deadline)
}
@@ -108,10 +107,10 @@ func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
}
// 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。
priorityLabel := agentmodel.PriorityLabelCN(priorityGroup)
priorityLabel := newagentshared.PriorityLabelCN(priorityGroup)
deadlineStr := ""
if deadline != nil {
deadlineStr = deadline.In(agentnode.QuickNoteLocation()).Format("2006-01-02 15:04")
deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
}
result := QuickNoteCreateResult{

View File

@@ -8,7 +8,6 @@ import (
"strings"
"time"
agentchat "github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
@@ -17,6 +16,7 @@ import (
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
"github.com/LoveLosita/smartflow/backend/model"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/pkg"
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
@@ -318,7 +318,7 @@ func (s *AgentService) runNormalChatFlow(
// 3. 计算本次请求可用的历史 token 预算,并执行历史裁剪。
// 这样可以在上下文增长时稳定控制模型窗口,避免超长上下文引发报错或高延迟。
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agentchat.SystemPrompt, userMessage)
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, newagentprompt.SystemPrompt, userMessage)
trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget)
chatHistory = trimmedHistory
@@ -346,7 +346,7 @@ func (s *AgentService) runNormalChatFlow(
// 6. 执行真正的流式聊天。
// fullText 用于后续写 Redis/持久化outChan 用于把流片段实时推给前端。
fullText, reasoningText, reasoningDurationSeconds, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart, assistantReasoningStartedAt)
fullText, reasoningText, reasoningDurationSeconds, streamUsage, streamErr := s.streamChatFallback(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, assistantReasoningStartedAt)
if streamErr != nil {
pushErrNonBlocking(errChan, streamErr)
return

View File

@@ -18,9 +18,9 @@ import (
"github.com/cloudwego/eino/schema"
"github.com/spf13/viper"
agentchat "github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/respond"
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
@@ -393,7 +393,7 @@ func (s *AgentService) loadConversationContext(ctx context.Context, chatID, user
}
// 构造 ConversationContext。
conversationContext := newagentmodel.NewConversationContext(agentchat.SystemPrompt)
conversationContext := newagentmodel.NewConversationContext(newagentprompt.SystemPrompt)
if history != nil {
conversationContext.ReplaceHistory(history)
}

View File

@@ -1,303 +0,0 @@
package agentsvc
import (
"context"
"fmt"
"log"
"strings"
"time"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/google/uuid"
)
// quickNoteRoutingDecision 只是路由层结果的本地别名。
// 保留这个别名是为了尽量少改调用侧agent.go 中的字段访问保持不变)。
type quickNoteRoutingDecision = agentrouter.RoutingDecision
// quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。
// 设计目标:
// 1) 不改现有 OpenAI 兼容协议外壳;
// 2) 让 Apifox 在等待期间也能看到“思考块”,避免用户空等;
// 3) 该 emitter 只负责状态,不负责最终正文回复和 [DONE] 结束块。
type quickNoteProgressEmitter struct {
outChan chan<- string
modelName string
requestID string
created int64
enablePush bool
reasoning strings.Builder
startedAt *time.Time
}
// newQuickNoteProgressEmitter 构造“阶段进度推送器”。
// 该推送器只负责发 reasoning 块,不负责正文回复。
func newQuickNoteProgressEmitter(outChan chan<- string, modelName string, enable bool) *quickNoteProgressEmitter {
// 1. 模型名兜底,避免出现空 model 字段导致客户端兼容性问题。
resolvedModel := strings.TrimSpace(modelName)
if resolvedModel == "" {
resolvedModel = "worker"
}
// 2. 每次请求生成独立 request_id方便前端或日志侧关联本次流式输出。
return &quickNoteProgressEmitter{
outChan: outChan,
modelName: resolvedModel,
requestID: "chatcmpl-" + uuid.NewString(),
created: time.Now().Unix(),
enablePush: enable,
}
}
// Emit 按“阶段 + 说明”输出 reasoning_content。
// 注意:
// 1) 这里不输出 role避免和后续正文 role 块冲突;
// 2) 即使发送失败,也只记录日志,不影响主流程继续执行。
func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
// 1. 推送器不可用nil/禁用/无通道)时直接返回,避免 panic。
if e == nil || !e.enablePush || e.outChan == nil {
return
}
// 2. 统一清理空白,避免日志和输出里出现异常空字符串。
stage = strings.TrimSpace(stage)
detail = strings.TrimSpace(detail)
if stage == "" && detail == "" {
return
}
if e.startedAt == nil {
now := time.Now()
e.startedAt = &now
}
if e.reasoning.Len() > 0 {
e.reasoning.WriteString("\n\n")
}
if stage != "" {
e.reasoning.WriteString("阶段:")
e.reasoning.WriteString(stage)
}
if detail != "" {
if stage != "" {
e.reasoning.WriteString("\n")
}
e.reasoning.WriteString(detail)
}
// 3. 调用目的:阶段提示统一走 Agent/stream 的 reasoning chunk 包装,
// 避免 service 层继续自己拼 OpenAI 兼容 JSON。
err := agentstream.EmitStageAsReasoning(func(payload string) error {
e.outChan <- payload
return nil
}, e.requestID, e.modelName, e.created, stage, detail, false)
if err != nil {
// 3.1 阶段推送失败不应影响主链路,只打日志即可。
log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err)
return
}
}
func (e *quickNoteProgressEmitter) HistoryText() string {
if e == nil {
return ""
}
return strings.TrimSpace(e.reasoning.String())
}
func (e *quickNoteProgressEmitter) StartedAt() *time.Time {
if e == nil || e.startedAt == nil {
return nil
}
startCopy := *e.startedAt
return &startCopy
}
func (e *quickNoteProgressEmitter) DurationSeconds(end time.Time) int {
if e == nil || e.startedAt == nil {
return 0
}
if !end.After(*e.startedAt) {
return 0
}
return int(end.Sub(*e.startedAt) / time.Second)
}
// tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。
// 返回值语义:
// 1) handled=true本次请求已在随口记链路处理完成成功/失败都会返回文案);
// 2) handled=false不是随口记意图调用方应回落普通聊天链路
// 3) state用于拼接最终“一次性正文回复”。
func (s *AgentService) tryHandleQuickNoteWithGraph(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
chatID string,
traceID string,
trustRoute bool,
emitStage func(stage, detail string),
) (handled bool, state *agentmodel.QuickNoteState, err error) {
// 1. 依赖预检taskRepo 或模型未注入时,不做随口记处理,交给上层回落聊天。
if s.taskRepo == nil || selectedModel == nil {
return false, nil, nil
}
// 2. 初始化随口记状态对象(贯穿 graph 全流程的共享上下文)。
state = agentmodel.NewQuickNoteState(traceID, userID, chatID, userMessage)
// 3. 执行 quick note graph。
// 本次依赖注入了两个“工具能力”:
// 3.1 ResolveUserID从当前请求上下文确定 user_id
// 3.2 CreateTask真正执行任务写库。
finalState, runErr := agentgraph.RunQuickNoteGraph(ctx, agentnode.QuickNoteGraphRunInput{
Model: selectedModel,
State: state,
Deps: agentnode.QuickNoteToolDeps{
ResolveUserID: func(ctx context.Context) (int, error) {
// 当前链路 userID 已由上层鉴权拿到,这里直接复用。
return userID, nil
},
CreateTask: func(ctx context.Context, req agentnode.QuickNoteCreateTaskRequest) (*agentnode.QuickNoteCreateTaskResult, error) {
// 3.2.1 把 quick note 的工具入参映射成项目 Task 模型。
taskModel := &model.Task{
UserID: req.UserID,
Title: req.Title,
Priority: req.PriorityGroup,
IsCompleted: false,
DeadlineAt: req.DeadlineAt,
UrgencyThresholdAt: req.UrgencyThresholdAt,
}
// 3.2.2 调用 DAO 写库。
created, createErr := s.taskRepo.AddTask(taskModel)
if createErr != nil {
return nil, createErr
}
// 3.2.3 把写库结果回填给 graph 状态,用于后续回复拼装。
return &agentnode.QuickNoteCreateTaskResult{
TaskID: created.ID,
Title: created.Title,
PriorityGroup: created.Priority,
DeadlineAt: created.DeadlineAt,
UrgencyThresholdAt: created.UrgencyThresholdAt,
}, nil
},
},
SkipIntentVerification: trustRoute,
EmitStage: emitStage,
})
if runErr != nil {
// 4. graph 执行失败由上层统一决定是否回退普通聊天。
return false, nil, runErr
}
// 5. graph 正常结束但判定“非随口记”时,明确返回 handled=false。
if finalState == nil || !finalState.IsQuickNoteIntent {
return false, nil, nil
}
// 6. 走到这里表示随口记链路已完成(含写库成功或业务失败反馈文案)。
return true, finalState, nil
}
// emitSingleAssistantCompletion 将单条完整回复包装成 OpenAI 兼容 chunk 流并写入 outChan。
// 说明:
// 1) 保持现有 OpenAI 兼容格式不变;
// 2) 正文只发一次,不做伪分段。
func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply string) error {
// 1. 模型名兜底,保持 OpenAI 兼容响应字段完整。
if strings.TrimSpace(modelName) == "" {
modelName = "worker"
}
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
emit := func(payload string) error {
outChan <- payload
return nil
}
if err := agentstream.EmitAssistantReply(emit, requestID, modelName, created, reply, true); err != nil {
return err
}
if err := agentstream.EmitFinish(emit, requestID, modelName, created); err != nil {
return err
}
return agentstream.EmitDone(emit)
}
// buildQuickNoteFinalReply 生成最终的一次性正文回复。
// 组合策略:
// 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确;
// 2) 轻松跟进句交给 AI 生成,贴合用户话题;
// 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。
func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agentmodel.QuickNoteState) string {
// 1. 极端兜底:状态为空时给出稳定失败文案,避免返回空字符串。
if state == nil {
return "我这次没成功记上,别急,再发我一次我马上补上。"
}
// 仅当“确实拿到了有效 task_id”时才走成功文案避免出现“回复成功但库里没数据”的错觉。
if state.Persisted && state.PersistedTaskID > 0 {
// 2. 组装“事实段”:标题 + 优先级 + 截止时间。
title := strings.TrimSpace(state.ExtractedTitle)
if title == "" {
title = "这条任务"
}
priorityText := "已安排优先级"
if agentmodel.IsValidTaskPriority(state.ExtractedPriority) {
priorityText = fmt.Sprintf("优先级:%s", agentmodel.PriorityLabelCN(state.ExtractedPriority))
}
deadlineText := ""
if state.ExtractedDeadline != nil {
deadlineText = fmt.Sprintf(";截止时间 %s", state.ExtractedDeadline.In(time.Local).Format("2006-01-02 15:04"))
}
factLine := fmt.Sprintf("好,给你安排上了:%s%s%s。", title, priorityText, deadlineText)
// 2.1 如果 graph 单次请求已生成 banter直接使用避免重复调用模型。
if strings.TrimSpace(state.ExtractedBanter) != "" {
return factLine + " " + strings.TrimSpace(state.ExtractedBanter)
}
// 2.2 聚合调用模式下,通常已在主流程完成风格化,给稳定文案即可。
if state.PlannedBySingleCall {
return factLine + " 已帮你稳稳记下,放心推进。"
}
// 2.3 兜底生成轻松跟进句;失败则降级固定文案,确保体验连续。
banter, err := agentllm.GenerateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText)
if err != nil {
return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。"
}
if strings.TrimSpace(banter) == "" {
return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。"
}
return factLine + " " + banter
}
// 3. 若时间校验失败,优先返回“可执行的修正引导”。
if strings.TrimSpace(state.DeadlineValidationError) != "" {
return "我识别到你给了时间但格式不够明确暂时不敢乱记。你可以改成比如2026-03-20 18:30、明天下午3点、下周一上午9点我立刻帮你安排。"
}
// 4. 若 graph 已给出助手回复(例如非意图/业务失败原因),优先透传。
if strings.TrimSpace(state.AssistantReply) != "" {
return strings.TrimSpace(state.AssistantReply)
}
// 5. 最终兜底文案。
return "这次没成功写入任务,我没跑路,再给我一次我就把它稳稳记上。"
}
// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。
// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 Agent/router 包。
func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision {
// 这里保留方法是为了让 AgentService 对外语义完整,
// 同时避免上层调用方直接依赖 Agent/router降低耦合。
_ = s
return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
}

View File

@@ -1,27 +0,0 @@
package agentsvc
import (
"context"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// actionRoutingDecision 是 route 层分流结果在 agentsvc 的本地别名。
//
// 设计目的:
// 1. 让 AgentService 对 route 包保持“最小接触面”;
// 2. 后续若 route 包返回结构调整,只需改这个桥接文件。
type actionRoutingDecision = agentrouter.RoutingDecision
// decideActionRouting 决定当前请求走向哪条业务链路。
//
// 职责边界:
// 1. 只负责调用 route 包拿分流结论;
// 2. 不负责执行任何业务节点;
// 3. route 层失败会通过 RoutingDecision.RouteFailed 向上层显式暴露。
func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision {
// 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。
_ = s
return agentrouter.DecideActionRouting(ctx, selectedModel, userMessage)
}

View File

@@ -1,162 +0,0 @@
package agentsvc
import (
"context"
"errors"
"log"
"strings"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/spf13/viper"
)
// runSchedulePlanFlow 执行“智能排程”分支。
//
// 职责边界:
// 1. 负责把本次请求接入 scheduleplan graph并注入运行依赖。
// 2. 负责读取对话历史(优先 Redis未命中再回源 DB用于连续对话微调。
// 3. 负责把排程预览快照写入 Redis供查询接口拉取 JSON
// 4. 负责返回给上层“可直接发给用户的最终文本回复”。
// 5. 不负责聊天持久化(由 AgentChat 主链路统一处理)。
func (s *AgentService) runSchedulePlanFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
chatID string,
traceID string,
extra map[string]any,
emitStage func(stage, detail string),
outChan chan<- string,
modelName string,
) (string, error) {
// 1. 依赖预检:缺硬依赖时直接失败,避免进入 graph 后才出现空指针或半途失败。
// 1.1 SmartPlanningMultiRaw / HybridScheduleWithPlanMulti / ResolvePlanningWindow 任一缺失都无法继续。
// 1.2 selectedModel 为空时无法执行 LLM 节点,直接返回错误由上层处理。
if s.SmartPlanningMultiRawFunc == nil || s.HybridScheduleWithPlanMultiFunc == nil || s.ResolvePlanningWindowFunc == nil {
return "", errors.New("schedule plan service dependencies are not ready")
}
if selectedModel == nil {
return "", errors.New("schedule plan model is nil")
}
// 2. 连续对话微调前置处理:先尝试读取“上一版预览快照”,再清理旧 key。
// 2.1 先读后删的原因:
// 2.1.1 若先删再读,会丢失“连续微调起点”;
// 2.1.2 先读可让本轮在内存中复用上轮 HybridEntries。
// 2.2 清理旧 key 仍然保留,避免前端在本轮进行中误读到旧结果。
var previousPreview *model.SchedulePlanPreviewCache
if s.cacheDAO != nil {
preview, getErr := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, chatID)
if getErr != nil {
log.Printf("读取上一版排程预览失败 chat_id=%s: %v", chatID, getErr)
} else {
previousPreview = preview
}
if delErr := s.cacheDAO.DeleteSchedulePlanPreviewFromCache(ctx, userID, chatID); delErr != nil {
log.Printf("清理旧排程预览失败 chat_id=%s: %v", chatID, delErr)
}
}
// 2.3 Redis miss 时回落 MySQL 快照:
// 2.3.1 目的:即使 Redis TTL 过期,也能延续同会话微调语境;
// 2.3.2 回填:命中 DB 后尝试回填 Redis提高后续读取命中率
// 2.3.3 失败策略DB 读取异常只打日志,链路继续按“无历史快照”执行。
if previousPreview == nil && s.repo != nil {
snapshot, snapshotErr := s.repo.GetScheduleStateSnapshot(ctx, userID, chatID)
if snapshotErr != nil {
log.Printf("从 MySQL 读取排程快照失败 chat_id=%s: %v", chatID, snapshotErr)
} else if snapshot != nil {
previousPreview = snapshotToSchedulePlanPreviewCache(snapshot)
if s.cacheDAO != nil && previousPreview != nil {
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, chatID, previousPreview); setErr != nil {
log.Printf("回填排程预览缓存失败 chat_id=%s: %v", chatID, setErr)
}
}
}
}
// 3. 读取对话历史:先快后稳。
// 3.1 先查 Redis命中则避免回源 DB降低请求时延。
// 3.2 Redis 异常仅记录日志,不中断主流程(回源 DB 兜底)。
var chatHistory []*schema.Message
if s.agentCache != nil {
history, err := s.agentCache.GetHistory(ctx, chatID)
if err != nil {
log.Printf("获取排程对话历史失败 chat_id=%s: %v", chatID, err)
} else if history != nil {
chatHistory = history
}
}
// 3.3 Redis 未命中时回源 DB保证链路在缓存波动时仍可用。
// 3.4 DB 回源失败同样只记日志并继续,让 graph 按“无历史”降级运行。
if chatHistory == nil && s.repo != nil {
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID)
if hisErr != nil {
log.Printf("回源 DB 获取排程对话历史失败 chat_id=%s: %v", chatID, hisErr)
} else {
chatHistory = conv.ToEinoMessages(histories)
}
}
// 4. 执行 graph 主流程。
// 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。
// 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。
state := agentmodel.NewSchedulePlanState(traceID, userID, chatID)
// 4.3 连续对话微调注入:
// 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state
// 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。
if previousPreview != nil {
state.HasPreviousPreview = true
state.PreviousTaskClassIDs = append([]int(nil), previousPreview.TaskClassIDs...)
state.PreviousHybridEntries = cloneHybridEntries(previousPreview.HybridEntries)
state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems)
state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans)
}
finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{
Model: selectedModel,
State: state,
Deps: agentnode.SchedulePlanToolDeps{
SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc,
HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc,
ResolvePlanningWindow: s.ResolvePlanningWindowFunc,
},
UserMessage: userMessage,
Extra: extra,
ChatHistory: chatHistory,
EmitStage: emitStage,
OutChan: outChan,
ModelName: modelName,
DailyRefineConcurrency: viper.GetInt("agent.dailyRefineConcurrency"),
WeeklyAdjustBudget: viper.GetInt("agent.weeklyAdjustBudget"),
})
if runErr != nil {
// 4.3 graph 失败直接上抛,由上层决定回落或报错。
return "", runErr
}
// 5. 组装最终回复文本。
// 5.1 明确移除“把排程结果序列化成 JSON 文本直接回传”的抽象,
// 避免在 SSE 聊天链路里吐出原始 JSON影响前端展示与用户体验。
// 5.2 当 finalState 为空或 summary 为空时,返回统一兜底文案,保证接口有稳定输出。
if finalState == nil {
return "排程流程异常,请稍后重试。", nil
}
reply := strings.TrimSpace(finalState.FinalSummary)
if reply == "" {
reply = "排程流程已完成,但未生成结果摘要。"
}
// 6. 旁路写入排程预览缓存(结构化 JSON给查询接口拉取。
// 6.1 失败只记日志,不影响本次对话回复;
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
return reply, nil
}

View File

@@ -7,63 +7,11 @@ import (
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/LoveLosita/smartflow/backend/model"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
"github.com/LoveLosita/smartflow/backend/respond"
)
// saveSchedulePlanPreview 负责把排程结果同步写入“查询预览”所需的缓存与快照。
//
// 职责边界:
// 1. 负责把 graph 最终状态映射为统一预览 DTO并先写 Redis、再写 MySQL 快照。
// 2. 负责执行“失败不阻断主回复”的旁路持久化策略,避免影响聊天主链路。
// 3. 不负责 SSE 输出,不负责聊天消息落库,也不负责 refine 状态到 plan 状态的转换。
func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 先做最小前置校验,避免把空状态或空会话写成脏快照。
if s == nil || finalState == nil {
return
}
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" {
return
}
// 2. 组装统一预览缓存结构。
// 2.1 summary 为空时使用统一兜底文案,保证查询接口始终有稳定输出。
// 2.2 所有切片字段都做深拷贝,避免缓存与 graph state 共享底层数组。
preview := &model.SchedulePlanPreviewCache{
UserID: userID,
ConversationID: normalizedChatID,
TraceID: strings.TrimSpace(finalState.TraceID),
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(finalState.FinalSummary)),
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 真正兜底由后续 MySQL 快照承担。
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 快照,保证缓存失效后仍能恢复预览与连续微调上下文。
// 4.1 这里继续采用“同步写快照”的策略,因为下一轮 refine 依赖强一致读取;
// 4.2 写库失败同样只记日志,避免让用户侧回复因为旁路持久化失败而中断。
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
}
}
}
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
//
// 职责边界:
@@ -81,8 +29,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
}
// 2. 优先查 Redis。
// 2.1 命中后立即校验 user_id避免把别人的会话预览泄露给当前用户
// 2.2 缓存异常直接上抛,由接口层统一处理错误响应。
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
@@ -92,7 +38,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
if preview.UserID > 0 && preview.UserID != userID {
return nil, respond.SchedulePlanPreviewNotFound
}
plans := cloneWeekSchedules(preview.CandidatePlans)
plans := newagentshared.CloneWeekSchedules(preview.CandidatePlans)
if plans == nil {
plans = make([]model.UserWeekSchedule, 0)
}
@@ -101,7 +47,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
TraceID: strings.TrimSpace(preview.TraceID),
Summary: strings.TrimSpace(preview.Summary),
CandidatePlans: plans,
HybridEntries: cloneHybridEntries(preview.HybridEntries),
HybridEntries: newagentshared.CloneHybridEntries(preview.HybridEntries),
TaskClassIDs: preview.TaskClassIDs,
GeneratedAt: preview.GeneratedAt,
}, nil
@@ -109,8 +55,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
}
// 3. Redis 未命中时回源 MySQL。
// 3.1 命中快照后顺手回填 Redis提高后续命中率
// 3.2 DB 未命中才真正返回 not found避免缓存过期造成假阴性。
if s.repo != nil {
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
@@ -131,49 +75,6 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
return nil, respond.SchedulePlanPreviewNotFound
}
// cloneWeekSchedules 负责深拷贝周视图排程,避免缓存与运行态共享底层切片。
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
return agentshared.CloneWeekSchedules(src)
}
// cloneHybridEntries 负责深拷贝混合排程条目,避免跨请求污染。
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
return agentshared.CloneHybridEntries(src)
}
// cloneTaskClassItems 负责深拷贝任务项切片,包含内部指针字段的安全复制。
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
return agentshared.CloneTaskClassItems(src)
}
// buildSchedulePlanSnapshotFromState 把 graph 最终状态映射成可持久化的快照 DTO。
//
// 职责边界:
// 1. 负责字段归一化、深拷贝和 state_version 补齐。
// 2. 不负责数据库写入,也不负责生成业务摘要文案。
func buildSchedulePlanSnapshotFromState(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 {
@@ -188,10 +89,10 @@ func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapsho
ConversationID: snapshot.ConversationID,
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans),
CandidatePlans: newagentshared.CloneWeekSchedules(snapshot.CandidatePlans),
TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...),
HybridEntries: cloneHybridEntries(snapshot.HybridEntries),
AllocatedItems: cloneTaskClassItems(snapshot.AllocatedItems),
HybridEntries: newagentshared.CloneHybridEntries(snapshot.HybridEntries),
AllocatedItems: newagentshared.CloneTaskClassItems(snapshot.AllocatedItems),
GeneratedAt: generatedAt,
}
}
@@ -201,7 +102,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap
if snapshot == nil {
return nil
}
plans := cloneWeekSchedules(snapshot.CandidatePlans)
plans := newagentshared.CloneWeekSchedules(snapshot.CandidatePlans)
if plans == nil {
plans = make([]model.UserWeekSchedule, 0)
}
@@ -214,7 +115,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap
TraceID: strings.TrimSpace(snapshot.TraceID),
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
CandidatePlans: plans,
HybridEntries: cloneHybridEntries(snapshot.HybridEntries),
HybridEntries: newagentshared.CloneHybridEntries(snapshot.HybridEntries),
TaskClassIDs: snapshot.TaskClassIDs,
GeneratedAt: generatedAt,
}

View File

@@ -1,175 +0,0 @@
package agentsvc
import (
"context"
"errors"
"log"
"strings"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// runScheduleRefineFlow 鎵ц鈥滆繛缁璇濆井璋冩帓绋嬧€濆垎鏀€?
//
// 鑱岃矗杈圭晫锛?
// 1. 璐熻矗璇诲彇鈥滀笂涓€鐗堟帓绋嬮瑙堝揩鐓р€濓紙浼樺厛 Redis锛岀己澶卞啀鍥炴簮 MySQL锛夛紱
// 2. 璐熻矗璋冪敤鐙珛 schedulerefine 鍥鹃摼璺畬鎴愭湰杞井璋冿紱
// 3. 璐熻矗鎶婂井璋冪粨鏋滃洖鍐欓瑙堢紦瀛樹笌鐘舵€佸揩鐓э紝渚涘悗缁户缁井璋冿紱
// 4. 涓嶈礋璐h亰澶╂秷鎭寔涔呭寲锛堟秷鎭寔涔呭寲鐢?AgentChat 涓婚摼璺粺涓€澶勭悊锛夈€?
func (s *AgentService) runScheduleRefineFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
chatID string,
traceID string,
emitStage func(stage, detail string),
outChan chan<- string,
modelName string,
) (string, error) {
_ = outChan
_ = modelName
// 1. 渚濊禆棰勬锛氭ā鍨嬩负绌烘椂鏃犳硶鎵ц浠讳綍鑺傜偣锛岀洿鎺ュけ璐ラ伩鍏嶇┖鎸囬拡銆?
if selectedModel == nil {
return "", errors.New("schedule refine model is nil")
}
emitStage("schedule_refine.context.loading", "正在加载上一版排程上下文。")
// 2. 鍏堟煡 Redis 棰勮蹇収锛屼繚璇佺儹璺緞浣庡欢杩熴€?
// 2.1 濡傛灉 Redis 鏈懡涓紝鍐嶅洖婧?MySQL 蹇収鍏滃簳锛?
// 2.2 濡傛灉涓よ€呴兘娌℃湁锛岃鏄庡綋鍓嶄細璇濇病鏈夊彲寰皟鍩虹锛岀洿鎺ヨ繑鍥炰笟鍔¢敊璇€?
preview := s.loadSchedulePreviewContext(ctx, userID, chatID)
if preview == nil {
return "", respond.SchedulePlanPreviewNotFound
}
// 3. 鍒濆鍖栧井璋冪姸鎬佸苟杩愯鐙珛鍥俱€?
state := agentnode.NewScheduleRefineState(traceID, userID, chatID, userMessage, preview)
finalState, runErr := agentgraph.RunScheduleRefineGraph(ctx, agentnode.ScheduleRefineGraphRunInput{
Model: selectedModel,
State: state,
EmitStage: emitStage,
})
if runErr != nil {
return "", runErr
}
if finalState == nil {
return "", errors.New("schedule refine graph returned nil state")
}
// 4. 璋冪敤鐩殑锛?
// 4.1 saveSchedulePlanPreview 鐩墠鏄€滈瑙堢紦瀛?+ MySQL 蹇収鈥濈殑缁熶竴鍐欏叆鍙o紱
// 4.2 杩欓噷鎶?refine state 鏄犲皠涓?scheduleplan state锛屽鐢ㄥ凡鏈夎惤鐩橀摼璺紱
// 4.3 浣嗚嫢鏄€滅嫭绔嬪鍚堝垎鏀凡鍑虹珯銆佺粓瀹′粛澶辫触鈥濓紝鍒欎笉瑕嗙洊涓婁竴鐗堥瑙堬紝閬垮厤澶栭儴璇互涓烘柊鏂规宸查獙璇侀€氳繃銆?
if shouldPersistScheduleRefinePreview(finalState) {
s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
} else {
emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。")
}
reply := strings.TrimSpace(finalState.FinalSummary)
if reply == "" {
reply = "微调已完成,但本轮未生成总结文案。"
}
return reply, nil
}
// loadSchedulePreviewContext 璇诲彇鈥滃彲鐢ㄤ簬杩炵画寰皟鈥濈殑鎺掔▼涓婁笅鏂囧揩鐓с€?
//
// 姝ラ鍖栬鏄庯細
// 1. 鍏堟煡 Redis锛氬懡涓垯鐩存帴杩斿洖锛屾椂寤舵渶灏忥紱
// 2. Redis miss 鍐嶆煡 MySQL锛氫繚璇佺紦瀛樿繃鏈熷悗浠嶅彲缁х画寰皟锛?
// 3. 鑻?MySQL 鍛戒腑涓?Redis 鍙敤锛岄『渚垮洖濉?Redis锛屾彁鍗囧悗缁懡涓巼锛?
// 4. 浠讳竴姝ュけ璐ヤ粎鎵撴棩蹇楋紝涓?panic锛岀敱涓婂眰鏍规嵁杩斿洖 nil 鍋氱粺涓€澶勭悊銆?
func (s *AgentService) loadSchedulePreviewContext(ctx context.Context, userID int, chatID string) *model.SchedulePlanPreviewCache {
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" || userID <= 0 {
return nil
}
if s.cacheDAO != nil {
preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("璇诲彇鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, err)
} else if preview != nil {
return preview
}
}
if s.repo == nil {
return nil
}
snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("璇诲彇鎺掔▼鐘舵€佸揩鐓уけ璐?chat_id=%s: %v", normalizedChatID, err)
return nil
}
if snapshot == nil {
return nil
}
preview := snapshotToSchedulePlanPreviewCache(snapshot)
if preview != nil && s.cacheDAO != nil {
if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); setErr != nil {
log.Printf("鍥炲~鎺掔▼棰勮缂撳瓨澶辫触 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return preview
}
// convertRefineStateToPlanState 鎶?schedulerefine 鐘舵€佹槧灏勪负 scheduleplan 鐘舵€併€?
//
// 璁捐鎰忓浘锛?
// 1. 澶嶇敤鐜版湁 saveSchedulePlanPreview 鍐欏叆閾捐矾锛屽噺灏戦噸澶嶈惤鐩樹唬鐮侊紱
// 2. 浠呮槧灏勨€滈瑙堟寔涔呭寲蹇呴』瀛楁鈥濓紝閬垮厤鎶?refine 杩愯鏈熶复鏃跺瓧娈靛甫鍏ュ瓨鍌ㄥ眰锛?
// 3. 鍚庣画濡傝鎵╁睍 refine 涓撳睘蹇収瀛楁锛屽彲鍦ㄨ鏄犲皠澶勯泦涓紨杩涖€?
func convertRefineStateToPlanState(st *agentnode.ScheduleRefineState) *agentmodel.SchedulePlanState {
if st == nil {
return nil
}
adjustmentScope := "medium"
if st.Contract.Strategy == "keep" {
adjustmentScope = "small"
}
return &agentmodel.SchedulePlanState{
TraceID: strings.TrimSpace(st.TraceID),
UserID: st.UserID,
ConversationID: strings.TrimSpace(st.ConversationID),
UserIntent: strings.TrimSpace(st.UserIntent),
Constraints: append([]string(nil), st.Constraints...),
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
Strategy: "steady",
AdjustmentScope: adjustmentScope,
IsAdjustment: true,
HybridEntries: append([]model.HybridScheduleEntry(nil), st.HybridEntries...),
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
FinalSummary: strings.TrimSpace(st.FinalSummary),
Completed: st.Completed,
}
}
// shouldPersistScheduleRefinePreview 鍒ゆ柇鈥滄湰杞井璋冪粨鏋滄槸鍚﹀簲瑕嗙洊涓婁竴鐗堥瑙堚€濄€?
//
// 鑱岃矗杈圭晫锛?
// 1. 榛樿娌跨敤鍘熸湁 refine 鎸佷箙鍖栫瓥鐣ワ紝淇濊瘉鏅€?ReAct 寰皟閾捐矾涓嶅彈褰卞搷锛?
// 2. 浠呭綋鈥滅嫭绔嬪鍚堝垎鏀凡鐩存帴鍑虹珯锛屼絾缁堝鏈€氳繃鈥濇椂锛屾嫆缁濊鐩栦笂涓€鐗堥瑙堬紱
// 3. 杩欐牱鍙互閬垮厤澶栧眰鎶婃湭缁忛獙璇佺殑澶嶅悎缁撴灉褰撴垚鏂扮殑鍩虹嚎缁х画婊氬姩寰皟銆?
func shouldPersistScheduleRefinePreview(st *agentnode.ScheduleRefineState) bool {
if st == nil {
return false
}
if st.CompositeRouteSucceeded && !agentnode.FinalHardCheckPassed(st) {
return false
}
return true
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/LoveLosita/smartflow/backend/model"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
"github.com/LoveLosita/smartflow/backend/respond"
)
@@ -115,10 +116,10 @@ func (s *AgentService) refreshSchedulePreviewAfterStateSave(
if existingPreview != nil {
preview.TraceID = strings.TrimSpace(existingPreview.TraceID)
if len(existingPreview.CandidatePlans) > 0 {
preview.CandidatePlans = cloneWeekSchedules(existingPreview.CandidatePlans)
preview.CandidatePlans = newagentshared.CloneWeekSchedules(existingPreview.CandidatePlans)
}
if len(existingPreview.AllocatedItems) > 0 {
preview.AllocatedItems = cloneTaskClassItems(existingPreview.AllocatedItems)
preview.AllocatedItems = newagentshared.CloneTaskClassItems(existingPreview.AllocatedItems)
}
if len(preview.TaskClassIDs) == 0 && len(existingPreview.TaskClassIDs) > 0 {
preview.TaskClassIDs = append([]int(nil), existingPreview.TaskClassIDs...)

View File

@@ -1,4 +1,4 @@
package agentchat
package agentsvc
import (
"context"
@@ -6,19 +6,17 @@ import (
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent/llm"
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// StreamChat 负责模型流式输出,并在关键节点打点:
// 1) 流连接建立llm.Stream 返回)
// 2) 首包到达(首字延迟)
// 3) 流式输出结束
func StreamChat(
// streamChatFallback 是 graph 执行失败时的降级流式聊天。
// 内联了旧 agentchat.StreamChat 的核心逻辑,不再依赖 agent/ 包。
func (s *AgentService) streamChatFallback(
ctx context.Context,
llm *ark.ChatModel,
modelName string,
@@ -26,15 +24,10 @@ func StreamChat(
ifThinking bool,
chatHistory []*schema.Message,
outChan chan<- string,
traceID string,
chatID string,
requestStart time.Time,
reasoningStartAt *time.Time,
) (string, string, int, *schema.TokenUsage, error) {
/*callStart := time.Now()*/
messages := make([]*schema.Message, 0)
messages = append(messages, schema.SystemMessage(SystemPrompt))
messages := make([]*schema.Message, 0, len(chatHistory)+2)
messages = append(messages, schema.SystemMessage(newagentprompt.SystemPrompt))
if len(chatHistory) > 0 {
messages = append(messages, chatHistory...)
}
@@ -47,52 +40,40 @@ func StreamChat(
thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}
}
/*connectStart := time.Now()*/
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
if err != nil {
return "", "", 0, nil, err
}
defer reader.Close()
if strings.TrimSpace(modelName) == "" {
modelName = "smartflow-worker"
}
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
firstChunk := true
chunkCount := 0
var tokenUsage *schema.TokenUsage
var localReasoningStartAt *time.Time
if reasoningStartAt != nil && !reasoningStartAt.IsZero() {
startCopy := reasoningStartAt.In(time.Local)
localReasoningStartAt = &startCopy
}
var reasoningEndAt *time.Time
/*streamRecvStart := time.Now()
log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d",
traceID,
chatID,
requestID,
time.Since(connectStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
len(chatHistory),
)*/
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
if err != nil {
return "", "", 0, nil, err
}
defer reader.Close()
var fullText strings.Builder
var reasoningText strings.Builder
var tokenUsage *schema.TokenUsage
for {
chunk, err := reader.Recv()
if err == io.EOF {
chunk, recvErr := reader.Recv()
if recvErr == io.EOF {
break
}
if err != nil {
return "", "", 0, nil, err
if recvErr != nil {
return "", "", 0, nil, recvErr
}
// 优先记录模型真实 usage通常在尾块返回部分模型也可能中途返回
if chunk != nil && chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
tokenUsage = newagentstream.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
}
if chunk != nil {
@@ -108,44 +89,23 @@ func StreamChat(
reasoningText.WriteString(chunk.ReasoningContent)
}
payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
if err != nil {
return "", "", 0, nil, err
payload, payloadErr := newagentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
if payloadErr != nil {
return "", "", 0, nil, payloadErr
}
if payload != "" {
outChan <- payload
chunkCount++
firstChunk = false
/*if firstChunk {
log.Printf("打点|首包到达|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
time.Since(streamRecvStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)
firstChunk = false
}*/
}
}
finishChunk, err := agentstream.ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return "", "", 0, nil, err
finishChunk, finishErr := newagentstream.ToOpenAIFinishStream(requestID, modelName, created)
if finishErr != nil {
return "", "", 0, nil, finishErr
}
outChan <- finishChunk
outChan <- "[DONE]"
/*log.Printf("打点|流式输出结束|trace_id=%s|chat_id=%s|request_id=%s|chunks=%d|reply_chars=%d|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
chunkCount,
len(fullText.String()),
time.Since(callStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)*/
reasoningDurationSeconds := 0
if localReasoningStartAt != nil {
if reasoningEndAt == nil {

View File

@@ -7,44 +7,12 @@ import (
"strings"
"time"
agentgraph "github.com/LoveLosita/smartflow/backend/agent/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
"github.com/LoveLosita/smartflow/backend/model"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"
)
func (s *AgentService) runTaskQueryFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
emitStage func(stage, detail string),
) (string, error) {
if s == nil || s.taskRepo == nil {
return "", errors.New("task query service dependency is not ready")
}
if selectedModel == nil {
return "", errors.New("task query model is nil")
}
requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04")
state := agentmodel.NewTaskQueryState(strings.TrimSpace(userMessage), requestNow, agentmodel.DefaultTaskQueryReflectRetry)
return agentgraph.RunTaskQueryGraph(ctx, agentnode.TaskQueryGraphRunInput{
Model: selectedModel,
State: state,
EmitStage: emitStage,
Deps: agentnode.TaskQueryToolDeps{
QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
req.UserID = userID
return s.QueryTasksForTool(ctx, req)
},
},
})
}
func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
func (s *AgentService) QueryTasksForTool(ctx context.Context, req newagentmodel.TaskQueryRequest) ([]newagentmodel.TaskQueryTaskRecord, error) {
_ = ctx
if req.UserID <= 0 {
return nil, errors.New("invalid user_id in task query")
@@ -56,7 +24,7 @@ func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.Task
tasks, err := s.taskRepo.GetTasksByUserID(req.UserID)
if err != nil {
if errors.Is(err, respond.UserTasksEmpty) {
return make([]agentnode.TaskQueryTaskRecord, 0), nil
return make([]newagentmodel.TaskQueryTaskRecord, 0), nil
}
return nil, err
}
@@ -77,9 +45,9 @@ func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.Task
filtered = filtered[:req.Limit]
}
records := make([]agentnode.TaskQueryTaskRecord, 0, len(filtered))
records := make([]newagentmodel.TaskQueryTaskRecord, 0, len(filtered))
for _, task := range filtered {
records = append(records, agentnode.TaskQueryTaskRecord{
records = append(records, newagentmodel.TaskQueryTaskRecord{
ID: task.ID,
Title: task.Title,
PriorityGroup: task.Priority,
@@ -106,7 +74,7 @@ func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
}
}
func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) bool {
func taskMatchesQueryFilter(task model.Task, req newagentmodel.TaskQueryRequest) bool {
if !req.IncludeCompleted && task.IsCompleted {
return false
}
@@ -130,7 +98,7 @@ func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) boo
return true
}
func sortTasksForQuery(tasks []model.Task, req agentnode.TaskQueryRequest) {
func sortTasksForQuery(tasks []model.Task, req newagentmodel.TaskQueryRequest) {
if len(tasks) <= 1 {
return
}

View File

@@ -126,7 +126,7 @@ async function handleOfficialSave() {
})
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
applyBatchIntoSchedule(classId, groupItems, officialSaveIdempotencyKey.value)
applyBatchIntoSchedule(classId, groupItems, `${officialSaveIdempotencyKey.value}-${classId}`)
)
await Promise.all(promises)