diff --git a/README.md b/README.md index a58a4a9..7dc3ac9 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,35 @@ $$Gap = \frac{TotalAvailableSlots - (TaskCount \times 2)}{TaskCount + 1}$$ ## 5.4 Agent范式实现细节 -### 1) 命中“添加日程/随口记”后的业务流转 +### 1) 总分流图(消息识别后的去向) + +```mermaid +flowchart TD + A["用户消息进入 AgentChat"] --> B["通用控制码分流
action=chat/quick_note_create/task_query"] + B --> C{"路由是否成功解析"} + + C -- 否 --> D["兜底普通聊天链路
StreamChat token流式输出"] + C -- 是 --> E{"action 类型"} + + E -- chat --> F["普通聊天链路
StreamChat token流式输出"] + + E -- quick_note_create --> G["随口记链路
单请求聚合规划 + 本地校验"] + G --> H["写库工具落库
task_id有效校验 + 失败重试"] + H --> I["一次性正文回复"] + + E -- task_query --> J["随口问链路
进入 TaskQueryGraph"] + J --> K["plan -> quadrant -> time_anchor"] + K --> L["tool_query 调用 query_tasks"] + L --> M["reflect 判断是否满足
不满足则 patch 重试(<=2)"] + M --> N["后端确定性渲染列表
严格按 limit 输出条数"] + + D --> Z["后置持久化
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[模型控制码路由
action=quick_note/chat] - B --> C{路由是否成功解析} - - C -- 是 --> D{action=quick_note?} - D -- 否 --> E[普通聊天链路
StreamChat token流式输出] - D -- 是 --> F[随口记快路径
跳过二次意图判定] - F --> G[单请求聚合规划
+本地时间校验/优先级兜底] - G --> H[写库工具落库
task_id有效校验] - H --> I[返回一次性正文] - - C -- 否 --> J[随口记兜底路径
恢复二次意图判定] - J --> K{是否随口记意图} - K -- 否 --> L[普通聊天链路
StreamChat token流式输出] - K -- 是 --> M[执行随口记写库链路
返回一次性正文] - - E --> Z[后置持久化
Redis + outbox/DB] - I --> Z - L --> Z - M --> Z + A["用户消息进入 /agent/chat"] --> B["通用控制码分流
action=chat/quick_note_create/task_query"] + B --> C{"action 是否为 task_query"} + C -- 否 --> D["走其它分支
普通聊天或随口记"] + C -- 是 --> E["进入 TaskQueryGraph"] + E --> F["节点1: plan
一次模型调用产出查询计划"] + F --> G["节点2: quadrant
归一化象限范围"] + G --> H["节点3: time_anchor
锁定时间过滤边界"] + H --> I["节点4: tool_query
调用 query_tasks 工具查询"] + I --> J{"首次结果是否为空"} + J -- 是 --> K["自动放宽一次
仅放宽关键词/完成状态/时间边界"] + K --> L["再次调用 query_tasks"] + J -- 否 --> M["进入反思节点"] + L --> M + M --> N["节点5: reflect
模型判断结果是否满足用户诉求"] + N --> O{"need_retry 且未超上限"} + O -- 是 --> P["应用 retry_patch
重试次数+1"] + P --> I + O -- 否 --> Q["后端确定性渲染最终回复
严格按 limit 输出条数"] + Q --> R["后置持久化
user+assistant 写 Redis + outbox/DB"] ``` # 6 前端实现 diff --git a/backend/agent/quicknote/prompt.go b/backend/agent/quicknote/prompt.go index 6226aa4..b69f117 100644 --- a/backend/agent/quicknote/prompt.go +++ b/backend/agent/quicknote/prompt.go @@ -36,7 +36,7 @@ const ( 必须完成以下五件事: 1) 提取任务标题 title(简洁明确)。 2) 归一化截止时间 deadline_at(若存在时间线索,必须输出绝对时间)。 -3) 评估紧急分界时间 urgency_threshold_at(何时从不紧急象限自动平移到紧急象限,可为空)。 +3) 评估紧急分界时间 urgency_threshold_at(当任务被判定为不紧急任务时才会触发:你需要评估何时从不紧急象限自动平移到紧急象限,不可为空)。 4) 评估优先级 priority_group(1~4)。 5) 生成一句轻松跟进句 banter(不超过30字)。 diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go index 0dfdfc3..2d708f8 100644 --- a/backend/agent/route/route.go +++ b/backend/agent/route/route.go @@ -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 ( - // 控制头格式: - // - routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`) - // 可选理由块: - // ... + // 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。 + +输出格式必须严格如下(两行): + +一句不超过30字的中文理由 + +禁止输出任何其他内容。` + +// 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) +} diff --git a/backend/agent/taskquery/graph.go b/backend/agent/taskquery/graph.go new file mode 100644 index 0000000..c70ca5b --- /dev/null +++ b/backend/agent/taskquery/graph.go @@ -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 +} diff --git a/backend/agent/taskquery/nodes.go b/backend/agent/taskquery/nodes.go new file mode 100644 index 0000000..40e7dcc --- /dev/null +++ b/backend/agent/taskquery/nodes.go @@ -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 +} diff --git a/backend/agent/taskquery/nodes_test.go b/backend/agent/taskquery/nodes_test.go new file mode 100644 index 0000000..53223c4 --- /dev/null +++ b/backend/agent/taskquery/nodes_test.go @@ -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) + } +} diff --git a/backend/agent/taskquery/prompt.go b/backend/agent/taskquery/prompt.go new file mode 100644 index 0000000..9145a36 --- /dev/null +++ b/backend/agent/taskquery/prompt.go @@ -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 中说明当前最接近结果。` +) diff --git a/backend/agent/taskquery/state.go b/backend/agent/taskquery/state.go new file mode 100644 index 0000000..a710dce --- /dev/null +++ b/backend/agent/taskquery/state.go @@ -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, + } +} diff --git a/backend/agent/taskquery/tool.go b/backend/agent/taskquery/tool.go new file mode 100644 index 0000000..8398124 --- /dev/null +++ b/backend/agent/taskquery/tool.go @@ -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") +} diff --git a/backend/agent/taskquery/tool_helpers.go b/backend/agent/taskquery/tool_helpers.go new file mode 100644 index 0000000..fb636ca --- /dev/null +++ b/backend/agent/taskquery/tool_helpers.go @@ -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 +} diff --git a/backend/agent/taskquery/tool_test.go b/backend/agent/taskquery/tool_test.go new file mode 100644 index 0000000..e03c37e --- /dev/null +++ b/backend/agent/taskquery/tool_test.go @@ -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) + } +} diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index aea792e..d34bc31 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -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 先走“通用控制码路由”决定 action(chat / 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) }() diff --git a/backend/service/agentsvc/agent_quick_note_route_test.go b/backend/service/agentsvc/agent_quick_note_route_test.go index 9691f81..e3b74a9 100644 --- a/backend/service/agentsvc/agent_quick_note_route_test.go +++ b/backend/service/agentsvc/agent_quick_note_route_test.go @@ -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 := ` +用户在查最紧急任务` + + 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) { diff --git a/backend/service/agentsvc/agent_route.go b/backend/service/agentsvc/agent_route.go new file mode 100644 index 0000000..a575436 --- /dev/null +++ b/backend/service/agentsvc/agent_route.go @@ -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) +} diff --git a/backend/service/agentsvc/agent_task_query.go b/backend/service/agentsvc/agent_task_query.go new file mode 100644 index 0000000..d1d660f --- /dev/null +++ b/backend/service/agentsvc/agent_task_query.go @@ -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(¤tTask, 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. less:left 是否应排在 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 +} diff --git a/docs/功能决策记录/AI随口问_决策记录.md b/docs/功能决策记录/AI随口问_决策记录.md new file mode 100644 index 0000000..19107cc --- /dev/null +++ b/docs/功能决策记录/AI随口问_决策记录.md @@ -0,0 +1,117 @@ +# 功能决策记录(FDR):AI随口问(任务查询) + +## 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. 复盘结论(上线后补充) +- 实际效果:待补充。 +- 与预期偏差:待补充。 +- 后续是否需要二次决策:待补充。 +