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
This commit is contained in:
Losita
2026-03-16 22:30:45 +08:00
parent 84371e2ff8
commit 09dca9f772
16 changed files with 2371 additions and 105 deletions

View File

@@ -0,0 +1,344 @@
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")
}