Version: 0.5.6.dev.260314
✨ feat(agent): 重构 Agent 分层并修复普通聊天助手消息未写入 Redis 的问题 🔧 按职责重构 backend/agent 目录为 route/chat/quicknote 三层结构 🔄 将随口记链路拆分为 graph/nodes/tool/state/prompt,其中 graph 仅负责连线 🏃 新增 quicknote runner(方法引用)来收口节点依赖,提升代码可读性 🔀 将控制码分流逻辑抽离到 agent/route,服务层改为薄封装调用 📚 更新相关 README 与测试引用路径,保持原业务逻辑不变 🐛 修复普通聊天链路遗漏 assistant 写入 Redis 的问题(确保 MySQL 和 Redis 的口径一致)
This commit is contained in:
@@ -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` 调用随口记图;若判定为非随口记意图,会自动回落到普通流式聊天链路。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package chat
|
||||
|
||||
const (
|
||||
// SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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
|
||||
}
|
||||
144
backend/agent/quicknote/graph.go
Normal file
144
backend/agent/quicknote/graph.go
Normal file
@@ -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)
|
||||
}
|
||||
554
backend/agent/quicknote/nodes.go
Normal file
554
backend/agent/quicknote/nodes.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package quicknote
|
||||
|
||||
const (
|
||||
// QuickNoteRouteControlPrompt 用于“首段控制码分流”:
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package quicknote
|
||||
|
||||
import "testing"
|
||||
|
||||
53
backend/agent/quicknote/runner.go
Normal file
53
backend/agent/quicknote/runner.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package quicknote
|
||||
|
||||
import "time"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package quicknote
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package quicknote
|
||||
|
||||
import (
|
||||
"testing"
|
||||
193
backend/agent/route/route.go
Normal file
193
backend/agent/route/route.go
Normal file
@@ -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 (
|
||||
// 控制头格式:
|
||||
// <SMARTFLOW_ROUTE nonce="xxx" action="quick_note|chat"></SMARTFLOW_ROUTE>
|
||||
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`)
|
||||
// 可选理由块:
|
||||
// <SMARTFLOW_REASON>...</SMARTFLOW_REASON>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user