后端: - ♻️ 将 `taskquery` 模块迁移至 `agent2`,并完成与 `agent2` 业务链路及整体结构的正式接入 前端: - 🧱 已完成基础框架搭建,并完成了登录、注册、主页等页面并对接了对应接口;但整体功能实现仍在完善中
287 lines
8.9 KiB
Go
287 lines
8.9 KiB
Go
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")
|
||
}
|