Files
smartmate/backend/agent/taskquery/tool.go
Losita 09dca9f772 Version: 0.6.5.dev.260316
 feat(agent): 通用分流接入随口问图编排,修复任务查询条数与重复输出问题

- ♻️ 将 Agent 路由升级为通用 `action` 分流机制,统一支持 `chat` / `quick_note_create` / `task_query`
- 🧩 新增 `taskquery` 子模块并落地图编排链路:`plan -> quadrant -> time_anchor -> tool_query -> reflect`
- 🔧 在图内接入 `query_tasks` 工具调用,支持自动放宽检索条件与反思重试,最多重试 2 次
- 🚪 保持 `/agent/chat` 作为多合一入口,不额外新增任务查询 HTTP 接口
- 🪄 修复“随口问”场景下的双重列表输出问题:LLM 仅保留简短前缀,任务列表统一由后端进行确定性渲染
- 🎯 修复显式数量约束失效问题:支持提取“来一个”“前 3 个”“top5”等数量表达,并将其锁定为 `limit`
- 🛡️ 防止在重试或放宽检索阶段改写用户显式指定的数量约束
-  补充并更新测试,覆盖路由解析、数量提取、`limit` 生效及重复输出等关键场景

📝 docs: 更新随口问链路文档与决策记录

- 📚 更新 README 5.4,新增/修订随口问链路 Mermaid 图
- 🧭 新增随口问功能决策记录 FDR
2026-03-16 22:30:45 +08:00

345 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package taskquery
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameTaskQueryTasks 是“任务查询工具”对模型暴露的标准名称。
ToolNameTaskQueryTasks = "query_tasks"
// ToolDescTaskQueryTasks 是工具职责说明,给模型理解参数语义。
ToolDescTaskQueryTasks = "按象限/关键字/截止时间筛选并排序任务,返回结构化任务列表"
)
var (
// taskQueryTimeLayouts 是任务查询工具允许的时间输入格式白名单。
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) ([]TaskRecord, error)
}
func (d TaskQueryToolDeps) validate() error {
// 1. 工具没有 QueryTasks 依赖就无法提供任何真实结果,启动时直接失败。
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 是工具层到业务层的内部查询请求。
//
// 职责边界:
// 1. 只承载“查询条件”,不承载数据库/缓存实现细节;
// 2. UserID 不由模型提供,必须由服务层上下文注入。
type TaskQueryRequest struct {
UserID int
Quadrant *int
SortBy string
Order string
Limit int
IncludeCompleted bool
Keyword string
DeadlineBefore *time.Time
DeadlineAfter *time.Time
}
// TaskRecord 是业务层返回给工具层的任务记录。
type TaskRecord struct {
ID int
Title string
PriorityGroup int
IsCompleted bool
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// TaskQueryToolInput 是对模型暴露的工具输入结构。
//
// 参数语义:
// 1. quadrant 可选1~4
// 2. sort_by 可选deadline/priority/id
// 3. order 可选asc/desc
// 4. limit 可选:默认 5上限 20
// 5. include_completed 可选:默认 false。
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 []TaskQueryToolRecord `json:"items"`
}
// TaskQueryToolRecord 是单条任务输出结构。
type TaskQueryToolRecord 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"`
}
// BuildTaskQueryToolBundle 构建任务查询工具包。
//
// 步骤化说明:
// 1. 先校验依赖,确保工具具备真实查询能力;
// 2. 通过 InferTool 声明工具 schema并在闭包内做全部参数校验
// 3. 输出 Tools + ToolInfos供模型与执行器分别使用。
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) {
// 1. 允许 input 为空,统一按默认参数执行一次查询。
normalized, normalizeErr := normalizeToolInput(input)
if normalizeErr != nil {
return nil, normalizeErr
}
// 2. 执行真实查询。
records, queryErr := deps.QueryTasks(ctx, normalized)
if queryErr != nil {
return nil, queryErr
}
// 3. 把业务记录映射成模型友好的结构化输出。
items := make([]TaskQueryToolRecord, 0, len(records))
for _, record := range records {
items = append(items, TaskQueryToolRecord{
ID: record.ID,
Title: record.Title,
PriorityGroup: record.PriorityGroup,
PriorityLabel: priorityLabelCN(record.PriorityGroup),
IsCompleted: record.IsCompleted,
DeadlineAt: formatOptionalTime(record.DeadlineAt),
UrgencyThresholdAt: formatOptionalTime(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
}
// normalizeToolInput 负责参数清洗、默认值填充与合法性校验。
//
// 失败策略:
// 1. 参数非法直接返回 error阻止错误查询落到数据层
// 2. 参数缺失走默认值,优先保证“可用”。
func normalizeToolInput(input *TaskQueryToolInput) (TaskQueryRequest, error) {
// 1. 先准备默认值,保证“空参数”也能查到结果。
req := TaskQueryRequest{
SortBy: "deadline",
Order: "asc",
Limit: 5,
IncludeCompleted: false,
}
if input == nil {
return req, nil
}
// 2. 象限校验:若提供则必须在 1~4。
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
}
// 3. 排序字段校验。
if strings.TrimSpace(input.SortBy) != "" {
req.SortBy = strings.ToLower(strings.TrimSpace(input.SortBy))
}
switch req.SortBy {
case "deadline", "priority", "id":
// 允许字段。
default:
return TaskQueryRequest{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", req.SortBy)
}
// 4. 排序方向校验。
if strings.TrimSpace(input.Order) != "" {
req.Order = strings.ToLower(strings.TrimSpace(input.Order))
}
switch req.Order {
case "asc", "desc":
// 允许方向。
default:
return TaskQueryRequest{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", req.Order)
}
// 5. limit 校验与上限保护。
if input.Limit > 0 {
req.Limit = input.Limit
}
if req.Limit > 20 {
req.Limit = 20
}
if req.Limit <= 0 {
req.Limit = 5
}
// 6. include_completed 默认 false明确传入时才覆盖。
if input.IncludeCompleted != nil {
req.IncludeCompleted = *input.IncludeCompleted
}
// 7. keyword 清洗:去首尾空格,空串视为未设置。
req.Keyword = strings.TrimSpace(input.Keyword)
// 8. 截止时间上下界解析。
before, err := parseOptionalBoundaryTime(input.DeadlineBefore, true)
if err != nil {
return TaskQueryRequest{}, err
}
after, err := parseOptionalBoundaryTime(input.DeadlineAfter, false)
if err != nil {
return TaskQueryRequest{}, err
}
req.DeadlineBefore = before
req.DeadlineAfter = after
// 9. 上下界合法性检查after 不能晚于 before。
if req.DeadlineBefore != nil && req.DeadlineAfter != nil && req.DeadlineAfter.After(*req.DeadlineBefore) {
return TaskQueryRequest{}, errors.New("deadline_after 不能晚于 deadline_before")
}
return req, nil
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
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
}
// parseOptionalBoundaryTime 解析时间上下界。
//
// 参数语义:
// 1. isUpper=true按“上界”解析若输入仅日期则补到 23:59
// 2. isUpper=false按“下界”解析若输入仅日期则补到 00:00。
func parseOptionalBoundaryTime(raw string, isUpper bool) (*time.Time, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, nil
}
loc := time.Local
for _, layout := range taskQueryTimeLayouts {
var (
t time.Time
err error
)
if layout == time.RFC3339 {
t, err = time.Parse(layout, text)
if err == nil {
t = t.In(loc)
}
} else {
t, err = time.ParseInLocation(layout, text, loc)
}
if err != nil {
continue
}
// 仅日期输入时,按上下界补齐时分。
if layout == "2006-01-02" {
if isUpper {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, loc)
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
}
return &t, nil
}
return nil, fmt.Errorf("时间格式不支持: %s", text)
}
func priorityLabelCN(priority int) string {
switch priority {
case 1:
return "重要且紧急"
case 2:
return "重要不紧急"
case 3:
return "简单不重要"
case 4:
return "不简单不重要"
default:
return "未知优先级"
}
}
func formatOptionalTime(t *time.Time) string {
if t == nil {
return ""
}
return t.In(time.Local).Format("2006-01-02 15:04")
}