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:
183
backend/agent/taskquery/graph.go
Normal file
183
backend/agent/taskquery/graph.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package taskquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
const (
|
||||
// 图节点:意图规划(一次模型调用,产出结构化查询计划)
|
||||
taskQueryGraphNodePlan = "task_query_plan"
|
||||
// 图节点:象限归一化(不调模型,只做参数规整)
|
||||
taskQueryGraphNodeQuadrant = "task_query_quadrant"
|
||||
// 图节点:时间锚定(不调模型,锁定绝对时间边界)
|
||||
taskQueryGraphNodeTime = "task_query_time_anchor"
|
||||
// 图节点:工具查询(调用 query_tasks 工具)
|
||||
taskQueryGraphNodeQuery = "task_query_tool_query"
|
||||
// 图节点:结果反思与回复(模型判断是否满足并产出回复/重试补丁)
|
||||
taskQueryGraphNodeReflect = "task_query_reflect"
|
||||
)
|
||||
|
||||
// QueryGraphRunInput 是任务查询图运行输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Model/Deps 提供图运行依赖;
|
||||
// 2. UserMessage/RequestNowText 提供本次请求上下文;
|
||||
// 3. MaxReflectRetry 控制“反思重试”上限;
|
||||
// 4. EmitStage 是可选阶段推送钩子,不影响主链路成功与否。
|
||||
type QueryGraphRunInput struct {
|
||||
Model *ark.ChatModel
|
||||
UserMessage string
|
||||
RequestNowText string
|
||||
Deps TaskQueryToolDeps
|
||||
MaxReflectRetry int
|
||||
EmitStage func(stage, detail string)
|
||||
}
|
||||
|
||||
// RunTaskQueryGraph 执行“任务查询图编排”。
|
||||
//
|
||||
// 关键策略:
|
||||
// 1. 规划节点只调用一次模型,统一产出查询计划;
|
||||
// 2. 查询节点优先按计划查,若为空先自动放宽一次(无额外模型调用);
|
||||
// 3. 反思节点最多重试 2 次,每次决定“是否满足、是否继续、如何补丁”。
|
||||
func RunTaskQueryGraph(ctx context.Context, input QueryGraphRunInput) (string, error) {
|
||||
// 1. 启动前硬校验。
|
||||
if input.Model == nil {
|
||||
return "", errors.New("task query graph: model is nil")
|
||||
}
|
||||
if err := input.Deps.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. 构建工具包,并拿到 query_tasks 可执行工具。
|
||||
toolBundle, err := BuildTaskQueryToolBundle(ctx, input.Deps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
toolMap, err := buildInvokableToolMap(toolBundle)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
queryTool, exists := toolMap[ToolNameTaskQueryTasks]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("task query graph: tool %s not found", ToolNameTaskQueryTasks)
|
||||
}
|
||||
|
||||
// 3. 初始化状态:请求时间为空时做本地兜底。
|
||||
requestNow := strings.TrimSpace(input.RequestNowText)
|
||||
if requestNow == "" {
|
||||
requestNow = time.Now().In(time.Local).Format("2006-01-02 15:04")
|
||||
}
|
||||
state := NewTaskQueryState(strings.TrimSpace(input.UserMessage), requestNow, input.MaxReflectRetry)
|
||||
|
||||
// 4. 封装 runner,把“依赖注入”和“节点逻辑”解耦。
|
||||
runner := newTaskQueryGraphRunner(input, queryTool)
|
||||
|
||||
// 5. 只在本次请求内构图并执行,避免跨请求共享状态。
|
||||
graph := compose.NewGraph[*TaskQueryState, *TaskQueryState]()
|
||||
|
||||
if err = graph.AddLambdaNode(taskQueryGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddLambdaNode(taskQueryGraphNodeQuadrant, compose.InvokableLambda(runner.quadrantNode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddLambdaNode(taskQueryGraphNodeTime, compose.InvokableLambda(runner.timeAnchorNode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddLambdaNode(taskQueryGraphNodeQuery, compose.InvokableLambda(runner.queryNode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddLambdaNode(taskQueryGraphNodeReflect, compose.InvokableLambda(runner.reflectNode)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 连线:START -> plan -> quadrant -> time -> query -> reflect
|
||||
if err = graph.AddEdge(compose.START, taskQueryGraphNodePlan); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddEdge(taskQueryGraphNodePlan, taskQueryGraphNodeQuadrant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddEdge(taskQueryGraphNodeQuadrant, taskQueryGraphNodeTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddEdge(taskQueryGraphNodeTime, taskQueryGraphNodeQuery); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = graph.AddEdge(taskQueryGraphNodeQuery, taskQueryGraphNodeReflect); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 分支:reflect 后要么结束,要么回到 query 重试。
|
||||
if err = graph.AddBranch(taskQueryGraphNodeReflect, compose.NewGraphBranch(
|
||||
runner.nextAfterReflect,
|
||||
map[string]bool{
|
||||
taskQueryGraphNodeQuery: true,
|
||||
compose.END: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
maxRunSteps := 24 + state.MaxReflectRetry*4
|
||||
if maxRunSteps < 24 {
|
||||
maxRunSteps = 24
|
||||
}
|
||||
runnable, err := graph.Compile(ctx,
|
||||
compose.WithGraphName("TaskQueryGraph"),
|
||||
compose.WithMaxRunSteps(maxRunSteps),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
finalState, err := runnable.Invoke(ctx, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if finalState == nil {
|
||||
return "", errors.New("task query graph: final state is nil")
|
||||
}
|
||||
|
||||
reply := strings.TrimSpace(finalState.FinalReply)
|
||||
if reply == "" {
|
||||
reply = buildTaskQueryFallbackReply(finalState.LastQueryItems)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
type taskQueryGraphRunner struct {
|
||||
input QueryGraphRunInput
|
||||
queryTool tool.InvokableTool
|
||||
}
|
||||
|
||||
func newTaskQueryGraphRunner(input QueryGraphRunInput, queryTool tool.InvokableTool) *taskQueryGraphRunner {
|
||||
return &taskQueryGraphRunner{
|
||||
input: input,
|
||||
queryTool: queryTool,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) emit(stage, detail string) {
|
||||
if r.input.EmitStage == nil {
|
||||
return
|
||||
}
|
||||
r.input.EmitStage(stage, detail)
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) nextAfterReflect(ctx context.Context, st *TaskQueryState) (string, error) {
|
||||
_ = ctx
|
||||
if st != nil && st.NeedRetry {
|
||||
return taskQueryGraphNodeQuery, nil
|
||||
}
|
||||
return compose.END, nil
|
||||
}
|
||||
839
backend/agent/taskquery/nodes.go
Normal file
839
backend/agent/taskquery/nodes.go
Normal file
@@ -0,0 +1,839 @@
|
||||
package taskquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
type taskQueryPlanOutput struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
// explicitLimitPatterns 用于从用户原话提取“显式数量要求”。
|
||||
//
|
||||
// 例子:
|
||||
// 1. 前3个任务
|
||||
// 2. 给我5条
|
||||
// 3. top 10
|
||||
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*(个|条|项)?`),
|
||||
}
|
||||
// chineseDigitMap 支持常见中文数字(用于“前五个”“来三个”这类口语)。
|
||||
chineseDigitMap = map[rune]int{
|
||||
'一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5,
|
||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||||
}
|
||||
)
|
||||
|
||||
func (r *taskQueryGraphRunner) planNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||||
// 1. 防御校验:state 为空时直接返回,避免后续节点空指针。
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("task query graph: nil state in plan node")
|
||||
}
|
||||
|
||||
// 2. 规划节点只调用一次模型,把查询意图打包成结构化计划。
|
||||
r.emit("task_query.plan.generating", "正在一次性规划查询范围、排序和时间条件。")
|
||||
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
|
||||
用户输入:%s
|
||||
|
||||
请输出任务查询计划 JSON。`, st.RequestNowText, st.UserMessage)
|
||||
|
||||
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryPlanPrompt, prompt, 260)
|
||||
if err != nil {
|
||||
// 3. 模型失败时不直接终止:回退到默认计划,保证可用性。
|
||||
st.UserGoal = "查询任务"
|
||||
st.Plan = defaultTaskQueryPlan()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
planned, parseErr := parseTaskQueryJSON[taskQueryPlanOutput](raw)
|
||||
if parseErr != nil {
|
||||
// 4. JSON 异常同样回退默认计划,避免用户请求直接失败。
|
||||
st.UserGoal = "查询任务"
|
||||
st.Plan = defaultTaskQueryPlan()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 5. 规划结果统一规范化,保证后续节点拿到稳定参数。
|
||||
st.UserGoal = strings.TrimSpace(planned.UserGoal)
|
||||
if st.UserGoal == "" {
|
||||
st.UserGoal = "查询任务"
|
||||
}
|
||||
st.Plan = normalizePlan(taskQueryPlanOutput{
|
||||
UserGoal: planned.UserGoal,
|
||||
Quadrants: planned.Quadrants,
|
||||
SortBy: planned.SortBy,
|
||||
Order: planned.Order,
|
||||
Limit: planned.Limit,
|
||||
IncludeCompleted: planned.IncludeCompleted,
|
||||
Keyword: planned.Keyword,
|
||||
DeadlineBefore: planned.DeadlineBefore,
|
||||
DeadlineAfter: planned.DeadlineAfter,
|
||||
})
|
||||
|
||||
// 6. 若用户原话里有明确数量要求(例如“给我3个”),强制覆盖 plan.limit。
|
||||
// 这样即使规划模型漏掉 limit,也不会影响最终返回条数预期。
|
||||
if explicitLimit, found := extractExplicitLimitFromUser(st.UserMessage); found {
|
||||
st.ExplicitLimit = explicitLimit
|
||||
st.Plan.Limit = explicitLimit
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) quadrantNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||||
_ = ctx
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("task query graph: nil state in quadrant node")
|
||||
}
|
||||
|
||||
// 1. 象限节点不调用模型,只做“象限参数兜底与去重”。
|
||||
// 2. 为空表示全象限,非空表示指定象限。
|
||||
r.emit("task_query.quadrant.routing", "正在归一化象限筛选范围。")
|
||||
st.Plan.Quadrants = normalizeQuadrants(st.Plan.Quadrants)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) timeAnchorNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||||
_ = ctx
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("task query graph: nil state in time anchor node")
|
||||
}
|
||||
|
||||
// 1. 时间节点不再调用模型,只负责把规划中的时间文本解析为绝对时间对象。
|
||||
// 2. 解析失败时清空该边界,避免非法时间导致整条查询失败。
|
||||
r.emit("task_query.time.anchoring", "正在锁定时间过滤边界。")
|
||||
applyTimeAnchorOnPlan(&st.Plan)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) queryNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("task query graph: nil state in query node")
|
||||
}
|
||||
|
||||
// 1. 按当前计划执行工具查询。
|
||||
r.emit("task_query.tool.querying", "正在查询任务数据。")
|
||||
items, err := r.executePlanByTool(ctx, st.Plan)
|
||||
if err != nil {
|
||||
// 查询失败不抛出硬错误,交给反思节点决定如何回复用户。
|
||||
st.LastQueryItems = make([]TaskQueryToolRecord, 0)
|
||||
st.LastQueryTotal = 0
|
||||
st.ReflectReason = "查询工具执行失败"
|
||||
return st, nil
|
||||
}
|
||||
st.LastQueryItems = items
|
||||
st.LastQueryTotal = len(items)
|
||||
|
||||
// 2. 额外优化:若结果为空且还没自动放宽过,则先放宽一次再查询(无额外模型调用)。
|
||||
if st.LastQueryTotal == 0 && !st.AutoBroadenApplied {
|
||||
plan, broadened := autoBroadenPlan(st.Plan)
|
||||
if broadened {
|
||||
st.AutoBroadenApplied = true
|
||||
st.Plan = plan
|
||||
r.emit("task_query.tool.broadened", "首次查询为空,已自动放宽条件再试一次。")
|
||||
retryItems, retryErr := r.executePlanByTool(ctx, st.Plan)
|
||||
if retryErr == nil {
|
||||
st.LastQueryItems = retryItems
|
||||
st.LastQueryTotal = len(retryItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) reflectNode(ctx context.Context, st *TaskQueryState) (*TaskQueryState, error) {
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("task query graph: nil state in reflect node")
|
||||
}
|
||||
|
||||
// 1. 反思节点负责三件事:
|
||||
// 1.1 判断当前结果是否满足用户诉求;
|
||||
// 1.2 需要重试时给出最小 patch;
|
||||
// 1.3 同时给出可直接返回用户的中文回复。
|
||||
r.emit("task_query.reflecting", "正在判断结果是否贴合你的需求。")
|
||||
reflectPrompt := buildReflectUserPrompt(st)
|
||||
raw, err := callTaskQueryModelForJSON(ctx, r.input.Model, TaskQueryReflectPrompt, reflectPrompt, 380)
|
||||
if err != nil {
|
||||
// 2. 反思调用失败时直接收束,避免无限等待。
|
||||
st.NeedRetry = false
|
||||
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
reflectResult, parseErr := parseTaskQueryJSON[taskQueryReflectOutput](raw)
|
||||
if parseErr != nil {
|
||||
st.NeedRetry = false
|
||||
st.FinalReply = buildTaskQueryFallbackReply(st.LastQueryItems)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
st.ReflectReason = strings.TrimSpace(reflectResult.Reason)
|
||||
|
||||
// 3. 满足需求时直接结束。
|
||||
if reflectResult.Satisfied {
|
||||
st.NeedRetry = false
|
||||
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 4. 不满足且允许重试时,应用 patch 并回到查询节点。
|
||||
if reflectResult.NeedRetry && st.RetryCount < st.MaxReflectRetry {
|
||||
st.Plan = applyRetryPatch(st.Plan, reflectResult.RetryPatch, st.ExplicitLimit)
|
||||
st.RetryCount++
|
||||
st.NeedRetry = true
|
||||
if strings.TrimSpace(reflectResult.Reply) != "" {
|
||||
// 4.1 这里先缓存中间回复,最终是否使用取决于后续是否成功命中。
|
||||
st.FinalReply = strings.TrimSpace(reflectResult.Reply)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 5. 不再重试:输出最终回复并结束。
|
||||
st.NeedRetry = false
|
||||
st.FinalReply = buildTaskQueryFinalReply(st.LastQueryItems, st.Plan, strings.TrimSpace(reflectResult.Reply))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *taskQueryGraphRunner) executePlanByTool(ctx context.Context, plan QueryPlan) ([]TaskQueryToolRecord, error) {
|
||||
// 1. 这里强制通过工具执行查询,而不是直接读 DAO。
|
||||
// 目的:保持“工具边界”一致,后续迁移多工具编排时可复用同一协议。
|
||||
if r.queryTool == nil {
|
||||
return nil, fmt.Errorf("task query tool is nil")
|
||||
}
|
||||
|
||||
merged := make([]TaskQueryToolRecord, 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 := r.queryTool.InvokableRun(ctx, string(rawInput))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseTaskQueryJSON[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
|
||||
}
|
||||
|
||||
// 2. Quadrants 为空表示全象限,执行一次无象限过滤查询。
|
||||
if len(plan.Quadrants) == 0 {
|
||||
if err := runOne(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 3. 指定象限时逐个调用工具并合并去重。
|
||||
for _, quadrant := range plan.Quadrants {
|
||||
q := quadrant
|
||||
if err := runOne(&q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 合并后再按计划统一排序,保证跨象限结果顺序稳定。
|
||||
sortTaskQueryToolRecords(merged, plan)
|
||||
if len(merged) > plan.Limit {
|
||||
merged = merged[:plan.Limit]
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizePlan(raw taskQueryPlanOutput) QueryPlan {
|
||||
plan := defaultTaskQueryPlan()
|
||||
plan.Quadrants = normalizeQuadrants(raw.Quadrants)
|
||||
|
||||
sortBy := strings.ToLower(strings.TrimSpace(raw.SortBy))
|
||||
switch sortBy {
|
||||
case "deadline", "priority", "id":
|
||||
plan.SortBy = sortBy
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(raw.Order))
|
||||
switch order {
|
||||
case "asc", "desc":
|
||||
plan.Order = order
|
||||
}
|
||||
|
||||
if raw.Limit > 0 {
|
||||
plan.Limit = raw.Limit
|
||||
}
|
||||
if plan.Limit > MaxTaskQueryLimit {
|
||||
plan.Limit = MaxTaskQueryLimit
|
||||
}
|
||||
if plan.Limit <= 0 {
|
||||
plan.Limit = 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 defaultTaskQueryPlan() QueryPlan {
|
||||
return QueryPlan{
|
||||
Quadrants: nil,
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
Limit: DefaultTaskQueryLimit,
|
||||
IncludeCompleted: false,
|
||||
Keyword: "",
|
||||
}
|
||||
}
|
||||
|
||||
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 _, q := range quadrants {
|
||||
if q < 1 || q > 4 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[q]; exists {
|
||||
continue
|
||||
}
|
||||
seen[q] = struct{}{}
|
||||
result = append(result, q)
|
||||
}
|
||||
sort.Ints(result)
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(result) == 4 {
|
||||
// 指定了全部象限时与“空=全象限”等价,统一归一化为 nil。
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyTimeAnchorOnPlan(plan *QueryPlan) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
before, errBefore := parseOptionalBoundaryTime(plan.DeadlineBeforeText, true)
|
||||
after, errAfter := parseOptionalBoundaryTime(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 QueryPlan) (QueryPlan, bool) {
|
||||
// 1. 仅允许自动放宽一次,且放宽必须“可解释”:
|
||||
// 1.1 清空关键词;
|
||||
// 1.2 放开完成状态;
|
||||
// 1.3 清空时间边界;
|
||||
// 1.4 不主动改象限和 limit,避免语义漂移(例如“简单任务”被放宽成全象限)。
|
||||
changed := false
|
||||
broadened := plan
|
||||
|
||||
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 QueryPlan, patch taskQueryRetryPatch, explicitLimit int) QueryPlan {
|
||||
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 {
|
||||
// 用户显式指定数量时,锁定 limit,不允许反思补丁改写。
|
||||
if explicitLimit <= 0 {
|
||||
limit := *patch.Limit
|
||||
if limit <= 0 {
|
||||
limit = DefaultTaskQueryLimit
|
||||
}
|
||||
if limit > MaxTaskQueryLimit {
|
||||
limit = 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 buildReflectUserPrompt(st *TaskQueryState) string {
|
||||
planSummary := summarizePlan(st.Plan)
|
||||
resultSummary := summarizeQueryItems(st.LastQueryItems, 6)
|
||||
return fmt.Sprintf(`当前时间:%s
|
||||
用户原话:%s
|
||||
用户目标:%s
|
||||
当前查询计划:%s
|
||||
当前重试:%d/%d
|
||||
查询结果摘要:
|
||||
%s`,
|
||||
st.RequestNowText,
|
||||
st.UserMessage,
|
||||
st.UserGoal,
|
||||
planSummary,
|
||||
st.RetryCount,
|
||||
st.MaxReflectRetry,
|
||||
resultSummary,
|
||||
)
|
||||
}
|
||||
|
||||
func summarizePlan(plan QueryPlan) string {
|
||||
quadrants := "全部象限"
|
||||
if len(plan.Quadrants) > 0 {
|
||||
parts := make([]string, 0, len(plan.Quadrants))
|
||||
for _, q := range plan.Quadrants {
|
||||
parts = append(parts, strconv.Itoa(q))
|
||||
}
|
||||
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 summarizeQueryItems(items []TaskQueryToolRecord, 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 {
|
||||
line := fmt.Sprintf("- #%d %s | 象限=%d | 完成=%t | 截止=%s",
|
||||
item.ID, item.Title, item.PriorityGroup, item.IsCompleted, emptyToDash(item.DeadlineAt))
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildTaskQueryFallbackReply(items []TaskQueryToolRecord) string {
|
||||
if len(items) == 0 {
|
||||
return "我这边暂时没找到匹配的任务。你可以再补一句,比如“按截止时间最早的前3个”或“只看简单不重要”。"
|
||||
}
|
||||
// 1. 用最多 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, "、"))
|
||||
}
|
||||
|
||||
// buildTaskQueryFinalReply 构建“确定性条数”的最终回复。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 让返回条数严格受 plan.limit 约束,避免 LLM 自由发挥导致“只说1条”;
|
||||
// 2. 仍可保留 LLM 的语气前缀,但清单主体由后端稳定渲染;
|
||||
// 3. 无结果时统一走兜底文案。
|
||||
func buildTaskQueryFinalReply(items []TaskQueryToolRecord, plan QueryPlan, 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 = DefaultTaskQueryLimit
|
||||
}
|
||||
if desired > MaxTaskQueryLimit {
|
||||
desired = 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
|
||||
}
|
||||
|
||||
// extractSafeReplyLead 从 LLM 回复中提取“安全前缀句”。
|
||||
//
|
||||
// 目的:
|
||||
// 1. 防止 LLM 已经输出一整段列表时再次和后端列表拼接,造成双重输出;
|
||||
// 2. 仅保留单行短句语气前缀,正文列表始终以后端确定性渲染为准。
|
||||
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 sortTaskQueryToolRecords(items []TaskQueryToolRecord, plan QueryPlan) {
|
||||
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:
|
||||
lTime, lOK := parseRecordDeadline(left.DeadlineAt)
|
||||
rTime, rOK := parseRecordDeadline(right.DeadlineAt)
|
||||
if lOK && rOK {
|
||||
if !lTime.Equal(rTime) {
|
||||
if order == "desc" {
|
||||
return lTime.After(rTime)
|
||||
}
|
||||
return lTime.Before(rTime)
|
||||
}
|
||||
return left.ID > right.ID
|
||||
}
|
||||
if lOK && !rOK {
|
||||
return true
|
||||
}
|
||||
if !lOK && rOK {
|
||||
return false
|
||||
}
|
||||
return left.ID > right.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func parseRecordDeadline(raw string) (time.Time, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04", text, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func emptyToDash(text string) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "-"
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// extractExplicitLimitFromUser 从用户原话提取显式数量诉求。
|
||||
//
|
||||
// 解析策略:
|
||||
// 1. 先匹配阿拉伯数字(前3个/top 5/给我2条);
|
||||
// 2. 再匹配常见中文数字(前五个/来三个);
|
||||
// 3. 统一限制在 1~20 之间。
|
||||
func extractExplicitLimitFromUser(userMessage string) (int, bool) {
|
||||
text := strings.TrimSpace(userMessage)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, pattern := range explicitLimitPatterns {
|
||||
matches := pattern.FindStringSubmatch(text)
|
||||
if len(matches) < 2 {
|
||||
continue
|
||||
}
|
||||
number, err := strconv.Atoi(strings.TrimSpace(matches[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return normalizeExplicitLimit(number)
|
||||
}
|
||||
|
||||
// 中文数字兜底:覆盖高频口语模式。
|
||||
chinesePatterns := []string{"前", "来", "给我"}
|
||||
for _, prefix := range chinesePatterns {
|
||||
for digitRune, number := range chineseDigitMap {
|
||||
token := prefix + string(digitRune)
|
||||
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 > MaxTaskQueryLimit {
|
||||
number = MaxTaskQueryLimit
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
func callTaskQueryModelForJSON(
|
||||
ctx context.Context,
|
||||
model *ark.ChatModel,
|
||||
systemPrompt string,
|
||||
userPrompt string,
|
||||
maxTokens int,
|
||||
) (string, error) {
|
||||
if model == nil {
|
||||
return "", fmt.Errorf("task query model is nil")
|
||||
}
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(systemPrompt),
|
||||
schema.UserMessage(userPrompt),
|
||||
}
|
||||
|
||||
opts := []einoModel.Option{
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||||
einoModel.WithTemperature(0),
|
||||
}
|
||||
if maxTokens > 0 {
|
||||
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
|
||||
}
|
||||
|
||||
resp, err := model.Generate(ctx, messages, opts...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("task query model returned nil")
|
||||
}
|
||||
text := strings.TrimSpace(resp.Content)
|
||||
if text == "" {
|
||||
return "", fmt.Errorf("task query model returned empty content")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func parseTaskQueryJSON[T any](raw string) (*T, error) {
|
||||
clean := strings.TrimSpace(raw)
|
||||
if clean == "" {
|
||||
return nil, fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
// 1. 兼容 ```json 包裹格式。
|
||||
if strings.HasPrefix(clean, "```") {
|
||||
clean = strings.TrimPrefix(clean, "```json")
|
||||
clean = strings.TrimPrefix(clean, "```")
|
||||
clean = strings.TrimSuffix(clean, "```")
|
||||
clean = strings.TrimSpace(clean)
|
||||
}
|
||||
|
||||
// 2. 先尝试整体解析。
|
||||
var out T
|
||||
if err := json.Unmarshal([]byte(clean), &out); err == nil {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 3. 若模型前后带了额外文本,则提取最外层对象再解析。
|
||||
start := strings.Index(clean, "{")
|
||||
end := strings.LastIndex(clean, "}")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return nil, fmt.Errorf("no json object found")
|
||||
}
|
||||
obj := clean[start : end+1]
|
||||
if err := json.Unmarshal([]byte(obj), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
86
backend/agent/taskquery/nodes_test.go
Normal file
86
backend/agent/taskquery/nodes_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package taskquery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 目的:验证“来一个...”这种口语数量表达也能识别为 1。
|
||||
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 输出对应条数,而不是由 LLM 自由决定条数。
|
||||
func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
|
||||
items := []TaskQueryToolRecord{
|
||||
{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, QueryPlan{Limit: 2}, "好的")
|
||||
if !strings.Contains(reply, "整理了 2 条任务") {
|
||||
t.Fatalf("回复未体现 limit=2,reply=%s", reply)
|
||||
}
|
||||
if strings.Contains(reply, "3. ") {
|
||||
t.Fatalf("回复不应出现第3条,reply=%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskQueryFinalReply_NoDuplicateList
|
||||
// 目的:验证当 llmReply 已带列表内容时,不会和后端确定性列表重复拼接。
|
||||
func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
|
||||
items := []TaskQueryToolRecord{
|
||||
{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
|
||||
}
|
||||
llmReply := "以下是你的任务:\n#1 任务1"
|
||||
reply := buildTaskQueryFinalReply(items, QueryPlan{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 := QueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
|
||||
limit := 10
|
||||
next := applyRetryPatch(plan, taskQueryRetryPatch{Limit: &limit}, 1)
|
||||
if next.Limit != 1 {
|
||||
t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
|
||||
}
|
||||
}
|
||||
81
backend/agent/taskquery/prompt.go
Normal file
81
backend/agent/taskquery/prompt.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package taskquery
|
||||
|
||||
const (
|
||||
// TaskQueryAssistantPrompt 是“任务查询”分支的系统提示词。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 把“先查工具再回答”的约束写死,减少模型直接编造任务的风险;
|
||||
// 2. 约束输出风格:简洁、可执行、可追问;
|
||||
// 3. 当用户需求不完整时,引导模型先做合理默认,再补充可选澄清。
|
||||
TaskQueryAssistantPrompt = `你是 SmartFlow 的任务查询助手。
|
||||
你的职责是:根据用户的问题,从任务工具中检索真实任务,再给出中文回复。
|
||||
|
||||
强约束:
|
||||
1) 只要用户在“查任务/筛任务/排序任务/找任务”,必须优先调用 query_tasks 工具,不要凭空回答。
|
||||
2) 工具返回为空时,直接说明“当前没有匹配任务”,并给一个简短下一步建议。
|
||||
3) 结果较多时,默认展示前 3~5 条关键信息(标题、象限、截止时间、完成状态)。
|
||||
4) 用户指令不完整时可先用默认参数查一次,再补一句澄清建议,不要反复追问。
|
||||
5) 回复必须自然口语化,禁止输出 markdown 表格。`
|
||||
|
||||
// TaskQueryPlanPrompt 是“任务查询规划节点”的系统提示词。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 只调用一次模型,把“象限选择 + 排序 + 时间过滤 + 结果规模”统一规划出来;
|
||||
// 2. 输出强约束 JSON,便于后端节点稳定解析;
|
||||
// 3. 不要求模型直接生成最终回复,避免规划阶段混入废话。
|
||||
TaskQueryPlanPrompt = `你是 SmartFlow 的任务查询规划器。
|
||||
请根据用户原话,输出“结构化查询计划”JSON,供后端直接执行。
|
||||
|
||||
输出字段(只允许 JSON,不要解释):
|
||||
{
|
||||
"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 或空字符串"
|
||||
}
|
||||
|
||||
规则:
|
||||
1) quadrants 为空数组表示“全部象限”。
|
||||
2) 若用户没提排序,默认 deadline + asc。
|
||||
3) 若用户没提数量,limit 默认 5。
|
||||
4) 时间字段必须是绝对时间或空字符串,不得输出相对时间。
|
||||
5) 只有用户的语义偏向"我还有啥事要做",即了解自己待办的请求,才优先1,2象限,即重要并紧急或者重要不紧急,若1,2象限没任务,则自动退至3,4象限;如果用户语义偏向"来点事情做做",那就说明用户需要无关紧要的事情做做,则优先3,4象限,即简单不重要或者不简单不重要。
|
||||
6) 允许多选象限。`
|
||||
|
||||
// TaskQueryReflectPrompt 是“查询结果反思节点”的系统提示词。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1. 让模型判断“当前结果是否满足用户诉求”;
|
||||
// 2. 若不满足,给出可执行的轻量 patch(最多改几个关键条件);
|
||||
// 3. 同时输出可直接返回给用户的 reply,减少额外生成调用。
|
||||
TaskQueryReflectPrompt = `你是 SmartFlow 的任务查询结果审阅器。
|
||||
你会看到:用户原话、当前查询计划、查询结果摘要、当前重试次数。
|
||||
|
||||
请仅输出 JSON:
|
||||
{
|
||||
"satisfied": true/false,
|
||||
"need_retry": true/false,
|
||||
"reason": "一句话原因",
|
||||
"reply": "可直接给用户看的中文回复",
|
||||
"retry_patch": {
|
||||
"quadrants": [1,2,3,4],
|
||||
"sort_by": "deadline|priority|id",
|
||||
"order": "asc|desc",
|
||||
"limit": 1-20,
|
||||
"include_completed": true/false,
|
||||
"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 中说明当前最接近结果。`
|
||||
)
|
||||
88
backend/agent/taskquery/state.go
Normal file
88
backend/agent/taskquery/state.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package taskquery
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// DefaultTaskQueryLimit 是任务查询默认返回条数。
|
||||
DefaultTaskQueryLimit = 5
|
||||
// MaxTaskQueryLimit 是任务查询最大返回条数。
|
||||
MaxTaskQueryLimit = 20
|
||||
// DefaultReflectRetryMax 是反思重试默认上限。
|
||||
DefaultReflectRetryMax = 2
|
||||
)
|
||||
|
||||
// TaskQueryState 是任务查询图在节点间传递的统一状态容器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 保存“规划参数、查询结果、反思决策、最终回复”;
|
||||
// 2. 控制“是否重试 + 已重试次数”状态机;
|
||||
// 3. 不负责真正查库,查库由工具执行。
|
||||
type TaskQueryState struct {
|
||||
// 请求上下文
|
||||
UserMessage string
|
||||
RequestNowText string
|
||||
|
||||
// 规划结果
|
||||
UserGoal string
|
||||
Plan QueryPlan
|
||||
// ExplicitLimit 表示“用户原话中明确指定的数量”。
|
||||
//
|
||||
// 语义说明:
|
||||
// 1. 0 代表未显式指定;
|
||||
// 2. >0 时应锁定该数量,不允许反思补丁或自动放宽改写。
|
||||
ExplicitLimit int
|
||||
|
||||
// 上一轮查询结果
|
||||
LastQueryItems []TaskQueryToolRecord
|
||||
LastQueryTotal int
|
||||
|
||||
// 自动放宽状态
|
||||
AutoBroadenApplied bool
|
||||
|
||||
// 反思状态
|
||||
RetryCount int
|
||||
MaxReflectRetry int
|
||||
NeedRetry bool
|
||||
ReflectReason string
|
||||
|
||||
// 最终输出
|
||||
FinalReply string
|
||||
}
|
||||
|
||||
// QueryPlan 是“任务查询计划”的统一结构。
|
||||
//
|
||||
// 语义说明:
|
||||
// 1. Quadrants 为空表示“查全部象限”;非空表示“只查这些象限”;
|
||||
// 2. DeadlineBefore/AfterText 保留原始文本,方便日志和反思 prompt;
|
||||
// 3. DeadlineBefore/After 是解析后的时间对象,供工具调用使用。
|
||||
type QueryPlan struct {
|
||||
Quadrants []int
|
||||
|
||||
SortBy string
|
||||
Order string
|
||||
Limit int
|
||||
|
||||
IncludeCompleted bool
|
||||
Keyword string
|
||||
|
||||
DeadlineBeforeText string
|
||||
DeadlineAfterText string
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
}
|
||||
|
||||
// NewTaskQueryState 创建任务查询初始状态。
|
||||
func NewTaskQueryState(userMessage, requestNowText string, maxReflectRetry int) *TaskQueryState {
|
||||
if maxReflectRetry <= 0 {
|
||||
maxReflectRetry = DefaultReflectRetryMax
|
||||
}
|
||||
return &TaskQueryState{
|
||||
UserMessage: userMessage,
|
||||
RequestNowText: requestNowText,
|
||||
MaxReflectRetry: maxReflectRetry,
|
||||
LastQueryItems: make([]TaskQueryToolRecord, 0),
|
||||
NeedRetry: false,
|
||||
ReflectReason: "",
|
||||
AutoBroadenApplied: false,
|
||||
}
|
||||
}
|
||||
344
backend/agent/taskquery/tool.go
Normal file
344
backend/agent/taskquery/tool.go
Normal 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")
|
||||
}
|
||||
37
backend/agent/taskquery/tool_helpers.go
Normal file
37
backend/agent/taskquery/tool_helpers.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package taskquery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
// buildInvokableToolMap 把工具包转换成“工具名 -> 可执行工具”映射。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做工具元数据到执行器的映射,不做业务逻辑;
|
||||
// 2. 若工具包结构异常(数量不一致/信息缺失)直接返回 error;
|
||||
// 3. 供图节点在运行时快速按工具名取执行器。
|
||||
func buildInvokableToolMap(bundle *TaskQueryToolBundle) (map[string]tool.InvokableTool, error) {
|
||||
if bundle == nil || len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
|
||||
return nil, fmt.Errorf("task query tool bundle is empty")
|
||||
}
|
||||
if len(bundle.Tools) != len(bundle.ToolInfos) {
|
||||
return nil, fmt.Errorf("task query tool bundle mismatch")
|
||||
}
|
||||
|
||||
result := make(map[string]tool.InvokableTool, len(bundle.Tools))
|
||||
for idx, baseTool := range bundle.Tools {
|
||||
info := bundle.ToolInfos[idx]
|
||||
if info == nil || strings.TrimSpace(info.Name) == "" {
|
||||
return nil, fmt.Errorf("task query tool info is invalid")
|
||||
}
|
||||
invokableTool, ok := baseTool.(tool.InvokableTool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("task query tool %s is not invokable", info.Name)
|
||||
}
|
||||
result[info.Name] = invokableTool
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
45
backend/agent/taskquery/tool_test.go
Normal file
45
backend/agent/taskquery/tool_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package taskquery
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNormalizeToolInput_Default
|
||||
// 目的:验证空入参会回填默认查询参数,保证工具在“参数缺失”场景仍可执行。
|
||||
func TestNormalizeToolInput_Default(t *testing.T) {
|
||||
req, err := normalizeToolInput(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
|
||||
t.Fatalf("默认值异常: %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeToolInput_InvalidQuadrant
|
||||
// 目的:验证 quadrant 越界时会被拦截,避免无效过滤条件进入业务层。
|
||||
func TestNormalizeToolInput_InvalidQuadrant(t *testing.T) {
|
||||
invalid := 6
|
||||
_, err := normalizeToolInput(&TaskQueryToolInput{
|
||||
Quadrant: &invalid,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("期望 quadrant 越界时报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeToolInput_DateRange
|
||||
// 目的:验证时间上下界可解析并正确落入请求结构。
|
||||
func TestNormalizeToolInput_DateRange(t *testing.T) {
|
||||
req, err := normalizeToolInput(&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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user