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

@@ -352,7 +352,35 @@ $$Gap = \frac{TotalAvailableSlots - (TaskCount \times 2)}{TaskCount + 1}$$
## 5.4 Agent范式实现细节
### 1) 命中“添加日程/随口记”后的业务流转
### 1) 总分流图(消息识别后的去向)
```mermaid
flowchart TD
A["用户消息进入 AgentChat"] --> B["通用控制码分流<br/>action=chat/quick_note_create/task_query"]
B --> C{"路由是否成功解析"}
C -- 否 --> D["兜底普通聊天链路<br/>StreamChat token流式输出"]
C -- 是 --> E{"action 类型"}
E -- chat --> F["普通聊天链路<br/>StreamChat token流式输出"]
E -- quick_note_create --> G["随口记链路<br/>单请求聚合规划 + 本地校验"]
G --> H["写库工具落库<br/>task_id有效校验 + 失败重试"]
H --> I["一次性正文回复"]
E -- task_query --> J["随口问链路<br/>进入 TaskQueryGraph"]
J --> K["plan -> quadrant -> time_anchor"]
K --> L["tool_query 调用 query_tasks"]
L --> M["reflect 判断是否满足<br/>不满足则 patch 重试(<=2)"]
M --> N["后端确定性渲染列表<br/>严格按 limit 输出条数"]
D --> Z["后置持久化<br/>Redis + outbox/DB"]
F --> Z
I --> Z
N --> Z
```
### 2) 命中“添加日程/随口记”后的业务流转
```mermaid
flowchart TD
@@ -380,29 +408,29 @@ flowchart TD
X --> T
```
### 2) 总分流图(消息识别后的去向)
### 3) 命中“随口问/任务查询”后的业务流转
```mermaid
flowchart TD
A[用户消息进入 AgentChat] --> B[模型控制码路由<br/>action=quick_note/chat]
B --> C{路由是否成功解析}
C -- 是 --> D{action=quick_note?}
D -- 否 --> E[普通聊天链路<br/>StreamChat token流式输出]
D -- 是 --> F[随口记快路径<br/>跳过二次意图判定]
F --> G[单请求聚合规划<br/>+本地时间校验/优先级兜底]
G --> H[写库工具落库<br/>task_id有效校验]
H --> I[返回一次性正文]
C -- 否 --> J[随口记兜底路径<br/>恢复二次意图判定]
J --> K{是否随口记意图}
K -- 否 --> L[普通聊天链路<br/>StreamChat token流式输出]
K -- 是 --> M[执行随口记写库链路<br/>返回一次性正文]
E --> Z[后置持久化<br/>Redis + outbox/DB]
I --> Z
L --> Z
M --> Z
A["用户消息进入 /agent/chat"] --> B["通用控制码分流<br/>action=chat/quick_note_create/task_query"]
B --> C{"action 是否为 task_query"}
C -- 否 --> D["走其它分支<br/>普通聊天或随口记"]
C -- 是 --> E["进入 TaskQueryGraph"]
E --> F["节点1: plan<br/>一次模型调用产出查询计划"]
F --> G["节点2: quadrant<br/>归一化象限范围"]
G --> H["节点3: time_anchor<br/>锁定时间过滤边界"]
H --> I["节点4: tool_query<br/>调用 query_tasks 工具查询"]
I --> J{"首次结果是否为空"}
J -- 是 --> K["自动放宽一次<br/>仅放宽关键词/完成状态/时间边界"]
K --> L["再次调用 query_tasks"]
J -- 否 --> M["进入反思节点"]
L --> M
M --> N["节点5: reflect<br/>模型判断结果是否满足用户诉求"]
N --> O{"need_retry 且未超上限"}
O -- 是 --> P["应用 retry_patch<br/>重试次数+1"]
P --> I
O -- 否 --> Q["后端确定性渲染最终回复<br/>严格按 limit 输出条数"]
Q --> R["后置持久化<br/>user+assistant 写 Redis + outbox/DB"]
```
# 6 前端实现

View File

@@ -36,7 +36,7 @@ const (
必须完成以下五件事:
1) 提取任务标题 title简洁明确
2) 归一化截止时间 deadline_at若存在时间线索必须输出绝对时间
3) 评估紧急分界时间 urgency_threshold_at何时从不紧急象限自动平移到紧急象限可为空
3) 评估紧急分界时间 urgency_threshold_at当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,可为空)。
4) 评估优先级 priority_group1~4
5) 生成一句轻松跟进句 banter不超过30字

View File

@@ -8,7 +8,6 @@ import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/quicknote"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
@@ -17,86 +16,135 @@ import (
)
const (
// ControlTimeout 是“模型控制码分流”步的额外超时。
// 设为 0 表示完全跟随父请求上下文,不额外截断。
// ControlTimeout 是“模型控制码分流”这一步的额外超时预算
//
// 约束说明:
// 1. 设为 0 代表完全继承父 ctx 的 deadline不额外截断
// 2. 若后续线上观测到分流偶发超时,可再加一个小预算(例如 2s做隔离。
ControlTimeout = 0 * time.Second
)
var (
// 控制头格式:
// <SMARTFLOW_ROUTE nonce="xxx" action="quick_note|chat"></SMARTFLOW_ROUTE>
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`)
// 可选理由块:
// <SMARTFLOW_REASON>...</SMARTFLOW_REASON>
// routeHeaderRegex 用于解析控制码头部。
//
// 支持动作:
// 1. quick_note_create新增随口记任务
// 2. task_query查询任务
// 3. chat普通聊天
// 4. quick_note历史兼容别名解析后会映射到 quick_note_create。
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|quick_note|chat)["']?[^>]*>`)
// routeReasonRegex 用于提取可选的理由块,方便日志排障。
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
)
// Action 表示控制码路由动作。
const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。
你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。
动作定义:
1) quick_note_create用户明确希望“记录/安排/提醒某件未来要做的事”。
2) task_query用户想“查看/筛选/排序/获取”已有任务如最紧急、按DDL、某象限、关键词
3) chat其余全部普通对话包括闲聊、知识问答、纯讨论“怎么安排任务”但未要求你真的去操作
判定优先级(冲突时按顺序):
1) 若句子核心诉求是“帮我记一件事”,选 quick_note_create。
2) 若核心诉求是“帮我查任务列表/某类任务”,选 task_query。
3) 其他情况选 chat。
输出格式必须严格如下(两行):
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|chat"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
禁止输出任何其他内容。`
// Action 表示分流动作。
type Action string
const (
ActionChat Action = "chat"
ActionChat Action = "chat"
ActionQuickNoteCreate Action = "quick_note_create"
ActionTaskQuery Action = "task_query"
// ActionQuickNote 是历史兼容别名,只用于解析旧 action 值。
ActionQuickNote Action = "quick_note"
)
// ControlDecision 是“控制码解析结果”。
// ControlDecision 是“模型控制码解析结果”。
type ControlDecision struct {
Action Action
Reason string
Raw string
}
// RoutingDecision 是服务层最终使用的路由结果。
// RoutingDecision 是服务层使用的统一分流结果。
//
// 职责边界:
// 1. Action最终动作chat/quick_note_create/task_query
// 2. TrustRoute是否允许下游跳过二次意图判定
// 3. Detail可选说明用于阶段提示或日志。
type RoutingDecision struct {
EnterQuickNote bool
TrustRoute bool
Detail string
Action Action
TrustRoute bool
Detail string
}
// DecideQuickNoteRouting 通过“模型控制码”决定本次请求走向。
// DecideActionRouting 通过“模型控制码”决定本次请求走向。
//
// 返回语义:
// 1) EnterQuickNote=true进入 quick_note graph
// 2) TrustRoute=true表示可跳过 graph 二次意图判定。
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
// 1. Action=quick_note_create进入随口记写入图
// 2. Action=task_query进入任务查询 tool-calling
// 3. Action=chat进入普通聊天流
// 4. 路由失败时回落 chat保证可用性优先。
func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision, err := routeByModelControlTag(ctx, selectedModel, userMessage)
if err != nil {
if deadline, ok := ctx.Deadline(); ok {
log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
} else {
log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d",
log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline=none route_timeout_ms=%d",
err, ControlTimeout.Milliseconds())
}
return RoutingDecision{
EnterQuickNote: true,
TrustRoute: false,
Detail: "路由判定暂不可用,已进入任务识别兜底流程。",
Action: ActionChat,
TrustRoute: false,
Detail: "",
}
}
switch decision.Action {
case ActionQuickNote:
case ActionQuickNoteCreate:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "模型识别到任务安排请求,准备执行随口记。"
reason = "识别到新增任务请求,准备执行随口记流程。"
}
return RoutingDecision{
EnterQuickNote: true,
TrustRoute: true,
Detail: reason,
Action: ActionQuickNoteCreate,
TrustRoute: true,
Detail: reason,
}
case ActionTaskQuery:
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = "识别到任务查询请求,准备调用任务查询工具。"
}
return RoutingDecision{
Action: ActionTaskQuery,
TrustRoute: true,
Detail: reason,
}
case ActionChat:
return RoutingDecision{
EnterQuickNote: false,
TrustRoute: false,
Detail: "",
Action: ActionChat,
TrustRoute: false,
Detail: "",
}
default:
log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw)
// 兜底:未知动作一律回落 chat避免误入错误分支。
log.Printf("通用分流出现未知动作,回落 chat: action=%s raw=%s", decision.Action, decision.Raw)
return RoutingDecision{
EnterQuickNote: true,
TrustRoute: false,
Detail: "路由结果异常,已进入任务识别兜底流程。",
Action: ActionChat,
TrustRoute: false,
Detail: "",
}
}
}
@@ -114,12 +162,12 @@ func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, u
userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
resp, err := selectedModel.Generate(routeCtx, []*schema.Message{
schema.SystemMessage(quicknote.QuickNoteRouteControlPrompt),
schema.SystemMessage(routeControlPrompt),
schema.UserMessage(userPrompt),
},
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
einoModel.WithMaxTokens(80),
einoModel.WithMaxTokens(120),
)
if err != nil {
return nil, err
@@ -133,13 +181,14 @@ func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, u
return nil, fmt.Errorf("empty route content")
}
return ParseQuickNoteRouteControlTag(raw, nonce)
return ParseRouteControlTag(raw, nonce)
}
// deriveRouteControlContext 为“控制码路由”创建子上下文。
//
// 设计要点:
// 1) timeout<=0 时不加额外 deadline仅继承父上下文
// 2) 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。
// 1. timeout<=0 时不加额外 deadline仅继承父上下文
// 2. 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。
func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return context.WithCancel(parent)
@@ -152,12 +201,13 @@ func deriveRouteControlContext(parent context.Context, timeout time.Duration) (c
return context.WithTimeout(parent, timeout)
}
// ParseQuickNoteRouteControlTag 解析控制码返回。
// ParseRouteControlTag 解析通用控制码返回。
//
// 容错策略:
// 1) 允许大小写、属性顺序、额外属性差异;
// 2) nonce 必须精确匹配;
// 3) action 仅允许 quick_note/chat
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
// 1. 允许大小写、属性顺序、额外属性差异;
// 2. nonce 必须精确匹配;
// 3. action 仅允许 quick_note_create/task_query/chat兼容 quick_note
func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, fmt.Errorf("route content is empty")
@@ -175,7 +225,13 @@ func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision,
actionText := strings.ToLower(strings.TrimSpace(header[2]))
action := Action(actionText)
if action != ActionQuickNote && action != ActionChat {
switch action {
case ActionQuickNoteCreate, ActionTaskQuery, ActionChat:
// 合法动作直接通过。
case ActionQuickNote:
// 兼容旧动作值:统一映射到 quick_note_create。
action = ActionQuickNoteCreate
default:
return nil, fmt.Errorf("invalid route action: %s", actionText)
}
@@ -191,3 +247,29 @@ func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision,
Raw: text,
}, nil
}
// DecideQuickNoteRouting 是历史兼容入口。
//
// 说明:
// 1. 旧代码只区分“进不进 quick_note”
// 2. 新分流里 task_query 不应进入 quick_note因此这里会映射为 false。
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
decision := DecideActionRouting(ctx, selectedModel, userMessage)
if decision.Action == ActionQuickNoteCreate {
return decision
}
return RoutingDecision{
Action: ActionChat,
TrustRoute: false,
Detail: "",
}
}
// ParseQuickNoteRouteControlTag 是历史兼容解析入口。
//
// 说明:
// 1. 旧测试仍调用该函数名;
// 2. 新实现统一委托给 ParseRouteControlTag。
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
return ParseRouteControlTag(raw, expectedNonce)
}

View 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
}

View 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
}

View 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=2reply=%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)
}
}

View 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 中说明当前最接近结果。`
)

View 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,
}
}

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")
}

View 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
}

View 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)
}
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/agent/route"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
@@ -260,58 +261,87 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
// 3) 统一异步分流:
// - 先走“模型控制码路由”决定 quick_note / chat
// - 路由命中 quick_note 时推阶段状态并执行 graph
// - 路由命中 chat 时直接普通流式聊天。
// 3.1 先走“通用控制码路由”决定 actionchat / quick_note_create / task_query
// 3.2 quick_note_create 进入随口记 graph
// 3.3 task_query 进入任务查询 tool-calling
// 3.4 chat 直接普通流式聊天。
go func() {
defer close(outChan)
// 3.1 先走轻量路由,判断是否进入“随口记”图
routing := s.decideQuickNoteRouting(ctx, selectedModel, userMessage)
if !routing.EnterQuickNote {
// 3.2 非随口记:直接走普通聊天主链路。
// 3.1 先走轻量路由,拿到统一 action
routing := s.decideActionRouting(ctx, selectedModel, userMessage)
// 3.2 chat:直接走普通聊天主链路。
if routing.Action == route.ActionChat {
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.3 随口记:先发阶段状态,减少用户等待时的“无反馈感”。
// 3.3 非 chat 分支统一先发“接收成功”阶段,减少用户等待时的“无反馈感”。
progress := newQuickNoteProgressEmitter(outChan, resolvedModelName, true)
progress.Emit("request.accepted", routing.Detail)
// 3.4 执行随口记 graph。
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
ctx,
selectedModel,
userMessage,
userID,
chatID,
traceID,
routing.TrustRoute,
progress.Emit,
)
if quickErr != nil {
// graph 出错不直接中断用户请求,而是回退普通聊天,保证可用性优先。
log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr)
}
// 3.4 quick_note_create执行随口记 graph。
if routing.Action == route.ActionQuickNoteCreate {
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
ctx,
selectedModel,
userMessage,
userID,
chatID,
traceID,
routing.TrustRoute,
progress.Emit,
)
if quickErr != nil {
// graph 出错不直接中断用户请求,而是回退普通聊天,保证可用性优先。
log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr)
}
if quickHandled {
// 3.5 随口记处理成功:组织最终回复并按 OpenAI 兼容格式输出。
progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。")
quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState)
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
if quickHandled {
// 3.4.1 随口记处理成功:组织最终回复并按 OpenAI 兼容格式输出。
progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。")
quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState)
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
// 3.4.2 对随口记回复执行统一后置持久化Redis + outbox/DB
s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan)
// 3.4.3 随口记链路同样异步生成会话标题(仅首次写入)。
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.6 对随口记回复执行统一后置持久化Redis + outbox/DB
s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan)
// 3.7 随口记链路同样异步生成会话标题(仅首次写入)。
// 3.4.4 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.5 task_query执行任务查询 tool-calling。
if routing.Action == route.ActionTaskQuery {
reply, queryErr := s.runTaskQueryFlow(ctx, selectedModel, userMessage, userID, progress.Emit)
if queryErr != nil {
// 3.5.1 任务查询失败时回退普通聊天,避免请求直接中断。
log.Printf("任务查询 tool-calling 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, queryErr)
progress.Emit("task_query.fallback", "任务查询暂不可用,先切回普通对话。")
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.5.2 查询成功后按 OpenAI 兼容格式输出,并执行统一后置持久化。
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
s.persistChatAfterReply(ctx, userID, chatID, userMessage, reply, errChan)
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.8 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
// 3.6 未知 action 兜底:走普通聊天,保证可用性
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
}()

View File

@@ -23,14 +23,34 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
if decision == nil {
t.Fatalf("decision 不应为空")
}
if decision.Action != route.ActionQuickNote {
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNote, decision.Action)
// 兼容逻辑:历史 quick_note 会被统一映射到 quick_note_create。
if decision.Action != route.ActionQuickNoteCreate {
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNoteCreate, decision.Action)
}
if strings.TrimSpace(decision.Reason) == "" {
t.Fatalf("reason 不应为空")
}
}
// TestParseRouteControlTag_TaskQuery
// 目的:验证通用分流中 action=task_query 的控制码可稳定解析。
func TestParseRouteControlTag_TaskQuery(t *testing.T) {
nonce := "taskquerynonce"
raw := `<SMARTFLOW_ROUTE nonce="taskquerynonce" action="task_query"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>用户在查最紧急任务</SMARTFLOW_REASON>`
decision, err := route.ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
if decision.Action != route.ActionTaskQuery {
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionTaskQuery, decision.Action)
}
}
// TestParseQuickNoteRouteControlTag_NonceMismatch
// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {

View File

@@ -0,0 +1,27 @@
package agentsvc
import (
"context"
"github.com/LoveLosita/smartflow/backend/agent/route"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// actionRoutingDecision 是 route 层分流结果在 agentsvc 的本地别名。
//
// 设计目的:
// 1. 让 AgentService 对 route 包保持“最小接触面”;
// 2. 后续若 route 包返回结构调整,只需改这个桥接文件。
type actionRoutingDecision = route.RoutingDecision
// decideActionRouting 决定当前请求走向哪条业务链路。
//
// 职责边界:
// 1. 只负责调用 route 包拿分流结论;
// 2. 不负责执行任何业务节点;
// 3. route 层失败时的兜底策略由 route 包内部统一处理(当前为回落 chat
func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision {
// 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。
_ = s
return route.DecideActionRouting(ctx, selectedModel, userMessage)
}

View File

@@ -0,0 +1,259 @@
package agentsvc
import (
"context"
"errors"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/taskquery"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// runTaskQueryFlow 执行“任务查询”分支。
//
// 职责边界:
// 1. 负责把本次请求接入 taskquery 执行器;
// 2. 负责把 user_id 注入工具依赖,确保模型无法越权查他人任务;
// 3. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
func (s *AgentService) runTaskQueryFlow(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
userID int,
emitStage func(stage, detail string),
) (string, error) {
// 1. 依赖预检:任务查询必须依赖 taskRepo + model。
if s == nil || s.taskRepo == nil {
return "", errors.New("task query service dependency is not ready")
}
if selectedModel == nil {
return "", errors.New("task query model is nil")
}
// 2. 构建执行输入并启动 tool-calling。
// 2.1 RequestNow 仅用于 prompt 辅助,不参与数据库过滤。
requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04")
return taskquery.RunTaskQueryGraph(ctx, taskquery.QueryGraphRunInput{
Model: selectedModel,
UserMessage: userMessage,
RequestNowText: requestNow,
MaxReflectRetry: 2,
EmitStage: emitStage,
Deps: taskquery.TaskQueryToolDeps{
QueryTasks: func(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
// 2.2 调用目的:在工具层做完参数校验后,这里把 user_id 强制注入,再执行真实查询。
// 这样可以保证模型永远只能查当前登录用户的数据。
req.UserID = userID
return s.queryTasksForAgent(ctx, req)
},
},
})
}
// queryTasksForAgent 在 Agent 任务查询场景下读取并筛选任务。
//
// 职责边界:
// 1. 负责“读取原始任务 + 读时优先级派生 + 条件筛选 + 排序 + 截断”;
// 2. 不负责写库,不触发 outbox只读查询链路
// 3. 返回的是工具层结构,不直接暴露 DAO 模型给上层。
func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
_ = ctx
// 1. 基础参数校验。
if req.UserID <= 0 {
return nil, errors.New("invalid user_id in task query")
}
if s.taskRepo == nil {
return nil, errors.New("task repository is nil")
}
// 2. 读取用户全部任务。
// 2.1 当前 TaskDAO 读取接口无 context 参数,这里保持最小侵入复用既有能力;
// 2.2 若用户任务为空,返回空切片而不是 error方便模型自然回复“暂无任务”。
tasks, err := s.taskRepo.GetTasksByUserID(req.UserID)
if err != nil {
if errors.Is(err, respond.UserTasksEmpty) {
return make([]taskquery.TaskRecord, 0), nil
}
return nil, err
}
// 3. 读时派生 + 条件筛选:
// 3.1 先按“紧急分界线”做内存派生,保证查询视图与主业务口径一致;
// 3.2 再应用 include_completed/quadrant/keyword/deadline 条件。
now := time.Now()
filtered := make([]model.Task, 0, len(tasks))
for _, originalTask := range tasks {
currentTask := originalTask
applyReadTimeUrgencyPromotion(&currentTask, now)
if !taskMatchesQueryFilter(currentTask, req) {
continue
}
filtered = append(filtered, currentTask)
}
// 4. 排序与截断:
// 4.1 排序字段/方向已经在工具层校验过,这里按约定执行;
// 4.2 limit 截断只发生在排序之后,保证“前 N 条”语义正确。
sortTasksForQuery(filtered, req)
if req.Limit > 0 && len(filtered) > req.Limit {
filtered = filtered[:req.Limit]
}
// 5. 映射成工具输出结构。
records := make([]taskquery.TaskRecord, 0, len(filtered))
for _, task := range filtered {
records = append(records, taskquery.TaskRecord{
ID: task.ID,
Title: task.Title,
PriorityGroup: task.Priority,
IsCompleted: task.IsCompleted,
DeadlineAt: task.DeadlineAt,
UrgencyThresholdAt: task.UrgencyThresholdAt,
})
}
return records, nil
}
// applyReadTimeUrgencyPromotion 复用“读时紧急性派生”口径(内存态)。
//
// 规则:
// 1. 已完成任务不派生;
// 2. 未到紧急分界线不派生;
// 3. 到线后仅做 2->1、4->3 的象限平移;
// 4. 只改内存对象,不改数据库。
func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
if task == nil {
return
}
if task.IsCompleted || task.UrgencyThresholdAt == nil {
return
}
if task.UrgencyThresholdAt.After(now) {
return
}
switch task.Priority {
case 2:
task.Priority = 1
case 4:
task.Priority = 3
}
}
// taskMatchesQueryFilter 判断任务是否满足查询条件。
func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) bool {
// 1. include_completed=false 时默认过滤掉已完成任务。
if !req.IncludeCompleted && task.IsCompleted {
return false
}
// 2. quadrant 过滤:只保留指定象限。
if req.Quadrant != nil && task.Priority != *req.Quadrant {
return false
}
// 3. keyword 过滤:对标题做大小写不敏感包含匹配。
keyword := strings.TrimSpace(req.Keyword)
if keyword != "" {
if !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
return false
}
}
// 4. deadline 区间过滤:
// 4.1 只要设置了上下界deadline_at 为空的任务默认不匹配;
// 4.2 区间边界为闭区间(>= after 且 <= before
if req.DeadlineAfter != nil {
if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) {
return false
}
}
if req.DeadlineBefore != nil {
if task.DeadlineAt == nil || task.DeadlineAt.After(*req.DeadlineBefore) {
return false
}
}
return true
}
// sortTasksForQuery 按查询条件排序任务。
//
// 排序策略:
// 1. sort_by=deadline按截止时间排deadline 为空的任务统一放末尾;
// 2. sort_by=priority按象限数值排1 最紧急),同优先级再按 id 倒序;
// 3. sort_by=id按 id 排(可近似“新旧顺序”)。
func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
if len(tasks) <= 1 {
return
}
order := strings.ToLower(strings.TrimSpace(req.Order))
if order != "desc" {
order = "asc"
}
sortBy := strings.ToLower(strings.TrimSpace(req.SortBy))
if sortBy == "" {
sortBy = "deadline"
}
sort.SliceStable(tasks, func(i, j int) bool {
left := tasks[i]
right := tasks[j]
switch sortBy {
case "priority":
if left.Priority != right.Priority {
if order == "desc" {
return left.Priority > right.Priority
}
return left.Priority < right.Priority
}
// 同优先级时按 id 倒序,保证排序稳定且更接近“最近创建在前”。
return left.ID > right.ID
case "id":
if order == "desc" {
return left.ID > right.ID
}
return left.ID < right.ID
default: // deadline
if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided {
return less
}
// 截止时间相同或都为空时,回退 id 倒序保证稳定性。
return left.ID > right.ID
}
})
}
// compareDeadline 比较两个可选截止时间。
//
// 返回语义:
// 1. lessleft 是否应排在 right 前;
// 2. decided本次比较是否已能得出顺序false 表示需要上层继续用次级键比较。
func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) {
// 1. 都为空:本次不决策,交给次级键。
if left == nil && right == nil {
return false, false
}
// 2. 只有一边为空:为空的一侧统一放末尾。
if left == nil && right != nil {
return false, true
}
if left != nil && right == nil {
return true, true
}
// 3. 两边都不为空:按 order 做时间比较。
if left.Equal(*right) {
return false, false
}
if order == "desc" {
return left.After(*right), true
}
return left.Before(*right), true
}

View File

@@ -0,0 +1,117 @@
# 功能决策记录FDRAI随口问任务查询
## 1. 基本信息
- 记录编号FDR-2026-03-AGENT-TASK-QUERY
- 功能名称AI 随口问(任务查询)
- 记录日期2026-03-16
- 决策状态:已采纳
- 负责人:项目协作实现(你 + Codex
- 关联需求 / Issue
- 在同一个 `/agent/chat` 多合一入口下支持“自然语言任务查询”
- 不新增独立查询接口,复用现有 Agent 分流体系
## 2. 背景与问题
- 业务背景:用户在聊天中会提出大量“模糊查询”表达,例如“有啥优先级比较低的任务”“我现在很闲,有啥简单的任务可以做”。
- 现状问题:
- 纯 tool loop 方案在“表达模糊/口语化”场景下稳定性不足,容易出现答错或答不上来。
- 模型回复阶段容易与后端列表拼接冲突,出现“双重输出”。
- 用户显式数量要求如“来一个”“前3个”会在重试或放宽阶段被冲掉。
- 不做此决策的后果:
- 用户对 AI 查询能力感知不稳定;
- 对外展示时难以解释“为何这次能查到、下次查不到”;
- 代码长期演进会出现多条并行查询链路,维护成本升高。
## 3. 决策目标
- 目标 1在不新增 HTTP 接口的前提下,把“随口问任务查询”稳定接入现有 Agent 主链路。
- 目标 2兼顾性能与准确性形成 `2+x`(规划一次 + 反思最多两次)的可控模型调用上限。
- 目标 3保证输出可控条数、格式、可读性避免双重输出与数量失真。
- 非目标(明确本次不解决什么):
- 不引入语义映射词典(避免词典维护成本膨胀);
- 不做任务查询独立微服务拆分;
- 不做跨用户多租户复杂权限系统升级(沿用现有 user_id 注入隔离)。
## 4. 备选方案
### 方案 A纯 Tool Loop模型直接决定工具参数并循环
- 描述:进入 task_query 分支后直接走模型工具循环,直到无工具调用为止。
- 优点:实现快,代码量少。
- 缺点:对模糊语义不稳,容易“结果可用但回复不稳定”;难做节点化可观测。
- 复杂度 / 成本:低。
### 方案 B规则词典 + 工具查询
- 描述:用词典/关键词规则先映射象限与条件,再调用工具查询。
- 优点:可控性强,行为可预测。
- 缺点:语言表达覆盖困难,词典维护成本高,边界案例会持续增多。
- 复杂度 / 成本:中(初期可用,长期维护成本高)。
### 方案 C采纳Graph 编排 + 节点内工具调用 + 反思重试
- 描述:
- 分流命中 task_query 后进入 TaskQueryGraph
- 图内固定节点:`plan -> quadrant -> time_anchor -> tool_query -> reflect`
- `reflect` 决定是否重试,并产出 patch最多 2 次);
- 最终回复由后端确定性渲染列表,模型仅提供短语气前缀。
- 优点:
- 可读性和可观测性更好(节点化状态明确);
- 对模糊表达更稳(规划 + 反思 + 自动放宽);
- 输出格式可控,避免双重输出和条数漂移。
- 缺点:
- 实现复杂度高于纯 loop
- 需要严格控制重试上限与 prompt 约束,避免延迟膨胀。
- 复杂度 / 成本:中高。
## 5. 最终决策
- 采纳方案:方案 C。
- 关键理由:
- 在体验、准确性、可维护性三者之间取得最优平衡;
- 能与现有 quick_note 图范式保持一致,便于统一认知;
- 满足“默认多合一入口、不新增接口”的项目约束。
## 6. 影响范围
- 涉及模块:
- `backend/agent/route/route.go`(通用分流 action
- `backend/agent/taskquery/*`(图、节点、状态、工具、测试)
- `backend/service/agentsvc/agent.go`(分流调度)
- `backend/service/agentsvc/agent_task_query.go`(查询执行与数据筛选)
- 数据与存储影响:
- 不新增表结构;
- 读取沿用 `tasks` 表与现有字段;
- 会复用“读时紧急性派生”口径,不直接写库。
- 接口 / 协议影响:
- 对外仍是 `/agent/chat`OpenAI 兼容 chunk 协议不变。
- 监控与日志影响:
- 新增 task_query 阶段推送:`plan/quadrant/time_anchor/tool_query/reflect`
- 可按节点阶段观察链路耗时与重试频次。
## 7. 风险与应对
- 风险 1反思重试导致延迟增大。
- 应对策略:重试上限固定为 2首轮结果为空时仅自动放宽一次不无限扩散。
- 风险 2模型自由回复导致列表重复或条数不一致。
- 应对策略最终列表由后端确定性渲染LLM 回复仅提取短前缀,检测到列表迹象直接丢弃。
- 风险 3用户显式数量要求被后续补丁覆盖。
- 应对策略:显式数量单独入状态并加锁,反思 patch 与自动放宽均不得改写该值。
## 8. 验证与回滚
- 验证方式:
- 语义场景回归:模糊提问、显式数量、象限筛选、无结果场景。
- 自动化测试:数量提取、显式数量锁、去重输出校验。
- 全量编译测试:`go test ./...`
- 成功判定标准:
- 不再出现双重列表输出;
- “来一个/前3个/top 5”能稳定按数量返回
- 模糊提问在多数样本下能返回可执行结果。
- 回滚方案:
- 回退到上一版 task_query 执行器(纯 loop
- 保留 route 分流与 quick_note 不变,最小化影响面。
## 9. 里程碑与后续计划
- 里程碑 1完成 TaskQueryGraph 主链路接入(已完成)。
- 里程碑 2完成显式数量锁与防双重输出修复已完成
- 后续优化项:
- 增加节点级耗时日志plan/query/reflect用于性能压测
- 增加“更多结果翻页式追问”能力;
- 在前端阶段事件协议正式化后,去掉 reasoning 兼容层临时格式。
## 10. 复盘结论(上线后补充)
- 实际效果:待补充。
- 与预期偏差:待补充。
- 后续是否需要二次决策:待补充。