Version: 0.7.6.dev.260325
后端: - ♻️ 将 `taskquery` 模块迁移至 `agent2`,并完成与 `agent2` 业务链路及整体结构的正式接入 前端: - 🧱 已完成基础框架搭建,并完成了登录、注册、主页等页面并对接了对应接口;但整体功能实现仍在完善中
This commit is contained in:
@@ -12,24 +12,22 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// QuickNoteGraphName 是“随口记”图编排的稳定标识。
|
||||
// 保留这个名字的目的:
|
||||
// 1. 让 compile 后的 graph 名称在日志、调试、可视化工具里有固定口径;
|
||||
// 2. 后续如果接入更多技能图,可以统一按技能名识别。
|
||||
// QuickNoteGraphName 是随口记图编排的稳定标识。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅用于 graph 编译和链路标识,方便日志与排障统一定位。
|
||||
// 2. 不参与意图判断,也不承载任务写库的业务语义。
|
||||
QuickNoteGraphName = "quick_note"
|
||||
)
|
||||
|
||||
// RunQuickNoteGraph 执行“随口记”图编排。
|
||||
// RunQuickNoteGraph 负责执行“随口记 -> 判断 -> 提取 -> 落库 -> 收口”的整条图链路。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责 graph 连线与运行时装配,不负责节点内部业务细节;
|
||||
// 2. graph 层只挂 node 层对外暴露的方法,不再维护额外 runner 适配层;
|
||||
// 3. 工具注册、时间基准补齐、compile 参数收口都在这里统一完成。
|
||||
// 1. 负责输入兜底、工具装配、节点注册与 graph 运行。
|
||||
// 2. 不负责每个节点的具体业务决策,节点内部逻辑由 node 层实现。
|
||||
// 3. 返回的 state 表示整条链路的最终状态,供上层继续拼接响应或写日志。
|
||||
func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInput) (*agentmodel.QuickNoteState, error) {
|
||||
// 1. 启动前先做硬校验。
|
||||
// 1.1 model 为空时无法调模型,直接失败;
|
||||
// 1.2 state 为空时图无法承载共享上下文,也必须直接拦截;
|
||||
// 1.3 tool deps 不完整时,后续 persist 节点必然失败,因此这里提前收口。
|
||||
// 1. 先校验最基础依赖,避免图已经启动后才发现模型或状态为空。
|
||||
if input.Model == nil {
|
||||
return nil, errors.New("quick note graph: model is nil")
|
||||
}
|
||||
@@ -40,9 +38,7 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 统一补齐本次请求的时间基准。
|
||||
// 2.1 RequestNow 只在整条 quicknote 链路入口确定一次,避免同一次请求里相对时间口径漂移;
|
||||
// 2.2 RequestNowText 是 prompt 注入用文本,缺失时也在这里统一补齐。
|
||||
// 2. 补齐当前请求时间,保证后续提示词、时间解析和落库字段都基于同一时刻。
|
||||
if input.State.RequestNow.IsZero() {
|
||||
input.State.RequestNow = agentshared.NowToMinute()
|
||||
}
|
||||
@@ -50,9 +46,7 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
input.State.RequestNowText = agentshared.FormatMinute(input.State.RequestNow)
|
||||
}
|
||||
|
||||
// 3. 构建工具包并提取“创建任务”工具。
|
||||
// 3.1 graph 层只关心“拿到一个可执行工具”,不关心工具内部如何注册;
|
||||
// 3.2 失败时直接返回,避免把半残依赖继续交给 node 层。
|
||||
// 3. 图运行前统一准备工具与节点容器,避免节点内部重复做依赖解析。
|
||||
toolBundle, err := agentnode.BuildQuickNoteToolBundle(ctx, input.Deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -62,18 +56,13 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 在 node 层创建节点容器。
|
||||
// 4.1 这一步就是“请求级依赖注入”的唯一收口点;
|
||||
// 4.2 graph 后续只认 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法,不再额外造 runner。
|
||||
nodes, err := agentnode.NewQuickNoteNodes(input, createTaskTool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 创建状态图容器,输入输出统一都是 *QuickNoteState。
|
||||
// 4. 主链路保持“意图识别 -> 优先级评估 -> 持久化 -> 退出”,中间通过 branch 决定是否提前结束或重试写库。
|
||||
graph := compose.NewGraph[*agentmodel.QuickNoteState, *agentmodel.QuickNoteState]()
|
||||
|
||||
// 6. 注册节点。
|
||||
if err = graph.AddLambdaNode(agentnode.QuickNoteGraphNodeIntent, compose.InvokableLambda(nodes.Intent)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,14 +76,9 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. 所有请求统一从 intent 节点开始。
|
||||
if err = graph.AddEdge(compose.START, agentnode.QuickNoteGraphNodeIntent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. intent 后分支:
|
||||
// 8.1 命中随口记且时间合法 -> priority;
|
||||
// 8.2 非随口记,或时间校验失败 -> exit。
|
||||
if err = graph.AddBranch(agentnode.QuickNoteGraphNodeIntent, compose.NewGraphBranch(
|
||||
nodes.NextAfterIntent,
|
||||
map[string]bool{
|
||||
@@ -104,22 +88,12 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. 显式 exit 节点仍然保留。
|
||||
// 这样后续若要统一加日志、埋点、收尾逻辑,不需要再改 branch 结构。
|
||||
if err = graph.AddEdge(agentnode.QuickNoteGraphNodeExit, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 10. priority 后固定进入 persist。
|
||||
if err = graph.AddEdge(agentnode.QuickNoteGraphNodeRank, agentnode.QuickNoteGraphNodePersist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 11. persist 后分支:
|
||||
// 11.1 已成功写入 -> END;
|
||||
// 11.2 仍可重试 -> 回到 persist;
|
||||
// 11.3 重试耗尽 -> END,由 state 中的失败文案兜底。
|
||||
if err = graph.AddBranch(agentnode.QuickNoteGraphNodePersist, compose.NewGraphBranch(
|
||||
nodes.NextAfterPersist,
|
||||
map[string]bool{
|
||||
@@ -130,13 +104,12 @@ func RunQuickNoteGraph(ctx context.Context, input agentnode.QuickNoteGraphRunInp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 12. 为 persist 重试预留运行步数余量,避免异常状态把图跑成死循环。
|
||||
// 5. persist 节点允许有限次重试,因此最大步数要覆盖首次执行与重试回路。
|
||||
maxSteps := input.State.MaxToolRetry + 10
|
||||
if maxSteps < 12 {
|
||||
maxSteps = 12
|
||||
}
|
||||
|
||||
// 13. 编译并执行图。
|
||||
runnable, err := graph.Compile(ctx,
|
||||
compose.WithGraphName(QuickNoteGraphName),
|
||||
compose.WithMaxRunSteps(maxSteps),
|
||||
|
||||
@@ -1,22 +1,126 @@
|
||||
package agentgraph
|
||||
|
||||
import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
const (
|
||||
TaskQueryGraphName = "task_query"
|
||||
|
||||
TaskQueryNodePlan = "task_query.plan"
|
||||
TaskQueryNodeTool = "task_query.tool.query"
|
||||
TaskQueryNodeReflect = "task_query.reflect"
|
||||
TaskQueryNodeReply = "task_query.reply"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// TaskQueryGraph 是“随口问任务”图编排骨架。
|
||||
type TaskQueryGraph struct {
|
||||
Nodes *agentnode.TaskQueryNodes
|
||||
}
|
||||
const (
|
||||
// TaskQueryGraphName 是任务查询图编排的稳定标识。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅用于 graph 编译、日志和排障时标识当前链路。
|
||||
// 2. 不承载路由判断,也不负责描述具体业务含义。
|
||||
TaskQueryGraphName = "task_query"
|
||||
)
|
||||
|
||||
// NewTaskQueryGraph 创建任务查询图骨架。
|
||||
func NewTaskQueryGraph(nodes *agentnode.TaskQueryNodes) *TaskQueryGraph {
|
||||
return &TaskQueryGraph{Nodes: nodes}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
package agentllm
|
||||
|
||||
// TaskQueryPlanOutput 是“随口问任务”聚合规划的模型契约草案。
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
)
|
||||
|
||||
// TaskQueryPlanOutput 描述计划节点返回的结构化查询方案。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承接模型输出,不在这里做合法性校验。
|
||||
// 2. 字段为空或非法时,由 node 层继续归一化与兜底。
|
||||
type TaskQueryPlanOutput struct {
|
||||
Intent string `json:"intent"`
|
||||
Quadrants []int `json:"quadrants"`
|
||||
SortBy string `json:"sort_by"`
|
||||
Limit int `json:"limit"`
|
||||
TimeRange string `json:"time_range"`
|
||||
NeedBroadening bool `json:"need_broadening"`
|
||||
Keywords []string `json:"keywords"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// TaskQueryReflectOutput 是查询结果反思节点的模型契约草案。
|
||||
type TaskQueryReflectOutput struct {
|
||||
Satisfied bool `json:"satisfied"`
|
||||
NeedRetry bool `json:"need_retry"`
|
||||
RetrySuggestion string `json:"retry_suggestion"`
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -7,89 +7,56 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// QuickNoteDatetimeMinuteLayout 是“随口记”链路内部统一的分钟级时间格式。
|
||||
// 说明:
|
||||
// 1) 用于把“当前时间基准”传给模型,避免模型在相对时间推断时出现秒级抖动。
|
||||
// 2) 用于日志和调试,读起来比 RFC3339 更直观。
|
||||
// QuickNoteDatetimeMinuteLayout 是随口记链路统一使用的分钟级时间格式。
|
||||
QuickNoteDatetimeMinuteLayout = "2006-01-02 15:04"
|
||||
|
||||
// QuickNoteTimezoneName 是随口记链路默认业务时区。
|
||||
// 这里固定为东八区,避免容器运行在 UTC 时把“明天/今晚”解释偏移到错误日期。
|
||||
// QuickNoteTimezoneName 是随口记时间解析与展示优先使用的时区。
|
||||
QuickNoteTimezoneName = "Asia/Shanghai"
|
||||
|
||||
// QuickNotePriorityImportantUrgent 对应四象限里的“重要且紧急”。
|
||||
QuickNotePriorityImportantUrgent = 1
|
||||
// QuickNotePriorityImportantNotUrgent 对应“重要不紧急”。
|
||||
QuickNotePriorityImportantNotUrgent = 2
|
||||
// QuickNotePrioritySimpleNotImportant 对应“简单不重要”。
|
||||
QuickNotePrioritySimpleNotImportant = 3
|
||||
// QuickNotePriorityComplexNotImportant 对应“不简单不重要”。
|
||||
QuickNotePriorityComplexNotImportant = 4
|
||||
QuickNotePriorityImportantUrgent = TaskPriorityImportantUrgent
|
||||
QuickNotePriorityImportantNotUrgent = TaskPriorityImportantNotUrgent
|
||||
QuickNotePrioritySimpleNotImportant = TaskPrioritySimpleNotImportant
|
||||
QuickNotePriorityComplexNotImportant = TaskPriorityComplexNotImportant
|
||||
)
|
||||
|
||||
// IsValidTaskPriority 判断优先级是否合法。
|
||||
func IsValidTaskPriority(priority int) bool {
|
||||
return priority >= QuickNotePriorityImportantUrgent && priority <= QuickNotePriorityComplexNotImportant
|
||||
}
|
||||
|
||||
// PriorityLabelCN 把优先级数值转换为中文标签,便于拼接给用户的自然语言回复。
|
||||
func PriorityLabelCN(priority int) string {
|
||||
switch priority {
|
||||
case QuickNotePriorityImportantUrgent:
|
||||
return "重要且紧急"
|
||||
case QuickNotePriorityImportantNotUrgent:
|
||||
return "重要不紧急"
|
||||
case QuickNotePrioritySimpleNotImportant:
|
||||
return "简单不重要"
|
||||
case QuickNotePriorityComplexNotImportant:
|
||||
return "不简单不重要"
|
||||
default:
|
||||
return "未知优先级"
|
||||
}
|
||||
}
|
||||
|
||||
// QuickNoteState 是“AI随口记”链路在 graph 节点间传递的统一状态容器。
|
||||
// QuickNoteState 是随口记图在节点间流转的完整状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存意图识别、任务提取、工具重试和最终回复所需状态。
|
||||
// 2. 不负责图编排,也不直接映射数据库任务实体。
|
||||
type QuickNoteState struct {
|
||||
TraceID string
|
||||
UserID int
|
||||
ConversationID string
|
||||
|
||||
// RequestNow 记录“请求进入随口记链路时”的时间基准(分钟级)。
|
||||
RequestNow time.Time
|
||||
// RequestNowText 是 RequestNow 的字符串形式,主要用于 prompt 注入。
|
||||
RequestNow time.Time
|
||||
RequestNowText string
|
||||
|
||||
UserInput string
|
||||
UserInput string
|
||||
|
||||
IsQuickNoteIntent bool
|
||||
IntentJudgeReason string
|
||||
|
||||
ExtractedTitle string
|
||||
ExtractedDeadline *time.Time
|
||||
ExtractedDeadlineText string
|
||||
// ExtractedUrgencyThreshold 表示“进入紧急象限的分界时间”。
|
||||
ExtractedTitle string
|
||||
ExtractedDeadline *time.Time
|
||||
ExtractedDeadlineText string
|
||||
ExtractedUrgencyThreshold *time.Time
|
||||
ExtractedPriority int
|
||||
// ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。
|
||||
ExtractedBanter string
|
||||
// PlannedBySingleCall 标记本次是否走了“单请求聚合规划”快路径。
|
||||
PlannedBySingleCall bool
|
||||
|
||||
ExtractedPriorityReason string
|
||||
// DeadlineValidationError 记录时间校验失败原因。
|
||||
DeadlineValidationError string
|
||||
ExtractedBanter string
|
||||
PlannedBySingleCall bool
|
||||
ExtractedPriorityReason string
|
||||
DeadlineValidationError string
|
||||
|
||||
ToolAttemptCount int
|
||||
MaxToolRetry int
|
||||
LastToolError string
|
||||
|
||||
PersistedTaskID int
|
||||
Persisted bool
|
||||
|
||||
AssistantReply string
|
||||
PersistedTaskID int
|
||||
Persisted bool
|
||||
AssistantReply string
|
||||
}
|
||||
|
||||
// NewQuickNoteState 创建随口记状态对象并初始化默认重试次数。
|
||||
// NewQuickNoteState 负责创建随口记图的初始状态。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. RequestNow 与 RequestNowText 会在创建时同步写入,保证整条链路共用同一时间基准。
|
||||
// 2. MaxToolRetry 默认给 3,避免上层未配置时完全失去重试能力。
|
||||
func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState {
|
||||
requestNow := agentshared.NowToMinute()
|
||||
return &QuickNoteState{
|
||||
@@ -103,18 +70,30 @@ func NewQuickNoteState(traceID string, userID int, conversationID, userInput str
|
||||
}
|
||||
}
|
||||
|
||||
// CanRetryTool 判断当前是否还能继续重试工具调用。
|
||||
// CanRetryTool 返回当前是否还允许再次调用持久化工具。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. true 表示“尚未达到最大重试次数”,调用方仍可继续重试。
|
||||
// 2. false 表示必须收口,避免无限重试。
|
||||
func (s *QuickNoteState) CanRetryTool() bool {
|
||||
return s.ToolAttemptCount < s.MaxToolRetry
|
||||
}
|
||||
|
||||
// RecordToolError 记录一次工具调用失败。
|
||||
// RecordToolError 记录一次工具失败,并推进重试计数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只更新与工具失败相关的状态。
|
||||
// 2. 不决定是否继续重试,是否重试由节点分支逻辑判断。
|
||||
func (s *QuickNoteState) RecordToolError(errMsg string) {
|
||||
s.ToolAttemptCount++
|
||||
s.LastToolError = errMsg
|
||||
}
|
||||
|
||||
// RecordToolSuccess 记录一次工具调用成功。
|
||||
// RecordToolSuccess 记录一次工具成功结果。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. taskID 必须是持久化后的真实任务 ID。
|
||||
// 2. 成功后会清空 LastToolError,表示当前链路已进入稳定态。
|
||||
func (s *QuickNoteState) RecordToolSuccess(taskID int) {
|
||||
s.ToolAttemptCount++
|
||||
s.PersistedTaskID = taskID
|
||||
|
||||
37
backend/agent2/model/task_priority.go
Normal file
37
backend/agent2/model/task_priority.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agentmodel
|
||||
|
||||
const (
|
||||
TaskPriorityImportantUrgent = 1
|
||||
TaskPriorityImportantNotUrgent = 2
|
||||
TaskPrioritySimpleNotImportant = 3
|
||||
TaskPriorityComplexNotImportant = 4
|
||||
)
|
||||
|
||||
// IsValidTaskPriority 用于校验任务优先级是否合法。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责判断 priority 是否落在系统支持的 1~4 范围内。
|
||||
// 2. 不负责把自然语言映射成优先级,也不负责做业务兜底推断。
|
||||
func IsValidTaskPriority(priority int) bool {
|
||||
return priority >= TaskPriorityImportantUrgent && priority <= TaskPriorityComplexNotImportant
|
||||
}
|
||||
|
||||
// PriorityLabelCN 返回任务优先级对应的中文标签。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责“优先级枚举 -> 中文展示文案”的稳定映射。
|
||||
// 2. 不负责国际化、多语言切换或业务规则解释。
|
||||
func PriorityLabelCN(priority int) string {
|
||||
switch priority {
|
||||
case TaskPriorityImportantUrgent:
|
||||
return "重要且紧急"
|
||||
case TaskPriorityImportantNotUrgent:
|
||||
return "重要不紧急"
|
||||
case TaskPrioritySimpleNotImportant:
|
||||
return "简单不重要"
|
||||
case TaskPriorityComplexNotImportant:
|
||||
return "复杂不重要"
|
||||
default:
|
||||
return "未知优先级"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,87 @@
|
||||
package agentmodel
|
||||
|
||||
// TaskQueryState 是“任务查询”skill 的运行时状态骨架。
|
||||
type TaskQueryState struct {
|
||||
UserInput string
|
||||
RequestNowText string
|
||||
NeedRetry bool
|
||||
RetryCount int
|
||||
MaxReflectRetry int
|
||||
FinalReply string
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,33 +11,21 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// QuickNoteGraphNodeIntent 是随口记图里的“意图识别”节点名。
|
||||
// 这里把节点名下沉到 node 层,是为了让:
|
||||
// 1. 节点自己的分支方法可以直接返回目标节点名;
|
||||
// 2. graph 层只负责连线,不需要反向暴露常量给 node 层;
|
||||
// 3. 后续若节点改名,只需要在这里统一收口。
|
||||
// QuickNoteGraphNodeIntent 是随口记图中的“意图识别”节点名。
|
||||
QuickNoteGraphNodeIntent = "quick_note_intent"
|
||||
// QuickNoteGraphNodeRank 是随口记图里的“优先级评估”节点名。
|
||||
// QuickNoteGraphNodeRank 是随口记图中的“优先级评估”节点名。
|
||||
QuickNoteGraphNodeRank = "quick_note_priority"
|
||||
// QuickNoteGraphNodePersist 是随口记图里的“持久化写库”节点名。
|
||||
// QuickNoteGraphNodePersist 是随口记图中的“持久化写库”节点名。
|
||||
QuickNoteGraphNodePersist = "quick_note_persist"
|
||||
// QuickNoteGraphNodeExit 是随口记图里的“提前退出”节点名。
|
||||
// QuickNoteGraphNodeExit 是随口记图中的“提前退出”节点名。
|
||||
QuickNoteGraphNodeExit = "quick_note_exit"
|
||||
)
|
||||
|
||||
// QuickNoteGraphRunInput 描述一次“随口记图运行”所需的请求级依赖。
|
||||
// QuickNoteGraphRunInput 描述一次随口记图运行所需的请求级依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Model:当前请求实际使用的聊天模型;
|
||||
// 2. State:本次图运行共享的状态对象;
|
||||
// 3. Deps:工具层依赖,例如解析 user_id、执行写库;
|
||||
// 4. SkipIntentVerification:若上游路由已高置信命中,可跳过二次意图判断;
|
||||
// 5. EmitStage:向外层推送阶段消息的可选回调。
|
||||
//
|
||||
// 不负责什么:
|
||||
// 1. 不负责真正的 graph 连线;
|
||||
// 2. 不负责工具注册与提取;
|
||||
// 3. 不负责节点内部业务流转。
|
||||
// 1. 负责把模型、初始状态、工具依赖和阶段回调打包给 graph 层。
|
||||
// 2. 不负责做依赖校验,校验逻辑由 graph/node 构造阶段处理。
|
||||
type QuickNoteGraphRunInput struct {
|
||||
Model *ark.ChatModel
|
||||
State *agentmodel.QuickNoteState
|
||||
@@ -46,29 +34,22 @@ type QuickNoteGraphRunInput struct {
|
||||
EmitStage func(stage, detail string)
|
||||
}
|
||||
|
||||
// QuickNoteNodes 是“随口记”节点容器。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 把“请求级依赖”收口到 node 层,而不是继续堆在 graph 层;
|
||||
// 2. 让 graph 层直接挂 `nodes.Intent / nodes.Priority / nodes.Persist` 这些方法;
|
||||
// 3. 这样 graph 文件就只负责画图,不再负责依赖转接。
|
||||
// QuickNoteNodes 是随口记图的节点容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责提供可直接挂载到 graph 的节点方法;
|
||||
// 2. 负责在节点执行时读取本次请求的 input / tool / stage emitter;
|
||||
// 3. 不负责 graph 编译与运行,也不负责 service 层收尾持久化。
|
||||
// 1. 负责承接节点运行时依赖,并向 graph 暴露可直接挂载的方法。
|
||||
// 2. 不负责 graph 编译,也不负责 service 层接口接线。
|
||||
type QuickNoteNodes struct {
|
||||
input QuickNoteGraphRunInput
|
||||
createTaskTool tool.InvokableTool
|
||||
emitStage func(stage, detail string)
|
||||
}
|
||||
|
||||
// NewQuickNoteNodes 创建随口记节点容器。
|
||||
// NewQuickNoteNodes 负责构造随口记节点容器。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里做的是“节点依赖注入”,不是 graph 连线;
|
||||
// 2. emitStage 允许为空,内部会补成 no-op,避免节点里反复判空;
|
||||
// 3. createTaskTool 为 persist 节点的硬依赖,缺失时直接报错,避免跑到写库节点再失败。
|
||||
// 输入输出语义:
|
||||
// 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")
|
||||
@@ -86,18 +67,22 @@ func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.Invokab
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Exit 是图里的显式退出节点。
|
||||
// Exit 是图中的显式退出节点。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把当前 state 原样透传到 END;
|
||||
// 2. 不负责追加业务逻辑;
|
||||
// 3. 保留这个节点,是为了后续若要补统一埋点、日志、收尾逻辑时有稳定挂载点。
|
||||
// 1. 仅作为图收口占位,保持状态原样透传。
|
||||
// 2. 不做额外业务处理,避免退出节点再引入副作用。
|
||||
func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
|
||||
_ = ctx
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// NextAfterIntent 负责根据意图识别结果决定 intent 后的分支走向。
|
||||
// NextAfterIntent 根据意图识别结果决定 intent 节点后的分支走向。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 非随口记意图时直接退出,避免误把普通聊天写成任务。
|
||||
// 2. 截止时间校验失败时同样直接退出,让上层优先把错误提示给用户。
|
||||
// 3. 只有意图成立且时间合法,才进入优先级评估节点。
|
||||
func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) {
|
||||
_ = ctx
|
||||
if st == nil || !st.IsQuickNoteIntent {
|
||||
@@ -107,10 +92,14 @@ func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.Qui
|
||||
return QuickNoteGraphNodeExit, nil
|
||||
}
|
||||
return QuickNoteGraphNodeRank, nil
|
||||
|
||||
}
|
||||
|
||||
// NextAfterPersist 负责根据持久化结果决定 persist 后的分支走向。
|
||||
// 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 {
|
||||
@@ -123,9 +112,6 @@ func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.Qu
|
||||
return QuickNoteGraphNodePersist, nil
|
||||
}
|
||||
if st.AssistantReply == "" {
|
||||
// 1. 重试次数耗尽且上游没有明确失败文案时,在这里补一条兜底回复;
|
||||
// 2. 这样可以保证图结束后 service 层一定能拿到稳定可展示的失败信息;
|
||||
// 3. 不在 graph 层处理,是因为这属于节点业务状态修正。
|
||||
st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。"
|
||||
}
|
||||
return compose.END, nil
|
||||
|
||||
@@ -17,16 +17,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的标准名称。
|
||||
// 该名称会直接暴露给大模型,因此建议保持稳定,避免后续提示词和历史上下文失配。
|
||||
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的稳定名称。
|
||||
ToolNameQuickNoteCreateTask = "quick_note_create_task"
|
||||
// ToolDescQuickNoteCreateTask 是工具的简要职责说明。
|
||||
// ToolDescQuickNoteCreateTask 是给大模型看的工具职责说明。
|
||||
ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级"
|
||||
)
|
||||
|
||||
var (
|
||||
// quickNoteDeadlineLayouts 是“绝对时间”白名单格式。
|
||||
// 只要命中任意一个 layout,就会被归一化为分钟级时间并进入写库流程。
|
||||
quickNoteDeadlineLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04",
|
||||
@@ -46,9 +43,6 @@ var (
|
||||
"2006.01.02": {},
|
||||
}
|
||||
|
||||
// 正则区:
|
||||
// 1) 用于解析明确时间表达;
|
||||
// 2) 用于“是否存在时间线索”的判定(即使格式错误,也会触发校验失败而非静默忽略)。
|
||||
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*[日号]?`)
|
||||
@@ -61,48 +55,38 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// QuickNoteToolDeps 描述“随口记工具包”需要的外部依赖。
|
||||
// 这里采用函数注入的方式,避免 agent 包和 service/dao 强耦合,后续更容易演进为 mock 测试或多实现切换。
|
||||
// QuickNoteToolDeps 描述随口记工具所需的外部依赖。
|
||||
type QuickNoteToolDeps struct {
|
||||
// ResolveUserID 从上下文中解析当前登录用户 ID。
|
||||
ResolveUserID func(ctx context.Context) (int, error)
|
||||
// CreateTask 执行真实写库动作。
|
||||
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
|
||||
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
|
||||
}
|
||||
|
||||
func (d QuickNoteToolDeps) Validate() error {
|
||||
// 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。
|
||||
if d.ResolveUserID == nil {
|
||||
return errors.New("quick note tool deps: ResolveUserID is nil")
|
||||
}
|
||||
// 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。
|
||||
if d.CreateTask == nil {
|
||||
return errors.New("quick note tool deps: CreateTask is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuickNoteToolBundle 是随口记工具集合的打包结果。
|
||||
// - Tools: 给 ToolsNode 使用
|
||||
// - ToolInfos: 给 ChatModel 绑定工具 schema 使用
|
||||
// 两者分开返回,可以适配你后面用 chain、graph、react 的不同挂载姿势。
|
||||
// QuickNoteToolBundle 是随口记工具集合。
|
||||
type QuickNoteToolBundle struct {
|
||||
Tools []tool.BaseTool
|
||||
ToolInfos []*schema.ToolInfo
|
||||
}
|
||||
|
||||
// QuickNoteCreateTaskRequest 是工具层到业务层的内部请求结构。
|
||||
// 与模型输入解耦,避免模型字段变化直接影响业务签名。
|
||||
// QuickNoteCreateTaskRequest 是工具层传给业务层的内部请求。
|
||||
type QuickNoteCreateTaskRequest struct {
|
||||
UserID int
|
||||
Title string
|
||||
PriorityGroup int
|
||||
DeadlineAt *time.Time
|
||||
// UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。
|
||||
UserID int
|
||||
Title string
|
||||
PriorityGroup int
|
||||
DeadlineAt *time.Time
|
||||
UrgencyThresholdAt *time.Time
|
||||
}
|
||||
|
||||
// QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。
|
||||
// QuickNoteCreateTaskResult 是业务层回给工具层的结构化结果。
|
||||
type QuickNoteCreateTaskResult struct {
|
||||
TaskID int
|
||||
Title string
|
||||
@@ -111,21 +95,18 @@ type QuickNoteCreateTaskResult struct {
|
||||
UrgencyThresholdAt *time.Time
|
||||
}
|
||||
|
||||
// QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。
|
||||
// 注意:user_id 不对模型暴露,统一从鉴权上下文提取,避免越权写入。
|
||||
// QuickNoteCreateTaskToolInput 是暴露给模型的工具入参。
|
||||
type QuickNoteCreateTaskToolInput struct {
|
||||
Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"`
|
||||
// PriorityGroup 使用 1~4,和后端 tasks.priority 保持一致。
|
||||
PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4不简单不重要)"`
|
||||
// DeadlineAt 支持绝对时间与常见相对时间(如明天/后天/下周一/今晚),内部会归一化为绝对时间。
|
||||
// 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 表示何时自动进入紧急象限。
|
||||
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间,支持与deadline_at相同格式"`
|
||||
}
|
||||
|
||||
// QuickNoteCreateTaskToolOutput 是返回给大模型的工具结果。
|
||||
// 该结构可直接给模型用于“向用户解释已记录到哪个优先级”。
|
||||
// QuickNoteCreateTaskToolOutput 是返回给模型的结构化结果。
|
||||
type QuickNoteCreateTaskToolOutput struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
@@ -135,26 +116,20 @@ type QuickNoteCreateTaskToolOutput struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BuildQuickNoteToolBundle 构建“AI随口记”工具包。
|
||||
// 这是 agent 目录给上层编排层(chain/graph/react)提供的统一入口。
|
||||
// BuildQuickNoteToolBundle 构建随口记工具包。
|
||||
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
|
||||
// 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。
|
||||
if err := deps.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。
|
||||
// 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。
|
||||
createTaskTool, err := toolutils.InferTool(
|
||||
ToolNameQuickNoteCreateTask,
|
||||
ToolDescQuickNoteCreateTask,
|
||||
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
|
||||
// 2.1 防御式检查:工具调用参数不能为 nil。
|
||||
if input == nil {
|
||||
return nil, errors.New("工具参数不能为空")
|
||||
}
|
||||
|
||||
// 2.2 标题与优先级是写库硬条件,必须先校验。
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("title 不能为空")
|
||||
@@ -163,9 +138,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
|
||||
}
|
||||
|
||||
// 这里对 deadline_at 做“强校验”:
|
||||
// - 空值允许(代表没有截止时间);
|
||||
// - 非空但无法解析直接报错,避免把有问题的时间静默写成 NULL。
|
||||
deadline, err := parseOptionalDeadline(input.DeadlineAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -175,7 +147,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。
|
||||
userID, err := deps.ResolveUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析用户身份失败: %w", err)
|
||||
@@ -184,7 +155,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, fmt.Errorf("非法 user_id=%d", userID)
|
||||
}
|
||||
|
||||
// 2.4 走业务层写库。
|
||||
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
@@ -199,18 +169,15 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, errors.New("写入任务后返回结果异常")
|
||||
}
|
||||
|
||||
// 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。
|
||||
finalTitle := title
|
||||
if strings.TrimSpace(result.Title) != "" {
|
||||
finalTitle = strings.TrimSpace(result.Title)
|
||||
}
|
||||
|
||||
finalPriority := input.PriorityGroup
|
||||
if agentmodel.IsValidTaskPriority(result.PriorityGroup) {
|
||||
finalPriority = result.PriorityGroup
|
||||
}
|
||||
|
||||
// 2.6 截止时间输出统一为 RFC3339,便于跨系统传输与调试。
|
||||
deadlineStr := ""
|
||||
if result.DeadlineAt != nil {
|
||||
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
@@ -218,7 +185,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。
|
||||
return &QuickNoteCreateTaskToolOutput{
|
||||
TaskID: result.TaskID,
|
||||
Title: finalTitle,
|
||||
@@ -233,7 +199,6 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. Tools 给执行节点使用,ToolInfos 给模型注册 schema 使用,二者都要返回。
|
||||
tools := []tool.BaseTool{createTaskTool}
|
||||
infos, err := collectToolInfos(ctx, tools)
|
||||
if err != nil {
|
||||
@@ -246,57 +211,26 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
}, nil
|
||||
}
|
||||
|
||||
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
|
||||
// 按工具列表顺序提取 ToolInfo,确保“tools[idx] <-> infos[idx]”一一对应。
|
||||
infos := make([]*schema.ToolInfo, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
info, err := t.Info(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取工具信息失败: %w", err)
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// GetInvokableToolByName 通过工具名提取可执行工具实例。
|
||||
func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
|
||||
if bundle == nil {
|
||||
return nil, errors.New("tool bundle is nil")
|
||||
}
|
||||
if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
|
||||
return nil, errors.New("tool bundle is empty")
|
||||
}
|
||||
for idx, info := range bundle.ToolInfos {
|
||||
if info == nil || info.Name != name {
|
||||
continue
|
||||
}
|
||||
invokable, ok := bundle.Tools[idx].(tool.InvokableTool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %s is not invokable", name)
|
||||
}
|
||||
return invokable, nil
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name)
|
||||
}
|
||||
|
||||
// parseOptionalDeadline 解析工具输入中的可选截止时间。
|
||||
// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at,就必须能被解析。
|
||||
func parseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
// 1. 先做标点与空白归一化,避免中文输入噪声影响解析。
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
// 2. 空字符串合法,表示任务无截止时间。
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 3. 统一按“严格模式”解析:给了时间就必须成功解析。
|
||||
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deadline == nil {
|
||||
// 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。
|
||||
if !hasHint {
|
||||
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
@@ -306,9 +240,7 @@ func parseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
}
|
||||
|
||||
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
|
||||
// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。
|
||||
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
|
||||
// 场景:模型已给出 deadline_at,需要基于同一 requestNow 再次硬校验。
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
@@ -325,12 +257,7 @@ func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error)
|
||||
}
|
||||
|
||||
// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。
|
||||
// 返回值说明:
|
||||
// - deadline != nil:成功解析出时间;
|
||||
// - hasHint=false 且 err=nil:文本里没有明显时间线索,应视为“用户没给时间”;
|
||||
// - hasHint=true 且 err!=nil:用户给了时间但格式非法,应提示用户修正,不应落库。
|
||||
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
|
||||
// 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, false, nil
|
||||
@@ -339,10 +266,8 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time,
|
||||
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
|
||||
if err != nil {
|
||||
if hasHint {
|
||||
// 有时间线索 + 解析失败:上层应明确提示用户改时间格式。
|
||||
return nil, true, err
|
||||
}
|
||||
// 无明显时间线索:按“未提供时间”处理。
|
||||
return nil, false, nil
|
||||
}
|
||||
if deadline == nil {
|
||||
@@ -354,49 +279,36 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time,
|
||||
return deadline, true, nil
|
||||
}
|
||||
|
||||
// parseOptionalDeadlineFromText 是内部通用解析器。
|
||||
// 解析顺序:
|
||||
// 1) 绝对时间(明确年月日时分);
|
||||
// 2) 相对时间(明天/下周一/今晚);
|
||||
// 3) 若识别到时间线索但仍失败,返回 hasHint=true + error,交给上层决定是否拦截。
|
||||
// parseOptionalDeadlineFromText 是内部通用时间解析器。
|
||||
func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// 1. 统一时区与时间基准,保证相对时间可重复计算。
|
||||
loc := quickNoteLocation()
|
||||
now = now.In(loc)
|
||||
hasHint := hasDeadlineHint(value)
|
||||
|
||||
// 2. 先尝试绝对时间(优先级更高,歧义更小)。
|
||||
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
|
||||
return abs, true, nil
|
||||
}
|
||||
|
||||
// 3. 再尝试相对时间(明天/下周一/今晚)。
|
||||
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return rel, true, nil
|
||||
}
|
||||
|
||||
// 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。
|
||||
if hasHint {
|
||||
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。
|
||||
func normalizeDeadlineInput(raw string) string {
|
||||
// 先 trim,避免纯空格输入影响后续逻辑。
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
// 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。
|
||||
replacer := strings.NewReplacer(
|
||||
":", ":",
|
||||
",", ",",
|
||||
@@ -406,12 +318,7 @@ func normalizeDeadlineInput(raw string) string {
|
||||
return strings.TrimSpace(replacer.Replace(trimmed))
|
||||
}
|
||||
|
||||
// hasDeadlineHint 判断文本里是否存在“时间相关线索”。
|
||||
// 该函数的意义是区分两种情况:
|
||||
// 1) 用户根本没给时间(允许 deadline 为空);
|
||||
// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL)。
|
||||
func hasDeadlineHint(value string) bool {
|
||||
// 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。
|
||||
if quickNoteClockHMRegex.MatchString(value) ||
|
||||
quickNoteClockCNRegex.MatchString(value) ||
|
||||
quickNoteYMDRegex.MatchString(value) ||
|
||||
@@ -420,7 +327,6 @@ func hasDeadlineHint(value string) bool {
|
||||
quickNoteWeekdayRegex.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
// 2. 再用词元判断“明天/今晚”等语义线索。
|
||||
for _, token := range quickNoteRelativeTokens {
|
||||
if strings.Contains(value, token) {
|
||||
return true
|
||||
@@ -429,51 +335,40 @@ func hasDeadlineHint(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。
|
||||
// 若只提供日期(无时分),默认归一到当天 23:59,表示“当日截止”。
|
||||
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
|
||||
// 逐个 layout 尝试,命中即返回。
|
||||
for _, layout := range quickNoteDeadlineLayouts {
|
||||
var (
|
||||
t time.Time
|
||||
err error
|
||||
parsed time.Time
|
||||
err error
|
||||
)
|
||||
if layout == time.RFC3339 {
|
||||
t, err = time.Parse(layout, value)
|
||||
parsed, err = time.Parse(layout, value)
|
||||
if err == nil {
|
||||
t = t.In(loc)
|
||||
parsed = parsed.In(loc)
|
||||
}
|
||||
} else {
|
||||
t, err = time.ParseInLocation(layout, value, loc)
|
||||
parsed, err = time.ParseInLocation(layout, value, loc)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Date-only 输入(例如 2026-03-20)默认补到 23:59。
|
||||
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc)
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
|
||||
} else {
|
||||
// 非 date-only 则统一清零秒级,保持分钟粒度一致。
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
|
||||
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
|
||||
}
|
||||
return &t, true
|
||||
return &parsed, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// tryParseRelativeDeadline 尝试解析“相对时间 + 可选时刻”。
|
||||
// 例子:
|
||||
// - 明天交报告(默认 23:59)
|
||||
// - 下周一上午9点开会(解析为下周一 09:00)
|
||||
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
|
||||
// 1. 先确定“哪一天”。
|
||||
baseDate, recognized := inferBaseDate(value, now, loc)
|
||||
if !recognized {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。
|
||||
hour, minute, hasExplicitClock, err := extractClock(value)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
@@ -486,14 +381,7 @@ func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (
|
||||
return &deadline, true, nil
|
||||
}
|
||||
|
||||
// inferBaseDate 负责先确定“哪一天”。
|
||||
// 解析优先级:
|
||||
// 1) 明确年月日;
|
||||
// 2) 月日(自动推断年份);
|
||||
// 3) 周几表达(本周/下周);
|
||||
// 4) 明天/后天/今晚等相对词。
|
||||
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
|
||||
// 1) yyyy年MM月dd日
|
||||
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
|
||||
year, _ := strconv.Atoi(matched[1])
|
||||
month, _ := strconv.Atoi(matched[2])
|
||||
@@ -503,7 +391,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// 2) MM月dd日(自动推断年份:若今年已过则滚到明年)
|
||||
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
month, _ := strconv.Atoi(matched[1])
|
||||
day, _ := strconv.Atoi(matched[2])
|
||||
@@ -522,7 +409,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
// 3) 本周/下周 + 周几
|
||||
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
prefix := matched[1]
|
||||
target, ok := toWeekday(matched[2])
|
||||
@@ -531,7 +417,6 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 今天/明天/后天/大后天/昨天等相对词
|
||||
today := startOfDay(now)
|
||||
switch {
|
||||
case strings.Contains(value, "大后天"):
|
||||
@@ -549,17 +434,11 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// extractClock 从文本提取时刻(时/分)。
|
||||
// 支持:
|
||||
// - 24h 表达:18:30
|
||||
// - 中文表达:3点、3点半、3点20分
|
||||
func extractClock(value string) (int, int, bool, error) {
|
||||
// hour/minute 最终会用于 time.Date,需要先做范围约束。
|
||||
hour := 0
|
||||
minute := 0
|
||||
hasClock := false
|
||||
|
||||
// 1) 24 小时制:18:30
|
||||
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
m, errM := strconv.Atoi(matched[2])
|
||||
@@ -570,7 +449,6 @@ func extractClock(value string) (int, int, bool, error) {
|
||||
minute = m
|
||||
hasClock = true
|
||||
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
|
||||
// 2) 中文时刻:3点 / 3点半 / 3点20分
|
||||
h, errH := strconv.Atoi(matched[1])
|
||||
if errH != nil {
|
||||
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
|
||||
@@ -592,11 +470,9 @@ func extractClock(value string) (int, int, bool, error) {
|
||||
}
|
||||
|
||||
if !hasClock {
|
||||
// 没有显式时刻并不是错误,交给默认时刻策略处理。
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
// 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。
|
||||
if isPMHint(value) && hour < 12 {
|
||||
hour += 12
|
||||
}
|
||||
@@ -613,9 +489,7 @@ func extractClock(value string) (int, int, bool, error) {
|
||||
return hour, minute, true, nil
|
||||
}
|
||||
|
||||
// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。
|
||||
func defaultClockByHint(value string) (int, int) {
|
||||
// 没有明确时刻时按中文语义设置一个“可解释的默认值”。
|
||||
switch {
|
||||
case strings.Contains(value, "凌晨"):
|
||||
return 1, 0
|
||||
@@ -628,29 +502,24 @@ func defaultClockByHint(value string) (int, int) {
|
||||
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 {
|
||||
// 下午/晚上/傍晚通常应映射到 12:00 之后。
|
||||
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
|
||||
}
|
||||
|
||||
func isNoonHint(value string) bool {
|
||||
// “中午 1 点”这类表达通常是 13:00 而非 01:00。
|
||||
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 {
|
||||
// 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。
|
||||
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||
return false
|
||||
}
|
||||
@@ -659,7 +528,6 @@ func isValidDate(year, month, day int) bool {
|
||||
}
|
||||
|
||||
func toWeekday(chinese string) (time.Weekday, bool) {
|
||||
// 把中文周几映射到 Go 的 Weekday 枚举。
|
||||
switch chinese {
|
||||
case "一":
|
||||
return time.Monday, true
|
||||
@@ -680,16 +548,13 @@ func toWeekday(chinese string) (time.Weekday, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。
|
||||
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
|
||||
// 1. 先定位本周周一。
|
||||
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)
|
||||
|
||||
// 2. 再根据“本周/下周/无前缀”选择最终日期。
|
||||
switch {
|
||||
case strings.HasPrefix(prefix, "下"):
|
||||
return candidateThisWeek.AddDate(0, 0, 7)
|
||||
@@ -703,7 +568,6 @@ func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.
|
||||
}
|
||||
}
|
||||
|
||||
// quickNoteLocation 返回随口记链路使用的业务时区。
|
||||
func quickNoteLocation() *time.Location {
|
||||
loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName)
|
||||
if err != nil {
|
||||
@@ -712,12 +576,10 @@ func quickNoteLocation() *time.Location {
|
||||
return loc
|
||||
}
|
||||
|
||||
// quickNoteNowToMinute 返回当前时间并截断到分钟级。
|
||||
func quickNoteNowToMinute() time.Time {
|
||||
return agentshared.NowToMinute()
|
||||
}
|
||||
|
||||
// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。
|
||||
func formatQuickNoteTimeToMinute(t time.Time) string {
|
||||
return agentshared.FormatMinute(t.In(quickNoteLocation()))
|
||||
}
|
||||
|
||||
@@ -1,25 +1,729 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
|
||||
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// TaskQueryNodeDeps 描述“随口问任务”节点层的公共依赖。
|
||||
type TaskQueryNodeDeps struct {
|
||||
LLM *agentllm.Client
|
||||
StageEmitter agentstream.StageEmitter
|
||||
}
|
||||
const (
|
||||
TaskQueryGraphNodePlan = "task_query.plan"
|
||||
TaskQueryGraphNodeQuadrant = "task_query.quadrant"
|
||||
TaskQueryGraphNodeTimeAnchor = "task_query.time_anchor"
|
||||
TaskQueryGraphNodeQuery = "task_query.query"
|
||||
TaskQueryGraphNodeReflect = "task_query.reflect"
|
||||
)
|
||||
|
||||
// TaskQueryNodes 是“随口问任务”节点逻辑容器。
|
||||
type TaskQueryNodes struct {
|
||||
deps TaskQueryNodeDeps
|
||||
}
|
||||
|
||||
// NewTaskQueryNodes 创建任务查询节点容器。
|
||||
func NewTaskQueryNodes(deps TaskQueryNodeDeps) *TaskQueryNodes {
|
||||
if deps.StageEmitter == nil {
|
||||
deps.StageEmitter = agentstream.NoopStageEmitter()
|
||||
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*(个|条|项)?`),
|
||||
}
|
||||
return &TaskQueryNodes{deps: deps}
|
||||
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
|
||||
}
|
||||
|
||||
81
backend/agent2/node/taskquery_test.go
Normal file
81
backend/agent2/node/taskquery_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
)
|
||||
|
||||
// TestExtractExplicitLimitFromUser_Number 验证阿拉伯数字条数可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_Number(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("给我3个优先级低的任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到显式数量")
|
||||
}
|
||||
if limit != 3 {
|
||||
t.Fatalf("数量识别错误,期望=3 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_ChineseNumber 验证中文数字也可以被正确提取。
|
||||
func TestExtractExplicitLimitFromUser_ChineseNumber(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("前五个简单任务给我看看")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到中文数量")
|
||||
}
|
||||
if limit != 5 {
|
||||
t.Fatalf("数量识别错误,期望=5 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractExplicitLimitFromUser_LaiYiGe 验证“来一个”这类口语表达也能命中数量提取。
|
||||
func TestExtractExplicitLimitFromUser_LaiYiGe(t *testing.T) {
|
||||
limit, ok := extractExplicitLimitFromUser("来一个我的简单任务")
|
||||
if !ok {
|
||||
t.Fatalf("期望识别到‘来一个’的显式数量")
|
||||
}
|
||||
if limit != 1 {
|
||||
t.Fatalf("数量识别错误,期望=1 实际=%d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_RespectsLimit 验证最终回复严格遵守 plan.limit。
|
||||
func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{
|
||||
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
|
||||
{ID: 2, Title: "任务2", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-17 10:00"},
|
||||
{ID: 3, Title: "任务3", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-18 10:00"},
|
||||
}
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 2}, "好的")
|
||||
if !strings.Contains(reply, "整理了 2 条任务") {
|
||||
t.Fatalf("回复未体现 limit=2,reply=%s", reply)
|
||||
}
|
||||
if strings.Contains(reply, "3. ") {
|
||||
t.Fatalf("回复中不应出现第 3 条,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_NoDuplicateList 验证 llmReply 自带列表时不会与后端列表重复拼接。
|
||||
func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
|
||||
items := []agentmodel.TaskQueryItem{{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"}}
|
||||
llmReply := "以下是你的任务:\n#1 任务1"
|
||||
reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 1}, llmReply)
|
||||
if strings.Contains(reply, "以下是你的任务") {
|
||||
t.Fatalf("不应保留 llm 列表头,reply=%s", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "整理了 1 条任务") {
|
||||
t.Fatalf("应保留后端确定性列表头,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRetryPatch_RespectExplicitLimit 验证显式数量存在时,反思补丁不能覆盖 limit。
|
||||
func TestApplyRetryPatch_RespectExplicitLimit(t *testing.T) {
|
||||
plan := agentmodel.TaskQueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
|
||||
limit := 10
|
||||
next := applyRetryPatch(plan, agentllm.TaskQueryRetryPatch{Limit: &limit}, 1)
|
||||
if next.Limit != 1 {
|
||||
t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
|
||||
}
|
||||
}
|
||||
286
backend/agent2/node/taskquery_tool.go
Normal file
286
backend/agent2/node/taskquery_tool.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/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")
|
||||
}
|
||||
40
backend/agent2/node/taskquery_tool_test.go
Normal file
40
backend/agent2/node/taskquery_tool_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package agentnode
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_Default 验证空输入会回填默认查询参数。
|
||||
func TestNormalizeTaskQueryToolInput_Default(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
|
||||
t.Fatalf("默认值异常: %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_InvalidQuadrant 验证 quadrant 越界时会被拦截。
|
||||
func TestNormalizeTaskQueryToolInput_InvalidQuadrant(t *testing.T) {
|
||||
invalid := 6
|
||||
_, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{Quadrant: &invalid})
|
||||
if err == nil {
|
||||
t.Fatalf("期望 quadrant 越界时报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskQueryToolInput_DateRange 验证时间上下界可被正确解析。
|
||||
func TestNormalizeTaskQueryToolInput_DateRange(t *testing.T) {
|
||||
req, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{
|
||||
DeadlineAfter: "2026-03-01 08:00",
|
||||
DeadlineBefore: "2026-03-31",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.DeadlineAfter == nil || req.DeadlineBefore == nil {
|
||||
t.Fatalf("时间上下界不应为空: %+v", req)
|
||||
}
|
||||
if req.DeadlineAfter.After(*req.DeadlineBefore) {
|
||||
t.Fatalf("时间上下界关系异常: after=%v before=%v", req.DeadlineAfter, req.DeadlineBefore)
|
||||
}
|
||||
}
|
||||
74
backend/agent2/node/tool_common.go
Normal file
74
backend/agent2/node/tool_common.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
@@ -5,19 +5,75 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const taskQuerySystemPrompt = `
|
||||
你是 SmartFlow 的“任务查询规划助手”。
|
||||
你不直接回答最终结果,而是先判断用户想查哪一类任务、需要怎样排序、以及是否需要时间范围约束。
|
||||
const TaskQueryPlanPrompt = `你是 SmartFlow 的任务查询规划器。请根据用户原话,输出结构化查询计划 JSON,供后端直接执行。
|
||||
只允许输出 JSON,不要输出解释、代码块或多余文字。
|
||||
|
||||
当前文件先保留 prompt 归档位置与基本职责说明。
|
||||
`
|
||||
|
||||
// BuildTaskQuerySystemPrompt 返回任务查询系统提示词骨架。
|
||||
func BuildTaskQuerySystemPrompt() string {
|
||||
return strings.TrimSpace(taskQuerySystemPrompt)
|
||||
输出字段:
|
||||
{
|
||||
"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 或空字符串"
|
||||
}
|
||||
|
||||
// BuildTaskQueryUserPrompt 构造任务查询用户提示词骨架。
|
||||
func BuildTaskQueryUserPrompt(nowText, userInput string) string {
|
||||
return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput))
|
||||
规则:
|
||||
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 = `你是 SmartFlow 的任务查询结果审阅器。你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。
|
||||
请只输出 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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,19 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) {
|
||||
pageSize = parsedPageSize
|
||||
}
|
||||
|
||||
// 2.3 limit 是 page_size 的懒加载别名:
|
||||
// 2.3.1 前端若显式传 limit,则以 limit 为准,避免前端再做字段转换;
|
||||
// 2.3.2 若 limit 非法同样直接返回 400,避免把脏参数下沉到 service;
|
||||
// 2.3.3 若未传 limit,则继续沿用历史 page_size 行为,保持老前端兼容。
|
||||
if rawLimit := strings.TrimSpace(c.Query("limit")); rawLimit != "" {
|
||||
parsedLimit, err := strconv.Atoi(rawLimit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
pageSize = parsedLimit
|
||||
}
|
||||
|
||||
// 3. status 过滤器可选,最终合法性由 service 层统一校验。
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
|
||||
@@ -181,6 +194,42 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
||||
}
|
||||
|
||||
// GetConversationHistory 返回指定会话的聊天历史记录。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1) 该接口只读历史,不负责改写 Redis/DB 中的会话状态;
|
||||
// 2) 读取顺序复用现有服务层能力:先校验归属,再查 Redis,未命中再回源 DB;
|
||||
// 3) 会话不存在时统一返回 400,避免前端把无效会话误判成系统故障。
|
||||
func (api *AgentHandler) GetConversationHistory(c *gin.Context) {
|
||||
// 1. 参数校验:conversation_id 必填。
|
||||
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
||||
if conversationID == "" {
|
||||
c.JSON(http.StatusBadRequest, respond.MissingParam)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 从鉴权上下文取当前用户 ID,确保查询范围只落在“本人会话”内。
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
// 3. 设置短超时,避免缓存抖动或慢查询长期占用连接。
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 4. 调 service 查询聊天历史。
|
||||
history, err := api.svc.GetConversationHistory(ctx, userID, conversationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 返回统一响应结构。
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, history))
|
||||
}
|
||||
|
||||
// GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。
|
||||
//
|
||||
// 设计说明:
|
||||
|
||||
@@ -78,10 +78,25 @@ type GetConversationListResponse struct {
|
||||
List []GetConversationListItem `json:"list"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Limit int `json:"limit"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// GetConversationHistoryItem 是“按会话读取聊天历史”接口的单条消息响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. role/content:承载前端渲染消息气泡所需的核心字段;
|
||||
// 2. id/created_at:仅在回源 DB 时可稳定提供,命中 Redis 时允许为空;
|
||||
// 3. reasoning_content:兼容模型推理内容,缓存命中时可直接透传。
|
||||
type GetConversationHistoryItem struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulePlanPreviewCache 是“排程预览”在 Redis 中的缓存结构。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -93,6 +93,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
|
||||
agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent)
|
||||
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
|
||||
agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList)
|
||||
agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory)
|
||||
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
|
||||
}
|
||||
}
|
||||
|
||||
130
backend/service/agentsvc/agent_history.go
Normal file
130
backend/service/agentsvc/agent_history.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetConversationHistory 返回指定会话的聊天历史。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责会话 ID 归一化、会话归属校验,以及“先 Redis、后 DB”的读取编排;
|
||||
// 2. 负责把缓存消息 / DB 记录统一转换为 API 响应 DTO;
|
||||
// 3. 不负责补写会话标题,也不负责修改聊天主链路的缓存写入策略。
|
||||
func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, chatID string) ([]model.GetConversationHistoryItem, error) {
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if normalizedChatID == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
// 1. 先做归属校验:
|
||||
// 1.1 Redis 历史缓存只按 chat_id 分桶,不能单靠缓存判断用户归属;
|
||||
// 1.2 因此先查会话是否属于当前用户,避免命中别人会话缓存时产生越权读取;
|
||||
// 1.3 若会话不存在,统一返回 gorm.ErrRecordNotFound,交由 API 层映射为参数错误。
|
||||
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// 2. 优先读 Redis:
|
||||
// 2.1 命中时直接返回,复用当前聊天主链路维护的最近消息窗口;
|
||||
// 2.2 失败策略:缓存读取异常只记日志并继续回源 DB,避免缓存抖动导致接口不可用;
|
||||
// 2.3 注意:缓存消息不包含稳定的 DB 主键与创建时间,因此这些字段允许为空。
|
||||
if s.agentCache != nil {
|
||||
history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID)
|
||||
if cacheErr != nil {
|
||||
log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
|
||||
} else if history != nil {
|
||||
return buildConversationHistoryItemsFromCache(history), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Redis 未命中时回源 DB:
|
||||
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证查询链路和主聊天链路口径一致;
|
||||
// 3.2 失败时直接上抛,由 API 层统一处理;
|
||||
// 3.3 成功后若缓存可用,则顺手回填 Redis,降低后续冷启动成本。
|
||||
histories, err := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.agentCache != nil {
|
||||
if setErr := s.agentCache.BackfillHistory(ctx, normalizedChatID, conv.ToEinoMessages(histories)); setErr != nil {
|
||||
log.Printf("回填会话历史缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
|
||||
}
|
||||
}
|
||||
|
||||
return buildConversationHistoryItemsFromDB(histories), nil
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromCache 把 Redis 中的 Eino 消息转换为接口响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字段映射,不做权限校验或排序调整;
|
||||
// 2. 不补 created_at/id,因为当前缓存模型不承载这两个字段;
|
||||
// 3. role 统一输出为 user / assistant / system,避免前端再感知 schema.RoleType。
|
||||
func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.GetConversationHistoryItem {
|
||||
items := make([]model.GetConversationHistoryItem, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
Role: normalizeConversationHistoryRole(string(msg.Role)),
|
||||
Content: strings.TrimSpace(msg.Content),
|
||||
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只透传 DB 已有字段,不尝试补算 reasoning_content;
|
||||
// 2. message_content / role 为空时兜底为空串与 system,避免空指针影响接口;
|
||||
// 3. 保持 DAO 返回的时间正序,前端可直接渲染。
|
||||
func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.GetConversationHistoryItem {
|
||||
items := make([]model.GetConversationHistoryItem, 0, len(histories))
|
||||
for _, history := range histories {
|
||||
content := ""
|
||||
if history.MessageContent != nil {
|
||||
content = strings.TrimSpace(*history.MessageContent)
|
||||
}
|
||||
|
||||
role := "system"
|
||||
if history.Role != nil {
|
||||
role = normalizeConversationHistoryRole(*history.Role)
|
||||
}
|
||||
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
ID: history.ID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
CreatedAt: history.CreatedAt,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeConversationHistoryRole(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "user":
|
||||
return "user"
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pa
|
||||
List: items,
|
||||
Page: normalizedPage,
|
||||
PageSize: normalizedPageSize,
|
||||
Limit: normalizedPageSize,
|
||||
Total: total,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
|
||||
@@ -7,18 +7,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/taskquery"
|
||||
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
)
|
||||
|
||||
// runTaskQueryFlow 执行“任务查询”分支。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把本次请求接入 taskquery 执行器;
|
||||
// 2. 负责把 user_id 注入工具依赖,确保模型无法越权查他人任务;
|
||||
// 3. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
|
||||
func (s *AgentService) runTaskQueryFlow(
|
||||
ctx context.Context,
|
||||
selectedModel *ark.ChatModel,
|
||||
@@ -26,7 +22,6 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
userID int,
|
||||
emitStage func(stage, detail string),
|
||||
) (string, error) {
|
||||
// 1. 依赖预检:任务查询必须依赖 taskRepo + model。
|
||||
if s == nil || s.taskRepo == nil {
|
||||
return "", errors.New("task query service dependency is not ready")
|
||||
}
|
||||
@@ -34,19 +29,14 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
return "", errors.New("task query model is nil")
|
||||
}
|
||||
|
||||
// 2. 构建执行输入并启动 tool-calling。
|
||||
// 2.1 RequestNow 仅用于 prompt 辅助,不参与数据库过滤。
|
||||
requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04")
|
||||
return taskquery.RunTaskQueryGraph(ctx, taskquery.QueryGraphRunInput{
|
||||
Model: selectedModel,
|
||||
UserMessage: userMessage,
|
||||
RequestNowText: requestNow,
|
||||
MaxReflectRetry: 2,
|
||||
EmitStage: emitStage,
|
||||
Deps: taskquery.TaskQueryToolDeps{
|
||||
QueryTasks: func(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
// 2.2 调用目的:在工具层做完参数校验后,这里把 user_id 强制注入,再执行真实查询。
|
||||
// 这样可以保证模型永远只能查当前登录用户的数据。
|
||||
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.queryTasksForAgent(ctx, req)
|
||||
},
|
||||
@@ -54,16 +44,8 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
})
|
||||
}
|
||||
|
||||
// queryTasksForAgent 在 Agent 任务查询场景下读取并筛选任务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责“读取原始任务 + 读时优先级派生 + 条件筛选 + 排序 + 截断”;
|
||||
// 2. 不负责写库,不触发 outbox(只读查询链路);
|
||||
// 3. 返回的是工具层结构,不直接暴露 DAO 模型给上层。
|
||||
func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
func (s *AgentService) queryTasksForAgent(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
|
||||
_ = ctx
|
||||
|
||||
// 1. 基础参数校验。
|
||||
if req.UserID <= 0 {
|
||||
return nil, errors.New("invalid user_id in task query")
|
||||
}
|
||||
@@ -71,20 +53,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
return nil, errors.New("task repository is nil")
|
||||
}
|
||||
|
||||
// 2. 读取用户全部任务。
|
||||
// 2.1 当前 TaskDAO 读取接口无 context 参数,这里保持最小侵入复用既有能力;
|
||||
// 2.2 若用户任务为空,返回空切片而不是 error,方便模型自然回复“暂无任务”。
|
||||
tasks, err := s.taskRepo.GetTasksByUserID(req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return make([]taskquery.TaskRecord, 0), nil
|
||||
return make([]agentnode.TaskQueryTaskRecord, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 读时派生 + 条件筛选:
|
||||
// 3.1 先按“紧急分界线”做内存派生,保证查询视图与主业务口径一致;
|
||||
// 3.2 再应用 include_completed/quadrant/keyword/deadline 条件。
|
||||
now := time.Now()
|
||||
filtered := make([]model.Task, 0, len(tasks))
|
||||
for _, originalTask := range tasks {
|
||||
@@ -96,18 +72,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
filtered = append(filtered, currentTask)
|
||||
}
|
||||
|
||||
// 4. 排序与截断:
|
||||
// 4.1 排序字段/方向已经在工具层校验过,这里按约定执行;
|
||||
// 4.2 limit 截断只发生在排序之后,保证“前 N 条”语义正确。
|
||||
sortTasksForQuery(filtered, req)
|
||||
if req.Limit > 0 && len(filtered) > req.Limit {
|
||||
filtered = filtered[:req.Limit]
|
||||
}
|
||||
|
||||
// 5. 映射成工具输出结构。
|
||||
records := make([]taskquery.TaskRecord, 0, len(filtered))
|
||||
records := make([]agentnode.TaskQueryTaskRecord, 0, len(filtered))
|
||||
for _, task := range filtered {
|
||||
records = append(records, taskquery.TaskRecord{
|
||||
records = append(records, agentnode.TaskQueryTaskRecord{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
@@ -119,24 +91,13 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// applyReadTimeUrgencyPromotion 复用“读时紧急性派生”口径(内存态)。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 已完成任务不派生;
|
||||
// 2. 未到紧急分界线不派生;
|
||||
// 3. 到线后仅做 2->1、4->3 的象限平移;
|
||||
// 4. 只改内存对象,不改数据库。
|
||||
func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
if task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
if task == nil || task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
return
|
||||
}
|
||||
if task.UrgencyThresholdAt.After(now) {
|
||||
return
|
||||
}
|
||||
|
||||
switch task.Priority {
|
||||
case 2:
|
||||
task.Priority = 1
|
||||
@@ -145,29 +106,17 @@ func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// taskMatchesQueryFilter 判断任务是否满足查询条件。
|
||||
func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) bool {
|
||||
// 1. include_completed=false 时默认过滤掉已完成任务。
|
||||
func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) bool {
|
||||
if !req.IncludeCompleted && task.IsCompleted {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. quadrant 过滤:只保留指定象限。
|
||||
if req.Quadrant != nil && task.Priority != *req.Quadrant {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. keyword 过滤:对标题做大小写不敏感包含匹配。
|
||||
keyword := strings.TrimSpace(req.Keyword)
|
||||
if keyword != "" {
|
||||
if !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. deadline 区间过滤:
|
||||
// 4.1 只要设置了上下界,deadline_at 为空的任务默认不匹配;
|
||||
// 4.2 区间边界为闭区间(>= after 且 <= before)。
|
||||
if req.DeadlineAfter != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) {
|
||||
return false
|
||||
@@ -181,17 +130,10 @@ func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// sortTasksForQuery 按查询条件排序任务。
|
||||
//
|
||||
// 排序策略:
|
||||
// 1. sort_by=deadline:按截止时间排,deadline 为空的任务统一放末尾;
|
||||
// 2. sort_by=priority:按象限数值排(1 最紧急),同优先级再按 id 倒序;
|
||||
// 3. sort_by=id:按 id 排(可近似“新旧顺序”)。
|
||||
func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
func sortTasksForQuery(tasks []model.Task, req agentnode.TaskQueryRequest) {
|
||||
if len(tasks) <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(req.Order))
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
@@ -204,7 +146,6 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
left := tasks[i]
|
||||
right := tasks[j]
|
||||
|
||||
switch sortBy {
|
||||
case "priority":
|
||||
if left.Priority != right.Priority {
|
||||
@@ -213,42 +154,31 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
}
|
||||
return left.Priority < right.Priority
|
||||
}
|
||||
// 同优先级时按 id 倒序,保证排序稳定且更接近“最近创建在前”。
|
||||
return left.ID > right.ID
|
||||
case "id":
|
||||
if order == "desc" {
|
||||
return left.ID > right.ID
|
||||
}
|
||||
return left.ID < right.ID
|
||||
default: // deadline
|
||||
default:
|
||||
if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided {
|
||||
return less
|
||||
}
|
||||
// 截止时间相同或都为空时,回退 id 倒序保证稳定性。
|
||||
return left.ID > right.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// compareDeadline 比较两个可选截止时间。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. less:left 是否应排在 right 前;
|
||||
// 2. decided:本次比较是否已能得出顺序;false 表示需要上层继续用次级键比较。
|
||||
func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) {
|
||||
// 1. 都为空:本次不决策,交给次级键。
|
||||
if left == nil && right == nil {
|
||||
return false, false
|
||||
}
|
||||
// 2. 只有一边为空:为空的一侧统一放末尾。
|
||||
if left == nil && right != nil {
|
||||
return false, true
|
||||
}
|
||||
if left != nil && right == nil {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// 3. 两边都不为空:按 order 做时间比较。
|
||||
if left.Equal(*right) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user