diff --git a/backend/agent/README.md b/backend/agent/README.md
index f6e28a1..440fad3 100644
--- a/backend/agent/README.md
+++ b/backend/agent/README.md
@@ -1,31 +1,23 @@
-# backend/agent 目录说明
+# backend/agent 目录说明
-该目录当前按“聊天流式输出能力”和“可编排的随口记能力”拆分:
+该目录已按“路由 / 聊天 / 随口记”三层拆分,便于阅读、调试与扩展:
-1. `graph.go`
-- 仅负责现有流式聊天输出封装(SSE/OpenAI 兼容 chunk 转换)。
-- 已有线上链路依赖,当前不改业务逻辑。
+1. `route/`
+- `route.go`:只负责模型控制码分流(`quick_note` / `chat`)。
+- 提供控制码解析、nonce 校验、路由兜底,不参与写库与回复拼装。
-2. `prompt.go`
-- 通用 Agent 提示词。
+2. `chat/`
+- `stream.go`:普通聊天流式输出封装(SSE/OpenAI 兼容 chunk 转换)。
+- `prompt.go`:聊天主系统提示词。
-3. `quick_note_prompt.go`
-- AI 随口记专用提示词(意图识别、优先级评估)。
+3. `quicknote/`
+- `graph.go`:只负责图编排连线与分支,不承载节点内部实现。
+- `nodes.go`:节点实现(意图识别、优先级评估、持久化、分支选择)。
+- `tool.go`:工具定义、参数校验、deadline 解析、写库工具打包。
+- `state.go`:随口记状态容器与重试状态记录。
+- `prompt.go`:随口记提示词(控制码路由、聚合规划、优先级评估、回复润色)。
-4. `state.go`
-- 随口记链路状态结构(意图标记、抽取结果、重试计数、持久化结果)。
+4. `README.md`(当前文件)
+- 记录目录职责边界,帮助后续继续按同样范式扩展 `query/update` 等技能链路。
-5. `tool.go`
-- 随口记工具打包入口:
- - `BuildQuickNoteToolBundle`
- - 工具输入输出 schema
- - deadline 解析与优先级校验
-
-6. `quick_note_graph.go`
-- 随口记 graph 编排实现:
- - 节点1:意图识别
- - 节点2:优先级评估
- - 节点3:调用写库工具
- - 分支:失败自动重试(最多 3 次)
-
-> 说明:服务层通过 `RunQuickNoteGraph` 调用该图;若判定为非随口记意图,会自动回落到原有普通流式聊天逻辑。
+> 说明:服务层仍通过 `RunQuickNoteGraph` 调用随口记图;若判定为非随口记意图,会自动回落到普通流式聊天链路。
diff --git a/backend/agent/prompt.go b/backend/agent/chat/prompt.go
similarity index 99%
rename from backend/agent/prompt.go
rename to backend/agent/chat/prompt.go
index 2eb0f26..67dce94 100644
--- a/backend/agent/prompt.go
+++ b/backend/agent/chat/prompt.go
@@ -1,4 +1,4 @@
-package agent
+package chat
const (
// SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性
diff --git a/backend/agent/graph.go b/backend/agent/chat/stream.go
similarity index 99%
rename from backend/agent/graph.go
rename to backend/agent/chat/stream.go
index 245ebd9..4c2093d 100644
--- a/backend/agent/graph.go
+++ b/backend/agent/chat/stream.go
@@ -1,4 +1,4 @@
-package agent
+package chat
import (
"context"
diff --git a/backend/agent/quick_note_graph.go b/backend/agent/quick_note_graph.go
deleted file mode 100644
index b6075f4..0000000
--- a/backend/agent/quick_note_graph.go
+++ /dev/null
@@ -1,685 +0,0 @@
-package agent
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/cloudwego/eino-ext/components/model/ark"
- einoModel "github.com/cloudwego/eino/components/model"
- "github.com/cloudwego/eino/components/tool"
- "github.com/cloudwego/eino/compose"
- "github.com/cloudwego/eino/schema"
- arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
-)
-
-const (
- quickNoteGraphNodeIntent = "quick_note_intent"
- quickNoteGraphNodeRank = "quick_note_priority"
- quickNoteGraphNodePersist = "quick_note_persist"
- quickNoteGraphNodeExit = "quick_note_exit"
-)
-
-type quickNoteIntentModelOutput struct {
- IsQuickNote bool `json:"is_quick_note"`
- Title string `json:"title"`
- DeadlineAt string `json:"deadline_at"`
- Reason string `json:"reason"`
-}
-
-type quickNotePriorityModelOutput struct {
- PriorityGroup int `json:"priority_group"`
- Reason string `json:"reason"`
-}
-
-// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。
-// 说明:
-// - 路由命中 quick_note 时,尽量通过这一份结果覆盖“时间/优先级/润色”三步;
-// - 任一字段异常不应阻断主链路,后续会有本地兜底与校验。
-type quickNotePlanModelOutput struct {
- Title string `json:"title"`
- DeadlineAt string `json:"deadline_at"`
- PriorityGroup int `json:"priority_group"`
- PriorityReason string `json:"priority_reason"`
- Banter string `json:"banter"`
-}
-
-// QuickNoteGraphRunInput 是运行“随口记 graph”所需的输入依赖。
-// 说明:
-// - EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块);
-// - 不传 EmitStage 时,图逻辑保持静默执行。
-type QuickNoteGraphRunInput struct {
- Model *ark.ChatModel
- State *QuickNoteState
- Deps QuickNoteToolDeps
-
- // SkipIntentVerification=true 时,跳过“意图识别二次模型判定”:
- // - 适用于上游路由已明确给出 quick_note 的场景;
- // - 可减少一次模型调用,降低首包前等待;
- // - 仍保留时间合法性校验与写库成功校验,避免脏数据与假成功。
- SkipIntentVerification bool
-
- EmitStage func(stage, detail string)
-}
-
-// RunQuickNoteGraph 执行“随口记”图编排。
-// 设计目标:
-// 1) 意图识别和信息抽取与写库解耦;
-// 2) 发生模型抖动或工具失败时,具备可控降级和重试;
-// 3) 时间解析严格可控,避免把非法日期静默写成 NULL。
-func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) {
- if input.Model == nil {
- return nil, errors.New("quick note graph: model is nil")
- }
- if input.State == nil {
- return nil, errors.New("quick note graph: state is nil")
- }
- if err := input.Deps.validate(); err != nil {
- return nil, err
- }
-
- emitStage := func(stage, detail string) {
- if input.EmitStage != nil {
- input.EmitStage(stage, detail)
- }
- }
-
- // 统一初始化“当前时间基准”:
- // - RequestNow 用于相对时间解析;
- // - RequestNowText 用于拼接到提示词,让模型知道“现在是几点”。
- if input.State.RequestNow.IsZero() {
- input.State.RequestNow = quickNoteNowToMinute()
- }
- if strings.TrimSpace(input.State.RequestNowText) == "" {
- input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow)
- }
-
- toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps)
- if err != nil {
- return nil, err
- }
- createTaskTool, err := getInvokableToolByName(toolBundle, ToolNameQuickNoteCreateTask)
- if err != nil {
- return nil, err
- }
-
- graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]()
-
- // 节点1:意图识别与信息抽取。
- if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda(
- func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
- if st == nil {
- return nil, errors.New("quick note graph: nil state in intent node")
- }
-
- if input.SkipIntentVerification {
- emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
- st.IsQuickNoteIntent = true
- st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定"
- st.PlannedBySingleCall = true
-
- emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
- plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
- if planErr != nil {
- st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
- } else {
- if strings.TrimSpace(plan.Title) != "" {
- st.ExtractedTitle = strings.TrimSpace(plan.Title)
- }
- if plan.Deadline != nil {
- st.ExtractedDeadline = plan.Deadline
- }
- st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
- if IsValidTaskPriority(plan.PriorityGroup) {
- st.ExtractedPriority = plan.PriorityGroup
- st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
- }
- st.ExtractedBanter = strings.TrimSpace(plan.Banter)
- }
-
- if strings.TrimSpace(st.ExtractedTitle) == "" {
- st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
- }
-
- emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
-
- // 仍以“用户原句”的本地解析与校验作为硬约束,防止模型给出非法时间。
- userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
- if userHasTimeHint && userDeadlineErr != nil {
- st.DeadlineValidationError = userDeadlineErr.Error()
- st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。"
- emitStage("quick_note.failed", "时间校验失败,未执行写入。")
- return st, nil
- }
- if userDeadline != nil {
- st.ExtractedDeadline = userDeadline
- st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
- }
- return st, nil
- }
-
- emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
-
- prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
-用户输入:%s
-请仅输出 JSON(不要 markdown,不要解释),字段如下:
-{
- "is_quick_note": boolean,
- "title": string,
- "deadline_at": string,
- "reason": string
-}
-字段约束:
-1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。
-2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。
-3) 如果用户没有提及时间,deadline_at 输出空字符串。`,
- st.RequestNowText,
- st.UserInput,
- )
- raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt)
- if callErr != nil {
- st.IsQuickNoteIntent = false
- st.IntentJudgeReason = "意图识别失败,回退普通聊天"
- return st, nil
- }
-
- parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw)
- if parseErr != nil {
- st.IsQuickNoteIntent = false
- st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天"
- return st, nil
- }
-
- st.IsQuickNoteIntent = parsed.IsQuickNote
- st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
- if !st.IsQuickNoteIntent {
- return st, nil
- }
-
- title := strings.TrimSpace(parsed.Title)
- if title == "" {
- title = strings.TrimSpace(st.UserInput)
- }
- st.ExtractedTitle = title
-
- emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
-
- // Step A:优先尝试解析模型抽取出来的 deadline。
- st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
- if st.ExtractedDeadlineText != "" {
- if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
- st.ExtractedDeadline = deadline
- }
- }
-
- // Step B:基于用户原句执行“本地时间解析 + 合法性校验”。
- userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
- if userHasTimeHint && userDeadlineErr != nil {
- st.DeadlineValidationError = userDeadlineErr.Error()
- st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。"
- emitStage("quick_note.failed", "时间校验失败,未执行写入。")
- return st, nil
- }
-
- if st.ExtractedDeadline == nil && userDeadline != nil {
- st.ExtractedDeadline = userDeadline
- if st.ExtractedDeadlineText == "" {
- st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
- }
- }
- return st, nil
- })); err != nil {
- return nil, err
- }
-
- // 节点2:优先级评估。
- if err = graph.AddLambdaNode(quickNoteGraphNodeRank, compose.InvokableLambda(
- func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
- if st == nil {
- return nil, errors.New("quick note graph: nil state in priority node")
- }
- if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
- return st, nil
- }
-
- // 命中“单请求聚合规划”时,优先复用其优先级结果,避免重复模型调用。
- if IsValidTaskPriority(st.ExtractedPriority) {
- if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
- st.ExtractedPriorityReason = "复用聚合规划优先级"
- }
- emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
- return st, nil
- }
- if input.SkipIntentVerification || st.PlannedBySingleCall {
- st.ExtractedPriority = fallbackPriority(st)
- st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
- emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
- return st, nil
- }
-
- emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
-
- deadlineText := "无"
- if st.ExtractedDeadline != nil {
- deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
- }
- deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
- if deadlineClue == "" {
- deadlineClue = "无"
- }
-
- prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
-请对以下任务评估优先级:
-- 任务标题:%s
-- 用户原始输入:%s
-- 时间线索原文:%s
-- 归一化截止时间:%s
-
-请仅输出 JSON(不要 markdown,不要解释):
-{
- "priority_group": 1|2|3|4,
- "reason": "简短理由"
-}`,
- st.RequestNowText,
- st.ExtractedTitle,
- st.UserInput,
- deadlineClue,
- deadlineText,
- )
-
- raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt)
- if callErr != nil {
- fallback := fallbackPriority(st)
- st.ExtractedPriority = fallback
- st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
- return st, nil
- }
-
- parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw)
- if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) {
- fallback := fallbackPriority(st)
- st.ExtractedPriority = fallback
- st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
- return st, nil
- }
-
- st.ExtractedPriority = parsed.PriorityGroup
- st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
- return st, nil
- })); err != nil {
- return nil, err
- }
-
- // 节点3:调用“写库工具”执行持久化。
- if err = graph.AddLambdaNode(quickNoteGraphNodePersist, compose.InvokableLambda(
- func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
- if st == nil {
- return nil, errors.New("quick note graph: nil state in persist node")
- }
- if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
- return st, nil
- }
-
- emitStage("quick_note.persisting", "正在写入任务数据。")
-
- priority := st.ExtractedPriority
- if !IsValidTaskPriority(priority) {
- priority = fallbackPriority(st)
- st.ExtractedPriority = priority
- }
-
- deadlineText := ""
- if st.ExtractedDeadline != nil {
- deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
- }
-
- toolInput := QuickNoteCreateTaskToolInput{
- Title: st.ExtractedTitle,
- PriorityGroup: priority,
- DeadlineAt: deadlineText,
- }
- rawInput, marshalErr := json.Marshal(toolInput)
- if marshalErr != nil {
- st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
- if !st.CanRetryTool() {
- st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
- emitStage("quick_note.failed", "参数构造失败,未完成写入。")
- }
- return st, nil
- }
-
- rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput))
- if invokeErr != nil {
- st.RecordToolError(invokeErr.Error())
- if !st.CanRetryTool() {
- st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
- emitStage("quick_note.failed", "多次重试后仍未完成写入。")
- }
- return st, nil
- }
-
- toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput)
- if parseErr != nil {
- st.RecordToolError("解析工具返回失败: " + parseErr.Error())
- if !st.CanRetryTool() {
- st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
- emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
- }
- return st, nil
- }
-
- // 成功判定加硬门槛:必须拿到有效 task_id。
- // 目的:
- // 1) 防止工具返回结构异常时被误判为“写入成功”;
- // 2) 避免出现“回复已安排,但数据库实际没记录”的错误体验;
- // 3) 命中该分支时会走既有重试策略,重试耗尽后明确报错给用户。
- if toolOutput.TaskID <= 0 {
- st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
- if !st.CanRetryTool() {
- st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
- emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。")
- }
- return st, nil
- }
-
- st.RecordToolSuccess(toolOutput.TaskID)
- if strings.TrimSpace(toolOutput.Title) != "" {
- st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
- }
- if IsValidTaskPriority(toolOutput.PriorityGroup) {
- st.ExtractedPriority = toolOutput.PriorityGroup
- }
- reply := strings.TrimSpace(toolOutput.Message)
- if reply == "" {
- reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority))
- }
- st.AssistantReply = reply
- emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
- return st, nil
- })); err != nil {
- return nil, err
- }
-
- if err = graph.AddLambdaNode(quickNoteGraphNodeExit, compose.InvokableLambda(
- func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
- return st, nil
- })); err != nil {
- return nil, err
- }
-
- if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil {
- return nil, err
- }
- if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch(
- func(ctx context.Context, st *QuickNoteState) (string, error) {
- if st == nil || !st.IsQuickNoteIntent {
- return quickNoteGraphNodeExit, nil
- }
- if strings.TrimSpace(st.DeadlineValidationError) != "" {
- return quickNoteGraphNodeExit, nil
- }
- return quickNoteGraphNodeRank, nil
- },
- map[string]bool{quickNoteGraphNodeRank: true, quickNoteGraphNodeExit: true},
- )); err != nil {
- return nil, err
- }
- if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil {
- return nil, err
- }
- if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil {
- return nil, err
- }
- if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch(
- func(ctx context.Context, st *QuickNoteState) (string, error) {
- if st == nil {
- return compose.END, nil
- }
- if st.Persisted {
- return compose.END, nil
- }
- if st.CanRetryTool() {
- return quickNoteGraphNodePersist, nil
- }
- if strings.TrimSpace(st.AssistantReply) == "" {
- st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
- }
- return compose.END, nil
- },
- map[string]bool{quickNoteGraphNodePersist: true, compose.END: true},
- )); err != nil {
- return nil, err
- }
-
- maxSteps := input.State.MaxToolRetry + 10
- if maxSteps < 12 {
- maxSteps = 12
- }
-
- runnable, err := graph.Compile(ctx,
- compose.WithGraphName("QuickNoteGraph"),
- compose.WithMaxRunSteps(maxSteps),
- compose.WithNodeTriggerMode(compose.AnyPredecessor),
- )
- if err != nil {
- return nil, err
- }
-
- return runnable.Invoke(ctx, input.State)
-}
-
-func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
- if bundle == nil {
- return nil, errors.New("tool bundle is nil")
- }
- if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
- return nil, errors.New("tool bundle is empty")
- }
- for idx, info := range bundle.ToolInfos {
- if info == nil || info.Name != name {
- continue
- }
- invokable, ok := bundle.Tools[idx].(tool.InvokableTool)
- if !ok {
- return nil, fmt.Errorf("tool %s is not invokable", name)
- }
- return invokable, nil
- }
- return nil, fmt.Errorf("tool %s not found", name)
-}
-
-func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
- return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256)
-}
-
-func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
- 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 := chatModel.Generate(ctx, messages, opts...)
- if err != nil {
- return "", err
- }
- if resp == nil {
- return "", errors.New("模型返回为空")
- }
- content := strings.TrimSpace(resp.Content)
- if content == "" {
- return "", errors.New("模型返回内容为空")
- }
- return content, nil
-}
-
-type quickNotePlannedResult struct {
- Title string
- Deadline *time.Time
- DeadlineText string
- PriorityGroup int
- PriorityReason string
- Banter string
-}
-
-// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。
-// 设计原则:
-// 1) 路由已命中 quick_note 时优先走该函数,减少串行模型调用;
-// 2) 输出字段解析失败时返回 error,让上层回退到本地/后续节点兜底;
-// 3) 对 banter 做轻量清洗,避免多行输出污染最终回复。
-func planQuickNoteInSingleCall(
- ctx context.Context,
- chatModel *ark.ChatModel,
- nowText string,
- now time.Time,
- userInput string,
-) (*quickNotePlannedResult, error) {
- prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
-用户输入:%s
-
-请仅输出 JSON(不要 markdown,不要解释),字段如下:
-{
- "title": string,
- "deadline_at": string,
- "priority_group": 1|2|3|4,
- "priority_reason": string,
- "banter": string
-}
-
-约束:
-1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
-2) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
-3) banter 只允许一句中文,不超过30字,不得改动任务事实。`,
- nowText,
- strings.TrimSpace(userInput),
- )
-
- raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220)
- if err != nil {
- return nil, err
- }
- parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw)
- if parseErr != nil {
- return nil, parseErr
- }
-
- result := &quickNotePlannedResult{
- Title: strings.TrimSpace(parsed.Title),
- DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
- PriorityGroup: parsed.PriorityGroup,
- PriorityReason: strings.TrimSpace(parsed.PriorityReason),
- Banter: strings.TrimSpace(parsed.Banter),
- }
-
- if result.Banter != "" {
- if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
- result.Banter = strings.TrimSpace(result.Banter[:idx])
- }
- }
-
- if result.DeadlineText != "" {
- if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
- result.Deadline = deadline
- }
- }
- return result, nil
-}
-
-func parseJSONPayload[T any](raw string) (*T, error) {
- clean := strings.TrimSpace(raw)
- if clean == "" {
- return nil, errors.New("empty response")
- }
-
- if strings.HasPrefix(clean, "```") {
- clean = strings.TrimPrefix(clean, "```json")
- clean = strings.TrimPrefix(clean, "```")
- clean = strings.TrimSuffix(clean, "```")
- clean = strings.TrimSpace(clean)
- }
-
- var out T
- if err := json.Unmarshal([]byte(clean), &out); err == nil {
- return &out, nil
- }
-
- obj := extractJSONObject(clean)
- if obj == "" {
- return nil, fmt.Errorf("no json object found in: %s", clean)
- }
- if err := json.Unmarshal([]byte(obj), &out); err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func extractJSONObject(text string) string {
- start := strings.Index(text, "{")
- end := strings.LastIndex(text, "}")
- if start == -1 || end == -1 || end <= start {
- return ""
- }
- return text[start : end+1]
-}
-
-func fallbackPriority(st *QuickNoteState) int {
- if st == nil {
- return QuickNotePrioritySimpleNotImportant
- }
- if st.ExtractedDeadline != nil {
- if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
- return QuickNotePriorityImportantUrgent
- }
- return QuickNotePriorityImportantNotUrgent
- }
- return QuickNotePrioritySimpleNotImportant
-}
-
-// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
-// 设计原则:
-// 1) 不依赖模型,避免再引入一次额外 LLM 调用;
-// 2) 优先保守提取,宁可稍长,也不要误删关键信息;
-// 3) 只做轻量清洗,不做复杂语义改写,保持可预期。
-func deriveQuickNoteTitleFromInput(userInput string) string {
- text := strings.TrimSpace(userInput)
- if text == "" {
- return "这条任务"
- }
-
- // 去掉常见前缀口头语,保留核心任务描述。
- prefixes := []string{
- "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下",
- }
- for _, prefix := range prefixes {
- if strings.HasPrefix(text, prefix) {
- text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
- break
- }
- }
-
- // 去掉常见尾部提醒口头语,避免把“记得喊我/q我”也写入标题。
- suffixSeparators := []string{
- ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
- }
- for _, sep := range suffixSeparators {
- if idx := strings.Index(text, sep); idx > 0 {
- text = strings.TrimSpace(text[:idx])
- break
- }
- }
-
- text = strings.Trim(text, ",,。.!!?;; ")
- if text == "" {
- return strings.TrimSpace(userInput)
- }
- return text
-}
diff --git a/backend/agent/quicknote/graph.go b/backend/agent/quicknote/graph.go
new file mode 100644
index 0000000..2186c86
--- /dev/null
+++ b/backend/agent/quicknote/graph.go
@@ -0,0 +1,144 @@
+package quicknote
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/cloudwego/eino-ext/components/model/ark"
+ "github.com/cloudwego/eino/compose"
+)
+
+const (
+ // 图节点:意图识别(含聚合规划与时间校验)
+ quickNoteGraphNodeIntent = "quick_note_intent"
+ // 图节点:优先级评估(或本地兜底)
+ quickNoteGraphNodeRank = "quick_note_priority"
+ // 图节点:持久化(调用写库工具)
+ quickNoteGraphNodePersist = "quick_note_persist"
+ // 图节点:退出(用于非随口记/校验失败分支)
+ quickNoteGraphNodeExit = "quick_note_exit"
+)
+
+// QuickNoteGraphRunInput 是运行“随口记 graph”所需的输入依赖。
+// 说明:
+// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块);
+// 2) 不传 EmitStage 时,图逻辑保持静默执行;
+// 3) SkipIntentVerification=true 时,表示上游路由已信任 quick_note,可跳过二次意图判定。
+type QuickNoteGraphRunInput struct {
+ Model *ark.ChatModel
+ State *QuickNoteState
+ Deps QuickNoteToolDeps
+
+ SkipIntentVerification bool
+ EmitStage func(stage, detail string)
+}
+
+// RunQuickNoteGraph 执行“随口记”图编排。
+// 该文件只负责“连线与分支”,节点内部逻辑全部下沉到 nodes.go。
+func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) {
+ if input.Model == nil {
+ return nil, errors.New("quick note graph: model is nil")
+ }
+ if input.State == nil {
+ return nil, errors.New("quick note graph: state is nil")
+ }
+ if err := input.Deps.validate(); err != nil {
+ return nil, err
+ }
+
+ emitStage := func(stage, detail string) {
+ if input.EmitStage != nil {
+ input.EmitStage(stage, detail)
+ }
+ }
+
+ // 统一初始化“当前时间基准”,避免同一请求内相对时间口径漂移。
+ if input.State.RequestNow.IsZero() {
+ input.State.RequestNow = quickNoteNowToMinute()
+ }
+ if strings.TrimSpace(input.State.RequestNowText) == "" {
+ input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow)
+ }
+
+ toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps)
+ if err != nil {
+ return nil, err
+ }
+ createTaskTool, err := getInvokableToolByName(toolBundle, ToolNameQuickNoteCreateTask)
+ if err != nil {
+ return nil, err
+ }
+ runner := newQuickNoteRunner(input, createTaskTool, emitStage)
+
+ graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]()
+
+ if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda(runner.intentNode)); err != nil {
+ return nil, err
+ }
+
+ if err = graph.AddLambdaNode(quickNoteGraphNodeRank, compose.InvokableLambda(runner.priorityNode)); err != nil {
+ return nil, err
+ }
+
+ if err = graph.AddLambdaNode(quickNoteGraphNodePersist, compose.InvokableLambda(runner.persistNode)); err != nil {
+ return nil, err
+ }
+
+ if err = graph.AddLambdaNode(quickNoteGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil {
+ return nil, err
+ }
+
+ // 连线:START -> intent
+ if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil {
+ return nil, err
+ }
+
+ // 分支:intent 后决定去 priority 还是 exit。
+ if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch(
+ runner.nextAfterIntent,
+ map[string]bool{
+ quickNoteGraphNodeRank: true,
+ quickNoteGraphNodeExit: true,
+ },
+ )); err != nil {
+ return nil, err
+ }
+
+ // exit 直接结束。
+ if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil {
+ return nil, err
+ }
+
+ // priority -> persist。
+ if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil {
+ return nil, err
+ }
+
+ // persist 后决定“重试 persist”还是结束。
+ if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch(
+ runner.nextAfterPersist,
+ map[string]bool{
+ quickNoteGraphNodePersist: true,
+ compose.END: true,
+ },
+ )); err != nil {
+ return nil, err
+ }
+
+ maxSteps := input.State.MaxToolRetry + 10
+ if maxSteps < 12 {
+ maxSteps = 12
+ }
+
+ runnable, err := graph.Compile(ctx,
+ compose.WithGraphName("QuickNoteGraph"),
+ compose.WithMaxRunSteps(maxSteps),
+ compose.WithNodeTriggerMode(compose.AnyPredecessor),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return runnable.Invoke(ctx, input.State)
+}
diff --git a/backend/agent/quicknote/nodes.go b/backend/agent/quicknote/nodes.go
new file mode 100644
index 0000000..d119898
--- /dev/null
+++ b/backend/agent/quicknote/nodes.go
@@ -0,0 +1,554 @@
+package quicknote
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/cloudwego/eino-ext/components/model/ark"
+ einoModel "github.com/cloudwego/eino/components/model"
+ "github.com/cloudwego/eino/components/tool"
+ "github.com/cloudwego/eino/compose"
+ "github.com/cloudwego/eino/schema"
+ arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
+)
+
+type quickNoteIntentModelOutput struct {
+ IsQuickNote bool `json:"is_quick_note"`
+ Title string `json:"title"`
+ DeadlineAt string `json:"deadline_at"`
+ Reason string `json:"reason"`
+}
+
+type quickNotePriorityModelOutput struct {
+ PriorityGroup int `json:"priority_group"`
+ Reason string `json:"reason"`
+}
+
+// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。
+type quickNotePlanModelOutput struct {
+ Title string `json:"title"`
+ DeadlineAt string `json:"deadline_at"`
+ PriorityGroup int `json:"priority_group"`
+ PriorityReason string `json:"priority_reason"`
+ Banter string `json:"banter"`
+}
+
+// runQuickNoteIntentNode 负责“意图识别 + 聚合规划 + 时间校验”。
+// 说明:
+// 1) trustRoute 命中时,直接走单请求聚合规划,跳过二次意图识别;
+// 2) 无论是否走快路径,最终都要走本地时间硬校验,防止脏时间落库。
+func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
+ if st == nil {
+ return nil, errors.New("quick note graph: nil state in intent node")
+ }
+
+ if input.SkipIntentVerification {
+ emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
+ st.IsQuickNoteIntent = true
+ st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定"
+ st.PlannedBySingleCall = true
+
+ emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
+ plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
+ if planErr != nil {
+ st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
+ } else {
+ if strings.TrimSpace(plan.Title) != "" {
+ st.ExtractedTitle = strings.TrimSpace(plan.Title)
+ }
+ if plan.Deadline != nil {
+ st.ExtractedDeadline = plan.Deadline
+ }
+ st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText)
+ if IsValidTaskPriority(plan.PriorityGroup) {
+ st.ExtractedPriority = plan.PriorityGroup
+ st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
+ }
+ st.ExtractedBanter = strings.TrimSpace(plan.Banter)
+ }
+
+ if strings.TrimSpace(st.ExtractedTitle) == "" {
+ st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
+ }
+
+ emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
+ userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
+ if userHasTimeHint && userDeadlineErr != nil {
+ st.DeadlineValidationError = userDeadlineErr.Error()
+ st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。"
+ emitStage("quick_note.failed", "时间校验失败,未执行写入。")
+ return st, nil
+ }
+ if userDeadline != nil {
+ st.ExtractedDeadline = userDeadline
+ st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
+ }
+ return st, nil
+ }
+
+ emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
+ prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
+用户输入:%s
+请仅输出 JSON(不要 markdown,不要解释),字段如下:
+{
+ "is_quick_note": boolean,
+ "title": string,
+ "deadline_at": string,
+ "reason": string
+}
+字段约束:
+1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。
+2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。
+3) 如果用户没有提及时间,deadline_at 输出空字符串。`,
+ st.RequestNowText,
+ st.UserInput,
+ )
+
+ raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt)
+ if callErr != nil {
+ st.IsQuickNoteIntent = false
+ st.IntentJudgeReason = "意图识别失败,回退普通聊天"
+ return st, nil
+ }
+
+ parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw)
+ if parseErr != nil {
+ st.IsQuickNoteIntent = false
+ st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天"
+ return st, nil
+ }
+
+ st.IsQuickNoteIntent = parsed.IsQuickNote
+ st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
+ if !st.IsQuickNoteIntent {
+ return st, nil
+ }
+
+ title := strings.TrimSpace(parsed.Title)
+ if title == "" {
+ title = strings.TrimSpace(st.UserInput)
+ }
+ st.ExtractedTitle = title
+
+ emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
+
+ // Step A:优先尝试解析模型抽取出来的 deadline。
+ st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
+ if st.ExtractedDeadlineText != "" {
+ if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
+ st.ExtractedDeadline = deadline
+ }
+ }
+
+ // Step B:基于用户原句执行“本地时间解析 + 合法性校验”。
+ userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
+ if userHasTimeHint && userDeadlineErr != nil {
+ st.DeadlineValidationError = userDeadlineErr.Error()
+ st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。"
+ emitStage("quick_note.failed", "时间校验失败,未执行写入。")
+ return st, nil
+ }
+
+ if st.ExtractedDeadline == nil && userDeadline != nil {
+ st.ExtractedDeadline = userDeadline
+ if st.ExtractedDeadlineText == "" {
+ st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
+ }
+ }
+ return st, nil
+}
+
+// runQuickNotePriorityNode 负责“优先级评估”。
+// 说明:
+// 1) 聚合规划已给出合法优先级时直接复用;
+// 2) 快路径下缺失优先级时直接本地兜底,避免额外模型调用;
+// 3) 其余场景走独立评估模型,失败再兜底。
+func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
+ if st == nil {
+ return nil, errors.New("quick note graph: nil state in priority node")
+ }
+ if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
+ return st, nil
+ }
+
+ if IsValidTaskPriority(st.ExtractedPriority) {
+ if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
+ st.ExtractedPriorityReason = "复用聚合规划优先级"
+ }
+ emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
+ return st, nil
+ }
+ if input.SkipIntentVerification || st.PlannedBySingleCall {
+ st.ExtractedPriority = fallbackPriority(st)
+ st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
+ emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
+ return st, nil
+ }
+
+ emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
+ deadlineText := "无"
+ if st.ExtractedDeadline != nil {
+ deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
+ }
+ deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
+ if deadlineClue == "" {
+ deadlineClue = "无"
+ }
+
+ prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
+请对以下任务评估优先级:
+- 任务标题:%s
+- 用户原始输入:%s
+- 时间线索原文:%s
+- 归一化截止时间:%s
+
+请仅输出 JSON(不要 markdown,不要解释):
+{
+ "priority_group": 1|2|3|4,
+ "reason": "简短理由"
+}`,
+ st.RequestNowText,
+ st.ExtractedTitle,
+ st.UserInput,
+ deadlineClue,
+ deadlineText,
+ )
+
+ raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt)
+ if callErr != nil {
+ st.ExtractedPriority = fallbackPriority(st)
+ st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
+ return st, nil
+ }
+
+ parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw)
+ if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) {
+ st.ExtractedPriority = fallbackPriority(st)
+ st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
+ return st, nil
+ }
+
+ st.ExtractedPriority = parsed.PriorityGroup
+ st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
+ return st, nil
+}
+
+// runQuickNotePersistNodeInternal 负责“写库工具调用 + 重试态回填”。
+func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, createTaskTool tool.InvokableTool, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
+ _ = input // 保留参数形状,后续若需要基于输入开关扩展可直接使用。
+
+ if st == nil {
+ return nil, errors.New("quick note graph: nil state in persist node")
+ }
+ if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
+ return st, nil
+ }
+
+ emitStage("quick_note.persisting", "正在写入任务数据。")
+ priority := st.ExtractedPriority
+ if !IsValidTaskPriority(priority) {
+ priority = fallbackPriority(st)
+ st.ExtractedPriority = priority
+ }
+
+ deadlineText := ""
+ if st.ExtractedDeadline != nil {
+ deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
+ }
+
+ toolInput := QuickNoteCreateTaskToolInput{
+ Title: st.ExtractedTitle,
+ PriorityGroup: priority,
+ DeadlineAt: deadlineText,
+ }
+ rawInput, marshalErr := json.Marshal(toolInput)
+ if marshalErr != nil {
+ st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
+ if !st.CanRetryTool() {
+ st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
+ emitStage("quick_note.failed", "参数构造失败,未完成写入。")
+ }
+ return st, nil
+ }
+
+ rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput))
+ if invokeErr != nil {
+ st.RecordToolError(invokeErr.Error())
+ if !st.CanRetryTool() {
+ st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
+ emitStage("quick_note.failed", "多次重试后仍未完成写入。")
+ }
+ return st, nil
+ }
+
+ toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput)
+ if parseErr != nil {
+ st.RecordToolError("解析工具返回失败: " + parseErr.Error())
+ if !st.CanRetryTool() {
+ st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
+ emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
+ }
+ return st, nil
+ }
+
+ // 成功判定硬门槛:必须拿到有效 task_id,防止“假成功”。
+ if toolOutput.TaskID <= 0 {
+ st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
+ if !st.CanRetryTool() {
+ st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
+ emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。")
+ }
+ return st, nil
+ }
+
+ st.RecordToolSuccess(toolOutput.TaskID)
+ if strings.TrimSpace(toolOutput.Title) != "" {
+ st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
+ }
+ if IsValidTaskPriority(toolOutput.PriorityGroup) {
+ st.ExtractedPriority = toolOutput.PriorityGroup
+ }
+
+ reply := strings.TrimSpace(toolOutput.Message)
+ if reply == "" {
+ reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority))
+ }
+ st.AssistantReply = reply
+ emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
+ return st, nil
+}
+
+// selectQuickNoteNextAfterIntent 根据意图与时间校验结果决定 intent 后分支。
+func selectQuickNoteNextAfterIntent(st *QuickNoteState) string {
+ if st == nil || !st.IsQuickNoteIntent {
+ return quickNoteGraphNodeExit
+ }
+ if strings.TrimSpace(st.DeadlineValidationError) != "" {
+ return quickNoteGraphNodeExit
+ }
+ return quickNoteGraphNodeRank
+}
+
+// selectQuickNoteNextAfterPersist 根据持久化状态决定 persist 后分支。
+func selectQuickNoteNextAfterPersist(st *QuickNoteState) string {
+ if st == nil {
+ return compose.END
+ }
+ if st.Persisted {
+ return compose.END
+ }
+ if st.CanRetryTool() {
+ return quickNoteGraphNodePersist
+ }
+ if strings.TrimSpace(st.AssistantReply) == "" {
+ st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
+ }
+ return compose.END
+}
+
+func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
+ if bundle == nil {
+ return nil, errors.New("tool bundle is nil")
+ }
+ if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 {
+ return nil, errors.New("tool bundle is empty")
+ }
+ for idx, info := range bundle.ToolInfos {
+ if info == nil || info.Name != name {
+ continue
+ }
+ invokable, ok := bundle.Tools[idx].(tool.InvokableTool)
+ if !ok {
+ return nil, fmt.Errorf("tool %s is not invokable", name)
+ }
+ return invokable, nil
+ }
+ return nil, fmt.Errorf("tool %s not found", name)
+}
+
+func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
+ return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256)
+}
+
+func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
+ 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 := chatModel.Generate(ctx, messages, opts...)
+ if err != nil {
+ return "", err
+ }
+ if resp == nil {
+ return "", errors.New("模型返回为空")
+ }
+ content := strings.TrimSpace(resp.Content)
+ if content == "" {
+ return "", errors.New("模型返回内容为空")
+ }
+ return content, nil
+}
+
+type quickNotePlannedResult struct {
+ Title string
+ Deadline *time.Time
+ DeadlineText string
+ PriorityGroup int
+ PriorityReason string
+ Banter string
+}
+
+// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。
+func planQuickNoteInSingleCall(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ nowText string,
+ now time.Time,
+ userInput string,
+) (*quickNotePlannedResult, error) {
+ prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
+用户输入:%s
+
+请仅输出 JSON(不要 markdown,不要解释),字段如下:
+{
+ "title": string,
+ "deadline_at": string,
+ "priority_group": 1|2|3|4,
+ "priority_reason": string,
+ "banter": string
+}
+
+约束:
+1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串;
+2) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间;
+3) banter 只允许一句中文,不超过30字,不得改动任务事实。`,
+ nowText,
+ strings.TrimSpace(userInput),
+ )
+
+ raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220)
+ if err != nil {
+ return nil, err
+ }
+ parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+
+ result := &quickNotePlannedResult{
+ Title: strings.TrimSpace(parsed.Title),
+ DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
+ PriorityGroup: parsed.PriorityGroup,
+ PriorityReason: strings.TrimSpace(parsed.PriorityReason),
+ Banter: strings.TrimSpace(parsed.Banter),
+ }
+
+ if result.Banter != "" {
+ if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
+ result.Banter = strings.TrimSpace(result.Banter[:idx])
+ }
+ }
+
+ if result.DeadlineText != "" {
+ if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
+ result.Deadline = deadline
+ }
+ }
+ return result, nil
+}
+
+func parseJSONPayload[T any](raw string) (*T, error) {
+ clean := strings.TrimSpace(raw)
+ if clean == "" {
+ return nil, errors.New("empty response")
+ }
+
+ if strings.HasPrefix(clean, "```") {
+ clean = strings.TrimPrefix(clean, "```json")
+ clean = strings.TrimPrefix(clean, "```")
+ clean = strings.TrimSuffix(clean, "```")
+ clean = strings.TrimSpace(clean)
+ }
+
+ var out T
+ if err := json.Unmarshal([]byte(clean), &out); err == nil {
+ return &out, nil
+ }
+
+ obj := extractJSONObject(clean)
+ if obj == "" {
+ return nil, fmt.Errorf("no json object found in: %s", clean)
+ }
+ if err := json.Unmarshal([]byte(obj), &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+func extractJSONObject(text string) string {
+ start := strings.Index(text, "{")
+ end := strings.LastIndex(text, "}")
+ if start == -1 || end == -1 || end <= start {
+ return ""
+ }
+ return text[start : end+1]
+}
+
+func fallbackPriority(st *QuickNoteState) int {
+ if st == nil {
+ return QuickNotePrioritySimpleNotImportant
+ }
+ if st.ExtractedDeadline != nil {
+ if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
+ return QuickNotePriorityImportantUrgent
+ }
+ return QuickNotePriorityImportantNotUrgent
+ }
+ return QuickNotePrioritySimpleNotImportant
+}
+
+// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
+func deriveQuickNoteTitleFromInput(userInput string) string {
+ text := strings.TrimSpace(userInput)
+ if text == "" {
+ return "这条任务"
+ }
+
+ prefixes := []string{
+ "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下",
+ }
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(text, prefix) {
+ text = strings.TrimSpace(strings.TrimPrefix(text, prefix))
+ break
+ }
+ }
+
+ suffixSeparators := []string{
+ ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
+ }
+ for _, sep := range suffixSeparators {
+ if idx := strings.Index(text, sep); idx > 0 {
+ text = strings.TrimSpace(text[:idx])
+ break
+ }
+ }
+
+ text = strings.Trim(text, ",,。.!!?;; ")
+ if text == "" {
+ return strings.TrimSpace(userInput)
+ }
+ return text
+}
diff --git a/backend/agent/quick_note_prompt.go b/backend/agent/quicknote/prompt.go
similarity index 99%
rename from backend/agent/quick_note_prompt.go
rename to backend/agent/quicknote/prompt.go
index 1bddd8d..db60918 100644
--- a/backend/agent/quick_note_prompt.go
+++ b/backend/agent/quicknote/prompt.go
@@ -1,4 +1,4 @@
-package agent
+package quicknote
const (
// QuickNoteRouteControlPrompt 用于“首段控制码分流”:
diff --git a/backend/agent/quick_note_graph_test.go b/backend/agent/quicknote/quick_note_graph_test.go
similarity index 97%
rename from backend/agent/quick_note_graph_test.go
rename to backend/agent/quicknote/quick_note_graph_test.go
index 7435f97..62b2c54 100644
--- a/backend/agent/quick_note_graph_test.go
+++ b/backend/agent/quicknote/quick_note_graph_test.go
@@ -1,4 +1,4 @@
-package agent
+package quicknote
import "testing"
diff --git a/backend/agent/quicknote/runner.go b/backend/agent/quicknote/runner.go
new file mode 100644
index 0000000..3a85141
--- /dev/null
+++ b/backend/agent/quicknote/runner.go
@@ -0,0 +1,53 @@
+package quicknote
+
+import (
+ "context"
+
+ "github.com/cloudwego/eino/components/tool"
+)
+
+// quickNoteRunner 是“单次图运行”的请求级依赖容器。
+// 设计目标:
+// 1) 把节点运行所需依赖(input/tool/emit)就近收口;
+// 2) 让 graph.go 只保留“节点连线”和“方法引用”,提升可读性;
+// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。
+type quickNoteRunner struct {
+ input QuickNoteGraphRunInput
+ createTaskTool tool.InvokableTool
+ emitStage func(stage, detail string)
+}
+
+func newQuickNoteRunner(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool, emitStage func(stage, detail string)) *quickNoteRunner {
+ return &quickNoteRunner{
+ input: input,
+ createTaskTool: createTaskTool,
+ emitStage: emitStage,
+ }
+}
+
+func (r *quickNoteRunner) intentNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
+ return runQuickNoteIntentNode(ctx, st, r.input, r.emitStage)
+}
+
+func (r *quickNoteRunner) priorityNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
+ return runQuickNotePriorityNode(ctx, st, r.input, r.emitStage)
+}
+
+func (r *quickNoteRunner) persistNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
+ return runQuickNotePersistNodeInternal(ctx, st, r.createTaskTool, r.input, r.emitStage)
+}
+
+func (r *quickNoteRunner) nextAfterIntent(ctx context.Context, st *QuickNoteState) (string, error) {
+ _ = ctx
+ return selectQuickNoteNextAfterIntent(st), nil
+}
+
+func (r *quickNoteRunner) nextAfterPersist(ctx context.Context, st *QuickNoteState) (string, error) {
+ _ = ctx
+ return selectQuickNoteNextAfterPersist(st), nil
+}
+
+func (r *quickNoteRunner) exitNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
+ _ = ctx
+ return st, nil
+}
diff --git a/backend/agent/state.go b/backend/agent/quicknote/state.go
similarity index 99%
rename from backend/agent/state.go
rename to backend/agent/quicknote/state.go
index bf7b465..b7e5e3b 100644
--- a/backend/agent/state.go
+++ b/backend/agent/quicknote/state.go
@@ -1,4 +1,4 @@
-package agent
+package quicknote
import "time"
diff --git a/backend/agent/tool.go b/backend/agent/quicknote/tool.go
similarity index 99%
rename from backend/agent/tool.go
rename to backend/agent/quicknote/tool.go
index be7d75e..049beb1 100644
--- a/backend/agent/tool.go
+++ b/backend/agent/quicknote/tool.go
@@ -1,4 +1,4 @@
-package agent
+package quicknote
import (
"context"
diff --git a/backend/agent/tool_deadline_test.go b/backend/agent/quicknote/tool_deadline_test.go
similarity index 99%
rename from backend/agent/tool_deadline_test.go
rename to backend/agent/quicknote/tool_deadline_test.go
index df9f37c..acf974d 100644
--- a/backend/agent/tool_deadline_test.go
+++ b/backend/agent/quicknote/tool_deadline_test.go
@@ -1,4 +1,4 @@
-package agent
+package quicknote
import (
"testing"
diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go
new file mode 100644
index 0000000..0dfdfc3
--- /dev/null
+++ b/backend/agent/route/route.go
@@ -0,0 +1,193 @@
+package route
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "regexp"
+ "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"
+ "github.com/google/uuid"
+ arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
+)
+
+const (
+ // ControlTimeout 是“模型控制码分流”步骤的额外子超时。
+ // 设为 0 表示完全跟随父请求上下文,不额外截断。
+ 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)["']?[^>]*>`)
+ // 可选理由块:
+ // ...
+ routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
+)
+
+// Action 表示控制码路由动作。
+type Action string
+
+const (
+ ActionChat Action = "chat"
+ ActionQuickNote Action = "quick_note"
+)
+
+// ControlDecision 是“控制码解析结果”。
+type ControlDecision struct {
+ Action Action
+ Reason string
+ Raw string
+}
+
+// RoutingDecision 是服务层最终使用的路由结果。
+type RoutingDecision struct {
+ EnterQuickNote bool
+ TrustRoute bool
+ Detail string
+}
+
+// DecideQuickNoteRouting 通过“模型控制码”决定本次请求走向。
+// 返回语义:
+// 1) EnterQuickNote=true:进入 quick_note graph;
+// 2) TrustRoute=true:表示可跳过 graph 二次意图判定。
+func DecideQuickNoteRouting(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",
+ err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
+ } else {
+ log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d",
+ err, ControlTimeout.Milliseconds())
+ }
+ return RoutingDecision{
+ EnterQuickNote: true,
+ TrustRoute: false,
+ Detail: "路由判定暂不可用,已进入任务识别兜底流程。",
+ }
+ }
+
+ switch decision.Action {
+ case ActionQuickNote:
+ reason := strings.TrimSpace(decision.Reason)
+ if reason == "" {
+ reason = "模型识别到任务安排请求,准备执行随口记。"
+ }
+ return RoutingDecision{
+ EnterQuickNote: true,
+ TrustRoute: true,
+ Detail: reason,
+ }
+ case ActionChat:
+ return RoutingDecision{
+ EnterQuickNote: false,
+ TrustRoute: false,
+ Detail: "",
+ }
+ default:
+ log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw)
+ return RoutingDecision{
+ EnterQuickNote: true,
+ TrustRoute: false,
+ Detail: "路由结果异常,已进入任务识别兜底流程。",
+ }
+ }
+}
+
+func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) {
+ if selectedModel == nil {
+ return nil, fmt.Errorf("model is nil")
+ }
+
+ nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))
+ routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout)
+ defer cancel()
+
+ nowText := time.Now().In(time.Local).Format("2006-01-02 15:04")
+ 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.UserMessage(userPrompt),
+ },
+ ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
+ einoModel.WithTemperature(0),
+ einoModel.WithMaxTokens(80),
+ )
+ if err != nil {
+ return nil, err
+ }
+ if resp == nil {
+ return nil, fmt.Errorf("empty route response")
+ }
+
+ raw := strings.TrimSpace(resp.Content)
+ if raw == "" {
+ return nil, fmt.Errorf("empty route content")
+ }
+
+ return ParseQuickNoteRouteControlTag(raw, nonce)
+}
+
+// deriveRouteControlContext 为“控制码路由”创建子上下文。
+// 设计要点:
+// 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)
+ }
+ if deadline, ok := parent.Deadline(); ok {
+ if time.Until(deadline) <= timeout {
+ return context.WithCancel(parent)
+ }
+ }
+ return context.WithTimeout(parent, timeout)
+}
+
+// ParseQuickNoteRouteControlTag 解析控制码返回。
+// 容错策略:
+// 1) 允许大小写、属性顺序、额外属性差异;
+// 2) nonce 必须精确匹配;
+// 3) action 仅允许 quick_note/chat。
+func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
+ text := strings.TrimSpace(raw)
+ if text == "" {
+ return nil, fmt.Errorf("route content is empty")
+ }
+
+ header := routeHeaderRegex.FindStringSubmatch(text)
+ if len(header) < 3 {
+ return nil, fmt.Errorf("route header not found: %s", text)
+ }
+
+ nonce := strings.ToLower(strings.TrimSpace(header[1]))
+ if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) {
+ return nil, fmt.Errorf("route nonce mismatch")
+ }
+
+ actionText := strings.ToLower(strings.TrimSpace(header[2]))
+ action := Action(actionText)
+ if action != ActionQuickNote && action != ActionChat {
+ return nil, fmt.Errorf("invalid route action: %s", actionText)
+ }
+
+ reason := ""
+ reasonMatch := routeReasonRegex.FindStringSubmatch(text)
+ if len(reasonMatch) >= 2 {
+ reason = strings.TrimSpace(reasonMatch[1])
+ }
+
+ return &ControlDecision{
+ Action: action,
+ Reason: reason,
+ Raw: text,
+ }, nil
+}
diff --git a/backend/service/agent.go b/backend/service/agent.go
index 84ab1a6..4b8afef 100644
--- a/backend/service/agent.go
+++ b/backend/service/agent.go
@@ -6,7 +6,7 @@ import (
"strings"
"time"
- "github.com/LoveLosita/smartflow/backend/agent"
+ "github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/inits"
@@ -103,7 +103,7 @@ func (s *AgentService) runNormalChatFlow(
chatHistory = conv.ToEinoMessages(histories)
}
- historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agent.SystemPrompt, userMessage)
+ historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, chat.SystemPrompt, userMessage)
trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget)
chatHistory = trimmedHistory
@@ -127,7 +127,7 @@ func (s *AgentService) runNormalChatFlow(
}
}
- fullText, streamErr := agent.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart)
+ fullText, streamErr := chat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart)
if streamErr != nil {
pushErrNonBlocking(errChan, streamErr)
return
@@ -147,6 +147,12 @@ func (s *AgentService) runNormalChatFlow(
return
}
+ // 普通聊天链路也需要把助手回复写入 Redis,
+ // 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。
+ if err = s.agentCache.PushMessage(context.Background(), chatID, &schema.Message{Role: schema.Assistant, Content: fullText}); err != nil {
+ log.Printf("写入助手消息到 Redis 失败: %v", err)
+ }
+
if saveErr := s.saveChatHistoryReliable(context.Background(), model.ChatHistoryPersistPayload{
UserID: userID,
ConversationID: chatID,
diff --git a/backend/service/agent_quick_note.go b/backend/service/agent_quick_note.go
index b07d018..fdc52ed 100644
--- a/backend/service/agent_quick_note.go
+++ b/backend/service/agent_quick_note.go
@@ -4,11 +4,12 @@ import (
"context"
"fmt"
"log"
- "regexp"
"strings"
"time"
- "github.com/LoveLosita/smartflow/backend/agent"
+ "github.com/LoveLosita/smartflow/backend/agent/chat"
+ "github.com/LoveLosita/smartflow/backend/agent/quicknote"
+ "github.com/LoveLosita/smartflow/backend/agent/route"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
@@ -17,51 +18,9 @@ import (
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
-const (
- // quickNoteRouteControlTimeout 是“模型控制码分流”这一步的额外超时。
- // 说明:
- // 1) 设为 0 代表“不额外加子超时”,完全跟随父请求上下文;
- // 2) 避免路由步骤因过短子超时反复触发 context deadline exceeded;
- // 3) 若后续需要强制保护,可再改为 >0 的值并通过配置化管理。
- quickNoteRouteControlTimeout = 0 * time.Second
-)
-
-var (
- // quickNoteRouteHeaderRegex 解析模型返回的控制头:
- //
- quickNoteRouteHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`)
- // quickNoteRouteReasonRegex 解析可选理由块:
- // ...
- quickNoteRouteReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
-)
-
-type quickNoteRouteAction string
-
-const (
- quickNoteRouteActionChat quickNoteRouteAction = "chat"
- quickNoteRouteActionQuickNote quickNoteRouteAction = "quick_note"
-)
-
-// quickNoteRouteControlDecision 是“模型控制码分流”的结构化结果。
-// 该结构不会直接暴露给前端,仅用于服务端决定后续链路:
-// - action=quick_note -> 进入随口记 graph;
-// - action=chat -> 进入普通聊天流。
-type quickNoteRouteControlDecision struct {
- Action quickNoteRouteAction
- Reason string
- Raw string
-}
-
-// quickNoteRoutingDecision 是对“是否进入随口记 graph”的最终决策。
-// 字段说明:
-// - EnterQuickNote:是否进入随口记 graph;
-// - TrustRoute:是否信任上游控制码并跳过 graph 内的二次意图判定;
-// - Detail:阶段状态文案,用于前端/调试可观测性。
-type quickNoteRoutingDecision struct {
- EnterQuickNote bool
- TrustRoute bool
- Detail string
-}
+// quickNoteRoutingDecision 只是路由层结果的本地别名。
+// 保留这个别名是为了尽量少改调用侧(agent.go 中的字段访问保持不变)。
+type quickNoteRoutingDecision = route.RoutingDecision
// quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。
// 设计目标:
@@ -92,8 +51,8 @@ func newQuickNoteProgressEmitter(outChan chan<- string, modelName string, enable
// Emit 按“阶段 + 说明”输出 reasoning_content。
// 注意:
-// - 这里不输出 role,避免和后续正文的 role 块冲突;
-// - 即使发送失败,也只记录日志,不影响主流程继续执行。
+// 1) 这里不输出 role,避免和后续正文 role 块冲突;
+// 2) 即使发送失败,也只记录日志,不影响主流程继续执行。
func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
if e == nil || !e.enablePush || e.outChan == nil {
return
@@ -109,7 +68,7 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
reasoning += "\n" + detail
}
- chunk, err := agent.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false)
+ chunk, err := chat.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false)
if err != nil {
log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err)
return
@@ -121,11 +80,9 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
// tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。
// 返回值语义:
-// - handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案);
-// - handled=false:不是随口记意图,调用方应回落普通聊天链路;
-// - state:用于拼接最终“一次性正文回复”。
-// 参数说明:
-// - trustRoute=true:信任上游控制码,graph 跳过二次意图判定,直接进入时间校验/优先级/写库流程。
+// 1) handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案);
+// 2) handled=false:不是随口记意图,调用方应回落普通聊天链路;
+// 3) state:用于拼接最终“一次性正文回复”。
func (s *AgentService) tryHandleQuickNoteWithGraph(
ctx context.Context,
selectedModel *ark.ChatModel,
@@ -135,20 +92,20 @@ func (s *AgentService) tryHandleQuickNoteWithGraph(
traceID string,
trustRoute bool,
emitStage func(stage, detail string),
-) (handled bool, state *agent.QuickNoteState, err error) {
+) (handled bool, state *quicknote.QuickNoteState, err error) {
if s.taskRepo == nil || selectedModel == nil {
return false, nil, nil
}
- state = agent.NewQuickNoteState(traceID, userID, chatID, userMessage)
- finalState, runErr := agent.RunQuickNoteGraph(ctx, agent.QuickNoteGraphRunInput{
+ state = quicknote.NewQuickNoteState(traceID, userID, chatID, userMessage)
+ finalState, runErr := quicknote.RunQuickNoteGraph(ctx, quicknote.QuickNoteGraphRunInput{
Model: selectedModel,
State: state,
- Deps: agent.QuickNoteToolDeps{
+ Deps: quicknote.QuickNoteToolDeps{
ResolveUserID: func(ctx context.Context) (int, error) {
return userID, nil
},
- CreateTask: func(ctx context.Context, req agent.QuickNoteCreateTaskRequest) (*agent.QuickNoteCreateTaskResult, error) {
+ CreateTask: func(ctx context.Context, req quicknote.QuickNoteCreateTaskRequest) (*quicknote.QuickNoteCreateTaskResult, error) {
taskModel := &model.Task{
UserID: req.UserID,
Title: req.Title,
@@ -160,7 +117,7 @@ func (s *AgentService) tryHandleQuickNoteWithGraph(
if createErr != nil {
return nil, createErr
}
- return &agent.QuickNoteCreateTaskResult{
+ return &quicknote.QuickNoteCreateTaskResult{
TaskID: created.ID,
Title: created.Title,
PriorityGroup: created.Priority,
@@ -177,14 +134,13 @@ func (s *AgentService) tryHandleQuickNoteWithGraph(
if finalState == nil || !finalState.IsQuickNoteIntent {
return false, nil, nil
}
-
return true, finalState, nil
}
// emitSingleAssistantCompletion 将单条完整回复包装成 OpenAI 兼容 chunk 流并写入 outChan。
// 说明:
-// - 保持现有 OpenAI 兼容格式不变;
-// - 正文只发一次,不做伪分段。
+// 1) 保持现有 OpenAI 兼容格式不变;
+// 2) 正文只发一次,不做伪分段。
func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply string) error {
if strings.TrimSpace(modelName) == "" {
modelName = "worker"
@@ -192,7 +148,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
- chunk, err := agent.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true)
+ chunk, err := chat.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true)
if err != nil {
return err
}
@@ -200,7 +156,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin
outChan <- chunk
}
- finishChunk, err := agent.ToOpenAIFinishStream(requestID, modelName, created)
+ finishChunk, err := chat.ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return err
}
@@ -212,9 +168,9 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin
// buildQuickNoteFinalReply 生成最终的一次性正文回复。
// 组合策略:
// 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确;
-// 2) 轻松跟进句交给 AI 生成,贴合用户话题(避免硬编码“薯饼”这类场景分支);
+// 2) 轻松跟进句交给 AI 生成,贴合用户话题;
// 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。
-func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agent.QuickNoteState) string {
+func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *quicknote.QuickNoteState) string {
if state == nil {
return "我这次没成功记上,别急,再发我一次我马上补上。"
}
@@ -227,8 +183,8 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
}
priorityText := "已安排优先级"
- if agent.IsValidTaskPriority(state.ExtractedPriority) {
- priorityText = fmt.Sprintf("优先级:%s", agent.PriorityLabelCN(state.ExtractedPriority))
+ if quicknote.IsValidTaskPriority(state.ExtractedPriority) {
+ priorityText = fmt.Sprintf("优先级:%s", quicknote.PriorityLabelCN(state.ExtractedPriority))
}
deadlineText := ""
@@ -237,14 +193,10 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
}
factLine := fmt.Sprintf("好,给你安排上了:%s(%s%s)。", title, priorityText, deadlineText)
-
- // 优先复用“聚合规划阶段”产出的跟进句,避免再触发一次润色模型调用。
if strings.TrimSpace(state.ExtractedBanter) != "" {
return factLine + " " + strings.TrimSpace(state.ExtractedBanter)
}
if state.PlannedBySingleCall {
- // 快路径兜底:单请求聚合已走过一次模型调用,若未产出 banter 则直接使用固定文案,
- // 避免再发起额外模型请求拉高总时延。
return factLine + " 已帮你稳稳记下,放心推进。"
}
@@ -270,9 +222,9 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
// generateQuickNoteBanter 让模型根据用户原话生成一条“贴题轻松句”。
// 约束:
-// - 只生成跟进语气,不承担事实表达;
-// - 不得改动任务事实;
-// - 输出控制在一句,方便直接拼接在事实句后。
+// 1) 只生成跟进语气,不承担事实表达;
+// 2) 不得改动任务事实;
+// 3) 输出控制在一句,方便直接拼接在事实句后。
func generateQuickNoteBanter(
ctx context.Context,
selectedModel *ark.ChatModel,
@@ -299,7 +251,7 @@ func generateQuickNoteBanter(
)
messages := []*schema.Message{
- schema.SystemMessage(agent.QuickNoteReplyBanterPrompt),
+ schema.SystemMessage(quicknote.QuickNoteReplyBanterPrompt),
schema.UserMessage(prompt),
}
@@ -320,8 +272,6 @@ func generateQuickNoteBanter(
if text == "" {
return "", fmt.Errorf("empty content")
}
-
- // 简单兜底:只保留首行,避免模型输出多段。
if idx := strings.Index(text, "\n"); idx >= 0 {
text = strings.TrimSpace(text[:idx])
}
@@ -329,162 +279,10 @@ func generateQuickNoteBanter(
}
// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。
-// 新策略:改为“模型控制码分流”,不再依赖关键词和本地猜测。
-//
-// 处理流程:
-// 1) 先调用路由模型拿控制码(quick_note / chat);
-// 2) 控制码可解析时按模型判定分流;
-// 3) 控制码超时/解析失败时,进入随口记 graph 做兜底意图识别,避免遗漏任务。
-//
-// 返回值说明:
-// - EnterQuickNote=true:进入随口记 graph;
-// - TrustRoute=true:跳过 graph 内二次意图判定;
-// - Detail:用于阶段推送,向前端解释“为何进入该分支”。
+// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 agent/route 包。
func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision {
- decision, err := s.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",
- err,
- time.Until(deadline).Milliseconds(),
- quickNoteRouteControlTimeout.Milliseconds(),
- )
- } else {
- log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d",
- err,
- quickNoteRouteControlTimeout.Milliseconds(),
- )
- }
- return quickNoteRoutingDecision{
- EnterQuickNote: true,
- TrustRoute: false,
- Detail: "路由判定暂不可用,已进入任务识别兜底流程。",
- }
- }
-
- switch decision.Action {
- case quickNoteRouteActionQuickNote:
- reason := strings.TrimSpace(decision.Reason)
- if reason == "" {
- reason = "模型识别到任务安排请求,准备执行随口记。"
- }
- return quickNoteRoutingDecision{
- EnterQuickNote: true,
- TrustRoute: true,
- Detail: reason,
- }
- case quickNoteRouteActionChat:
- return quickNoteRoutingDecision{
- EnterQuickNote: false,
- TrustRoute: false,
- Detail: "",
- }
- default:
- log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw)
- return quickNoteRoutingDecision{
- EnterQuickNote: true,
- TrustRoute: false,
- Detail: "路由结果异常,已进入任务识别兜底流程。",
- }
- }
-}
-
-// routeByModelControlTag 通过模型返回“控制码”完成分流。
-// 输出协议由 QuickNoteRouteControlPrompt 约束,核心字段:
-// - nonce:防伪随机串,防止模型回显历史脏内容;
-// - action:quick_note / chat。
-func (s *AgentService) routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*quickNoteRouteControlDecision, error) {
- if selectedModel == nil {
- return nil, fmt.Errorf("model is nil")
- }
-
- nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))
- routeCtx, cancel := deriveRouteControlContext(ctx, quickNoteRouteControlTimeout)
- defer cancel()
-
- nowText := time.Now().In(time.Local).Format("2006-01-02 15:04")
- userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
- resp, err := selectedModel.Generate(routeCtx, []*schema.Message{
- schema.SystemMessage(agent.QuickNoteRouteControlPrompt),
- schema.UserMessage(userPrompt),
- },
- ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
- einoModel.WithTemperature(0),
- einoModel.WithMaxTokens(80),
- )
- if err != nil {
- return nil, err
- }
- if resp == nil {
- return nil, fmt.Errorf("empty route response")
- }
-
- raw := strings.TrimSpace(resp.Content)
- if raw == "" {
- return nil, fmt.Errorf("empty route content")
- }
-
- decision, parseErr := parseQuickNoteRouteControlTag(raw, nonce)
- if parseErr != nil {
- return nil, parseErr
- }
- return decision, nil
-}
-
-// deriveRouteControlContext 为“控制码路由”创建子上下文。
-// 设计要点:
-// 1. 如果父 ctx 没有 deadline,则增加一个默认上限,防止异常请求无限等待;
-// 2. 如果父 ctx 已有更紧 deadline,则直接沿用父 ctx,不再额外缩短,
-// 避免出现“父请求还活着,但子路由因更短超时提前失败”的误判。
-func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
- if timeout <= 0 {
- return context.WithCancel(parent)
- }
- if deadline, ok := parent.Deadline(); ok {
- if time.Until(deadline) <= timeout {
- return context.WithCancel(parent)
- }
- }
- return context.WithTimeout(parent, timeout)
-}
-
-// parseQuickNoteRouteControlTag 解析模型输出控制码。
-// 容错策略:
-// - 允许大小写、属性顺序、标签内额外属性有差异;
-// - 但 nonce 必须精确匹配,action 必须为 quick_note/chat。
-func parseQuickNoteRouteControlTag(raw, expectedNonce string) (*quickNoteRouteControlDecision, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return nil, fmt.Errorf("route content is empty")
- }
-
- header := quickNoteRouteHeaderRegex.FindStringSubmatch(text)
- if len(header) < 3 {
- return nil, fmt.Errorf("route header not found: %s", text)
- }
-
- nonce := strings.ToLower(strings.TrimSpace(header[1]))
- if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) {
- return nil, fmt.Errorf("route nonce mismatch")
- }
-
- actionText := strings.ToLower(strings.TrimSpace(header[2]))
- action := quickNoteRouteAction(actionText)
- if action != quickNoteRouteActionQuickNote && action != quickNoteRouteActionChat {
- return nil, fmt.Errorf("invalid route action: %s", actionText)
- }
-
- reason := ""
- reasonMatch := quickNoteRouteReasonRegex.FindStringSubmatch(text)
- if len(reasonMatch) >= 2 {
- reason = strings.TrimSpace(reasonMatch[1])
- }
-
- return &quickNoteRouteControlDecision{
- Action: action,
- Reason: reason,
- Raw: text,
- }, nil
+ _ = s
+ return route.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
}
// persistChatAfterReply 在“随口记 graph”返回后,复用当前项目的后置持久化策略:
diff --git a/backend/service/agent_quick_note_route_test.go b/backend/service/agent_quick_note_route_test.go
index 996ba4d..6f9ee0e 100644
--- a/backend/service/agent_quick_note_route_test.go
+++ b/backend/service/agent_quick_note_route_test.go
@@ -4,7 +4,8 @@ import (
"strings"
"testing"
- "github.com/LoveLosita/smartflow/backend/agent"
+ "github.com/LoveLosita/smartflow/backend/agent/quicknote"
+ "github.com/LoveLosita/smartflow/backend/agent/route"
)
// TestParseQuickNoteRouteControlTag_QuickNote
@@ -15,15 +16,15 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
raw := `
用户明确在请求未来提醒`
- decision, err := parseQuickNoteRouteControlTag(raw, nonce)
+ decision, err := route.ParseQuickNoteRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
- if decision.Action != quickNoteRouteActionQuickNote {
- t.Fatalf("action 解析错误,期望=%s 实际=%s", quickNoteRouteActionQuickNote, decision.Action)
+ if decision.Action != route.ActionQuickNote {
+ t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNote, decision.Action)
}
if strings.TrimSpace(decision.Reason) == "" {
t.Fatalf("reason 不应为空")
@@ -34,7 +35,7 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
raw := ``
- if _, err := parseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
+ if _, err := route.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
t.Fatalf("期望 nonce 不匹配时报错,但未报错")
}
}
@@ -42,7 +43,7 @@ func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
// 目的:即使 state.Persisted 被错误置为 true,只要 task_id 无效,也不能返回“安排成功”文案。
func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
- state := &agent.QuickNoteState{
+ state := &quicknote.QuickNoteState{
Persisted: true,
PersistedTaskID: 0,
ExtractedTitle: "去下馆子",
@@ -57,7 +58,7 @@ func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
// TestBuildQuickNoteFinalReply_UseExtractedBanter
// 目的:当聚合规划阶段已经产出 banter 时,最终回复应直接复用,避免再次调用润色模型。
func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
- state := &agent.QuickNoteState{
+ state := &quicknote.QuickNoteState{
Persisted: true,
PersistedTaskID: 12,
ExtractedTitle: "明天去取快递",