Version: 0.7.6.dev.260325

后端:
- ♻️ 将 `taskquery` 模块迁移至 `agent2`,并完成与 `agent2` 业务链路及整体结构的正式接入

前端:
- 🧱 已完成基础框架搭建,并完成了登录、注册、主页等页面并对接了对应接口;但整体功能实现仍在完善中
This commit is contained in:
Losita
2026-03-25 00:49:16 +08:00
parent f4ef6fb256
commit e06284d0b0
52 changed files with 8847 additions and 468 deletions

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View 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 "未知优先级"
}
}

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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()))
}

View File

@@ -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
}

View 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=2reply=%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)
}
}

View 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")
}

View 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)
}
}

View 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
}

View File

@@ -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),
)
}

View File

@@ -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 返回“指定会话”的排程结构化预览。
//
// 设计说明:

View File

@@ -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 中的缓存结构。
//
// 职责边界:

View File

@@ -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)
}
}

View 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"
}
}

View File

@@ -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

View File

@@ -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. lessleft 是否应排在 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
}