Files
smartmate/backend/agent/taskquery/graph.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

184 lines
5.7 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-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
}