Version: 0.5.8.dev.260315

♻️ refactor(agent): 拆分 agentsvc,并增强 quicknote/outbox 注释与可维护性

- 📦 将 Agent 服务实现从 `service` 根目录迁移到 `service/agentsvc`,包含 `agent.go`、`agent_quick_note.go` 及相关测试
- 🔌 新增 service 层兼容桥接 `agent_bridge.go`,保持 `service.NewAgentService` 与 `*service.AgentService` 现有调用方式不变
- 📝 为 `quicknote` 补充高密度中文步骤化注释,覆盖 `graph` / `runner` / `nodes` / `tool` / `state` / `prompt`,明确职责边界、分支条件、重试与兜底策略
- 🧭 为 `infra/outbox` 与 service agent 链路补充详细中文注释,覆盖状态机流转、幂等处理、失败回写与异步持久化语义
-  统一格式化相关文件,并通过全量后端测试:`go test ./...`

📝 chore(docs): 更新 AGENTS.md 注释强制规范

- 📚 追加“注释规范(强制)”与“注释风格示例”
- ✍️ 明确复杂逻辑必须使用步骤化注释、跨文件调用需写调用目的、注释需同步维护
This commit is contained in:
Losita
2026-03-15 18:08:33 +08:00
parent c689af56c8
commit 7603a7561a
22 changed files with 1009 additions and 429 deletions

View File

@@ -37,6 +37,7 @@ type QuickNoteGraphRunInput struct {
// RunQuickNoteGraph 执行“随口记”图编排。
// 该文件只负责“连线与分支”,节点内部逻辑全部下沉到 nodes.go。
func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) {
// 1. 启动前硬校验:模型、状态、依赖缺一不可。
if input.Model == nil {
return nil, errors.New("quick note graph: model is nil")
}
@@ -47,6 +48,7 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
return nil, err
}
// 2. 统一封装阶段推送函数,避免各节点反复判空。
emitStage := func(stage, detail string) {
if input.EmitStage != nil {
input.EmitStage(stage, detail)
@@ -54,13 +56,17 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
}
// 统一初始化“当前时间基准”,避免同一请求内相对时间口径漂移。
// 2.1 若上游未设置 RequestNow这里补齐。
if input.State.RequestNow.IsZero() {
input.State.RequestNow = quickNoteNowToMinute()
}
// 2.2 若上游未设置文本基准,这里按统一格式补齐。
if strings.TrimSpace(input.State.RequestNowText) == "" {
input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow)
}
// 3. 构建工具包并取出写库工具。
// 这样 graph 运行时只关心“调用工具”,不关心工具如何注册。
toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps)
if err != nil {
return nil, err
@@ -69,10 +75,14 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
if err != nil {
return nil, err
}
// 4. runner 负责把依赖收口graph 只保留连线定义。
runner := newQuickNoteRunner(input, createTaskTool, emitStage)
// 5. 创建状态图容器:输入/输出类型都为 *QuickNoteState。
graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]()
// 6. 注册节点(意图 -> 优先级 -> 持久化 -> 退出)。
if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda(runner.intentNode)); err != nil {
return nil, err
}
@@ -90,11 +100,13 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
}
// 连线START -> intent
// 7. 所有请求统一先过 intent 节点,确保意图和时间校验在前。
if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil {
return nil, err
}
// 分支intent 后决定去 priority 还是 exit。
// 8. 非随口记或时间非法时直接 exit避免进入后续写库路径。
if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch(
runner.nextAfterIntent,
map[string]bool{
@@ -106,16 +118,19 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
}
// exit 直接结束。
// 9. exit 是显式终点前节点,方便后续插入“统一收尾逻辑”。
if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil {
return nil, err
}
// priority -> persist。
// 10. 通过优先级节点后,进入持久化节点。
if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil {
return nil, err
}
// persist 后决定“重试 persist”还是结束。
// 11. 重试策略由状态字段驱动,不在 graph 层写重试计数逻辑。
if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch(
runner.nextAfterPersist,
map[string]bool{
@@ -126,11 +141,14 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
return nil, err
}
// 12. 运行步数上限:至少 12 步,并根据 MaxToolRetry 预留重试步数。
// 防止异常分支导致无限循环。
maxSteps := input.State.MaxToolRetry + 10
if maxSteps < 12 {
maxSteps = 12
}
// 13. 编译图得到可执行实例。
runnable, err := graph.Compile(ctx,
compose.WithGraphName("QuickNoteGraph"),
compose.WithMaxRunSteps(maxSteps),
@@ -140,5 +158,6 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic
return nil, err
}
// 14. 执行图并返回最终状态。
return runnable.Invoke(ctx, input.State)
}

View File

@@ -42,21 +42,26 @@ type quickNotePlanModelOutput struct {
// 1) trustRoute 命中时,直接走单请求聚合规划,跳过二次意图识别;
// 2) 无论是否走快路径,最终都要走本地时间硬校验,防止脏时间落库。
func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) {
// 0. 基础防御state 为空直接返回错误,避免后续节点空指针。
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 如果上游路由已高置信命中 quick_note则走“单请求聚合快路径”。
if input.SkipIntentVerification {
emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
// 1.1 一次调用里尽量拿齐 title/deadline/priority/banter减少串行模型开销。
emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput)
if planErr != nil {
// 1.2 聚合规划失败不终止链路,改为后续本地兜底。
st.IntentJudgeReason += ";聚合规划失败,回退本地兜底"
} else {
// 1.3 仅在字段有效时回填,避免无效值污染状态。
if strings.TrimSpace(plan.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(plan.Title)
}
@@ -71,10 +76,12 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.4 如果模型没给标题,基于原句做本地标题提取兜底。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.5 无论是否聚合成功,都要进行本地时间硬校验,防止脏时间写库。
emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
@@ -84,12 +91,14 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
return st, nil
}
if userDeadline != nil {
// 用户原句能解析出时间时,以原句解析结果为准(更贴近真实输入)。
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先让模型做意图识别 + 初步抽取。
emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
@@ -108,6 +117,7 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
st.UserInput,
)
// 2.1 模型调用失败时,保守回退普通聊天,避免误写任务。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt)
if callErr != nil {
st.IsQuickNoteIntent = false
@@ -115,6 +125,7 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
return st, nil
}
// 2.2 解析失败同样回退普通聊天,保证稳定性优先。
parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw)
if parseErr != nil {
st.IsQuickNoteIntent = false
@@ -125,9 +136,11 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
st.IsQuickNoteIntent = parsed.IsQuickNote
st.IntentJudgeReason = strings.TrimSpace(parsed.Reason)
if !st.IsQuickNoteIntent {
// 非随口记:后续通过分支直接退出 graph。
return st, nil
}
// 2.3 处理标题字段:为空时回退到用户原句。
title := strings.TrimSpace(parsed.Title)
if title == "" {
title = strings.TrimSpace(st.UserInput)
@@ -137,6 +150,7 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
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 {
@@ -145,6 +159,7 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
}
// Step B基于用户原句执行“本地时间解析 + 合法性校验”。
// 本地校验是最终硬门槛,确保“用户给错时间不会被静默写成 NULL”。
userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow)
if userHasTimeHint && userDeadlineErr != nil {
st.DeadlineValidationError = userDeadlineErr.Error()
@@ -154,6 +169,7 @@ func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input Quick
}
if st.ExtractedDeadline == nil && userDeadline != nil {
// 当模型未提取出时间,但原句能解析时,补写时间结果。
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
@@ -171,10 +187,12 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
if st == nil {
return nil, errors.New("quick note graph: nil state in priority node")
}
// 1. 非随口记或时间校验失败时,不做优先级评估。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 已有合法优先级则直接复用,避免重复调用模型。
if IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
@@ -182,6 +200,7 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 3. 快路径下若缺失优先级,直接本地兜底,追求低延迟。
if input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
@@ -189,6 +208,7 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
return st, nil
}
// 4. 常规路径才调用独立优先级模型。
emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
@@ -218,6 +238,7 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
deadlineText,
)
// 4.1 调用失败:使用本地兜底,不中断主链路。
raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
@@ -225,6 +246,7 @@ func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input Qui
return st, nil
}
// 4.2 解析失败或非法值:同样兜底。
parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw)
if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
@@ -244,10 +266,12 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
if st == nil {
return nil, errors.New("quick note graph: nil state in persist node")
}
// 1. 非随口记或时间非法时不允许落库。
if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" {
return st, nil
}
// 2. 准备工具入参:优先使用已评估优先级,缺失则兜底。
emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !IsValidTaskPriority(priority) {
@@ -260,6 +284,7 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
// 3. 工具参数序列化失败视作一次失败尝试,交由重试分支处理。
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
@@ -275,6 +300,7 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
return st, nil
}
// 4. 调用写库工具。
rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
@@ -285,6 +311,7 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
return st, nil
}
// 5. 工具返回解析失败同样按“可重试错误”处理。
toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
@@ -305,6 +332,7 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
return st, nil
}
// 6. 写库成功后回填状态,并准备最终回复内容。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
@@ -324,6 +352,9 @@ func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, cr
// selectQuickNoteNextAfterIntent 根据意图与时间校验结果决定 intent 后分支。
func selectQuickNoteNextAfterIntent(st *QuickNoteState) string {
// 1) 非随口记 -> exit
// 2) 时间校验失败 -> exit
// 3) 其余 -> priority 节点。
if st == nil || !st.IsQuickNoteIntent {
return quickNoteGraphNodeExit
}
@@ -335,6 +366,11 @@ func selectQuickNoteNextAfterIntent(st *QuickNoteState) string {
// selectQuickNoteNextAfterPersist 根据持久化状态决定 persist 后分支。
func selectQuickNoteNextAfterPersist(st *QuickNoteState) string {
// 分支规则:
// 1) state=nil防御式结束
// 2) 已持久化:结束;
// 3) 可重试:回到 persist 重试;
// 4) 不可重试:写失败文案并结束。
if st == nil {
return compose.END
}
@@ -351,12 +387,14 @@ func selectQuickNoteNextAfterPersist(st *QuickNoteState) string {
}
func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
// 1. 校验工具包有效性。
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")
}
// 2. 通过 ToolInfo 名称定位并拿到同索引的 Tool 实例。
for idx, info := range bundle.ToolInfos {
if info == nil || info.Name != name {
continue
@@ -371,14 +409,17 @@ func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.Invo
}
func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) {
// 默认 JSON 输出场景 token 足够小,使用 256 作为保守上限。
return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256)
}
func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
// 1. 构造 system + user 两段消息。
messages := []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(userPrompt),
}
// 2. 统一关闭 thinking降低额外延迟并用温度 0 提升结构化稳定性。
opts := []einoModel.Option{
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0),
@@ -387,6 +428,7 @@ func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel
opts = append(opts, einoModel.WithMaxTokens(maxTokens))
}
// 3. 调模型并对空响应做防御校验。
resp, err := chatModel.Generate(ctx, messages, opts...)
if err != nil {
return "", err
@@ -418,6 +460,7 @@ func planQuickNoteInSingleCall(
now time.Time,
userInput string,
) (*quickNotePlannedResult, error) {
// 1. 构造聚合 prompt一次返回所有结构化字段减少多次 LLM 往返。
prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s
用户输入:%s
@@ -438,10 +481,12 @@ func planQuickNoteInSingleCall(
strings.TrimSpace(userInput),
)
// 2. 控制 maxTokens避免模型冗长输出导致延迟上升。
raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220)
if err != nil {
return nil, err
}
// 3. 解析模型输出 JSON。
parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw)
if parseErr != nil {
return nil, parseErr
@@ -455,12 +500,14 @@ func planQuickNoteInSingleCall(
Banter: strings.TrimSpace(parsed.Banter),
}
// 4. banter 只保留首行,防止模型输出多行破坏最终回复风格。
if result.Banter != "" {
if idx := strings.Index(result.Banter, "\n"); idx >= 0 {
result.Banter = strings.TrimSpace(result.Banter[:idx])
}
}
// 5. 对 deadline 做本地二次校验,确保可落库。
if result.DeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
result.Deadline = deadline
@@ -470,11 +517,13 @@ func planQuickNoteInSingleCall(
}
func parseJSONPayload[T any](raw string) (*T, error) {
// 1. 空字符串直接失败。
clean := strings.TrimSpace(raw)
if clean == "" {
return nil, errors.New("empty response")
}
// 2. 兼容 ```json ... ``` 包裹输出。
if strings.HasPrefix(clean, "```") {
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
@@ -482,11 +531,13 @@ func parseJSONPayload[T any](raw string) (*T, error) {
clean = strings.TrimSpace(clean)
}
// 3. 先尝试整体反序列化(最快路径)。
var out T
if err := json.Unmarshal([]byte(clean), &out); err == nil {
return &out, nil
}
// 4. 若模型附带额外文本,则提取最外层 JSON 对象再解析。
obj := extractJSONObject(clean)
if obj == "" {
return nil, fmt.Errorf("no json object found in: %s", clean)
@@ -498,6 +549,8 @@ func parseJSONPayload[T any](raw string) (*T, error) {
}
func extractJSONObject(text string) string {
// 简化提取策略:取首个“{”到最后“}”的片段。
// 对当前 prompt 场景足够稳定,且实现成本低。
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start == -1 || end == -1 || end <= start {
@@ -507,6 +560,10 @@ func extractJSONObject(text string) string {
}
func fallbackPriority(st *QuickNoteState) int {
// 兜底规则:
// 1) 有截止时间且 <=48h重要且紧急
// 2) 有截止时间但较远:重要不紧急;
// 3) 无截止时间:简单不重要。
if st == nil {
return QuickNotePrioritySimpleNotImportant
}
@@ -521,11 +578,13 @@ func fallbackPriority(st *QuickNoteState) int {
// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。
func deriveQuickNoteTitleFromInput(userInput string) string {
// 1. 先清理空白。
text := strings.TrimSpace(userInput)
if text == "" {
return "这条任务"
}
// 2. 去掉常见指令前缀,保留核心任务语义。
prefixes := []string{
"请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下",
}
@@ -536,6 +595,7 @@ func deriveQuickNoteTitleFromInput(userInput string) string {
}
}
// 3. 截断“记得/到时候”等尾部提醒语,避免标题过长。
suffixSeparators := []string{
",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得",
}
@@ -546,6 +606,7 @@ func deriveQuickNoteTitleFromInput(userInput string) string {
}
}
// 4. 收尾清理标点;若清理后为空则回退原句。
text = strings.Trim(text, ",。.!; ")
if text == "" {
return strings.TrimSpace(userInput)

View File

@@ -5,6 +5,10 @@ const (
// - 仅负责判断用户输入应走 quick_note 还是 chat
// - 不直接回答用户问题;
// - 必须输出可机读控制码,便于后端无歧义解析。
// 额外说明:
// 1) 这里要求固定 XML 结构,是为了让后端做严格字符串/标签解析,而不是模糊关键词匹配;
// 2) 增加 reason 标签,主要用于日志排障(看模型为何判到 quick_note/chat
// 3) 明确“禁止输出其他内容”,是为了减少模型附加寒暄导致解析失败。
QuickNoteRouteControlPrompt = `你是 SmartFlow 的请求分流控制器。
你的唯一任务是给后端返回可机读控制码,不要做用户可见回复,不要解释。
@@ -22,6 +26,10 @@ const (
// QuickNotePlanPrompt 用于“单请求聚合规划”:
// - 在一次调用内完成标题抽取、时间归一化、优先级评估、跟进句生成;
// - 主要用于路由已明确命中 quick_note 的场景,以降低串行 LLM 调用次数。
// 额外说明:
// 1) 强制 JSON 输出,减少后端解析分支复杂度;
// 2) deadline_at 统一分钟级,方便直接映射到数据库时间字段;
// 3) banter 与事实分离,避免润色文案污染结构化字段。
QuickNotePlanPrompt = `你是 SmartFlow 的任务聚合规划器。
你将基于用户输入,一次性输出任务规划结果,供后端直接写库。
@@ -42,6 +50,7 @@ const (
// 1) 只做识别与抽取,不允许模型宣称“已写库”;
// 2) 遇到相对时间必须先换算成绝对时间,减少后续工具层歧义;
// 3) 若无时间信息必须返回空字符串,避免幻觉时间污染数据库。
// 4) 把“当前时间”明确注入 prompt保证相对时间换算有统一基准。
QuickNoteIntentPrompt = `你是 SmartFlow 的“随口记分诊器”。
请判断用户输入是否表达了“帮我记一个任务/日程”的需求。
- 若是,请提取任务标题与时间线索。
@@ -51,6 +60,7 @@ const (
// QuickNotePriorityPrompt 用于第二阶段:将任务归类到四象限优先级。
// 输出会直接映射到 tasks.priority1~4因此要求结果必须可解释。
// 这里强调“理由必须可解释”,是为了后续日志复盘时能看懂模型为何这么判。
QuickNotePriorityPrompt = `你是 SmartFlow 的任务优先级评估器。
根据任务内容、时间约束和执行成本,输出优先级 priority_group
1=重要且紧急2=重要不紧急3=简单不重要4=不简单不重要。

View File

@@ -17,6 +17,8 @@ type quickNoteRunner struct {
emitStage func(stage, detail string)
}
// newQuickNoteRunner 构造请求级 runner。
// 说明runner 生命周期仅限一次 graph invoke不做跨请求复用。
func newQuickNoteRunner(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool, emitStage func(stage, detail string)) *quickNoteRunner {
return &quickNoteRunner{
input: input,
@@ -26,28 +28,34 @@ func newQuickNoteRunner(input QuickNoteGraphRunInput, createTaskTool tool.Invoka
}
func (r *quickNoteRunner) intentNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 方法引用适配层:把 runner 内部依赖透传到纯函数节点实现。
return runQuickNoteIntentNode(ctx, st, r.input, r.emitStage)
}
func (r *quickNoteRunner) priorityNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 方法引用适配层:让 graph.go 保持“只连线,不写业务细节”。
return runQuickNotePriorityNode(ctx, st, r.input, r.emitStage)
}
func (r *quickNoteRunner) persistNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// 这里注入 createTaskTool是为了让 persist 节点不直接依赖外部容器对象。
return runQuickNotePersistNodeInternal(ctx, st, r.createTaskTool, r.input, r.emitStage)
}
func (r *quickNoteRunner) nextAfterIntent(ctx context.Context, st *QuickNoteState) (string, error) {
// 当前分支决策是纯状态函数,不依赖 context保留参数仅为适配 GraphBranch 签名。
_ = ctx
return selectQuickNoteNextAfterIntent(st), nil
}
func (r *quickNoteRunner) nextAfterPersist(ctx context.Context, st *QuickNoteState) (string, error) {
// 当前分支决策是纯状态函数,不依赖 context保留参数仅为适配 GraphBranch 签名。
_ = ctx
return selectQuickNoteNextAfterPersist(st), nil
}
func (r *quickNoteRunner) exitNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) {
// exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。
_ = ctx
return st, nil
}

View File

@@ -104,6 +104,7 @@ type QuickNoteState struct {
// NewQuickNoteState 创建随口记状态对象并初始化默认重试次数。
func NewQuickNoteState(traceID string, userID int, conversationID, userInput string) *QuickNoteState {
// 1. 在“进入链路”这一刻固化时间基准,后续所有相对时间都以它为准。
requestNow := quickNoteNowToMinute()
return &QuickNoteState{
TraceID: traceID,
@@ -118,27 +119,36 @@ func NewQuickNoteState(traceID string, userID int, conversationID, userInput str
// CanRetryTool 判断当前是否还能继续重试工具调用。
func (s *QuickNoteState) CanRetryTool() bool {
// 规则:已尝试次数 < 最大重试次数 才允许继续。
// 这里不做 <=,是为了让“第 MaxToolRetry 次失败后”及时停机并给用户明确反馈。
return s.ToolAttemptCount < s.MaxToolRetry
}
// RecordToolError 记录一次工具调用失败。
func (s *QuickNoteState) RecordToolError(errMsg string) {
// 1. 每失败一次都要累加计数,供分支节点判断是否继续重试。
s.ToolAttemptCount++
// 2. 保留最后一次错误,便于日志与排障定位“最终失败原因”。
s.LastToolError = errMsg
}
// RecordToolSuccess 记录一次工具调用成功。
func (s *QuickNoteState) RecordToolSuccess(taskID int) {
// 1. 成功同样计入尝试次数,便于还原完整调用轨迹。
s.ToolAttemptCount++
// 2. 回填 task_id 和成功标志,供后续节点拼接成功回复。
s.PersistedTaskID = taskID
s.Persisted = true
// 3. 成功后清空错误,避免后续误读历史失败信息。
s.LastToolError = ""
}
// quickNoteLocation 返回随口记链路使用的业务时区。
func quickNoteLocation() *time.Location {
// 1. 优先加载业务固定时区,保证“明天/今晚”等语义与用户预期一致。
loc, err := time.LoadLocation(quickNoteTimezoneName)
if err != nil {
// 2. 极端情况下回退到系统本地时区,避免因时区加载失败导致链路整体不可用。
return time.Local
}
return loc
@@ -146,10 +156,12 @@ func quickNoteLocation() *time.Location {
// quickNoteNowToMinute 返回当前时间并截断到分钟级。
func quickNoteNowToMinute() time.Time {
// 统一截断到分钟,避免秒级抖动导致“同一次请求前后解析口径不一致”。
return time.Now().In(quickNoteLocation()).Truncate(time.Minute)
}
// formatQuickNoteTimeToMinute 将时间格式化为分钟级字符串。
func formatQuickNoteTimeToMinute(t time.Time) string {
// 输出前统一转换到业务时区,避免日志和 prompt 出现跨时区混淆。
return t.In(quickNoteLocation()).Format(QuickNoteDatetimeMinuteLayout)
}

View File

@@ -69,9 +69,11 @@ type QuickNoteToolDeps struct {
}
func (d QuickNoteToolDeps) validate() error {
// 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
// 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
@@ -128,18 +130,23 @@ type QuickNoteCreateTaskToolOutput struct {
// BuildQuickNoteToolBundle 构建“AI随口记”工具包。
// 这是 agent 目录给上层编排层chain/graph/react提供的统一入口。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
// 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。
if err := deps.validate(); err != nil {
return nil, err
}
// 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。
// 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
// 2.1 防御式检查:工具调用参数不能为 nil。
if input == nil {
return nil, errors.New("工具参数不能为空")
}
// 2.2 标题与优先级是写库硬条件,必须先校验。
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
@@ -156,6 +163,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, err
}
// 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。
userID, err := deps.ResolveUserID(ctx)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
@@ -164,6 +172,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
// 2.4 走业务层写库。
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
@@ -177,6 +186,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, errors.New("写入任务后返回结果异常")
}
// 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
@@ -187,6 +197,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
finalPriority = result.PriorityGroup
}
// 2.6 截止时间输出统一为 RFC3339便于跨系统传输与调试。
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
@@ -194,6 +205,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
}
// 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
@@ -208,6 +220,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
// 3. Tools 给执行节点使用ToolInfos 给模型注册 schema 使用,二者都要返回。
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
@@ -221,6 +234,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
// 按工具列表顺序提取 ToolInfo确保“tools[idx] <-> infos[idx]”一一对应。
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, t := range tools {
info, err := t.Info(ctx)
@@ -235,16 +249,20 @@ func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.Too
// parseOptionalDeadline 解析工具输入中的可选截止时间。
// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at就必须能被解析。
func parseOptionalDeadline(raw string) (*time.Time, error) {
// 1. 先做标点与空白归一化,避免中文输入噪声影响解析。
value := normalizeDeadlineInput(raw)
if value == "" {
// 2. 空字符串合法,表示任务无截止时间。
return nil, nil
}
// 3. 统一按“严格模式”解析:给了时间就必须成功解析。
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
// 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
@@ -256,6 +274,7 @@ func parseOptionalDeadline(raw string) (*time.Time, error) {
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
// 场景:模型已给出 deadline_at需要基于同一 requestNow 再次硬校验。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
@@ -277,6 +296,7 @@ func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error)
// - hasHint=false 且 err=nil文本里没有明显时间线索应视为“用户没给时间”
// - hasHint=true 且 err!=nil用户给了时间但格式非法应提示用户修正不应落库。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
// 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
@@ -285,8 +305,10 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time,
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
// 有时间线索 + 解析失败:上层应明确提示用户改时间格式。
return nil, true, err
}
// 无明显时间线索:按“未提供时间”处理。
return nil, false, nil
}
if deadline == nil {
@@ -308,14 +330,17 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
return nil, false, nil
}
// 1. 统一时区与时间基准,保证相对时间可重复计算。
loc := quickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
// 2. 先尝试绝对时间(优先级更高,歧义更小)。
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
// 3. 再尝试相对时间(明天/下周一/今晚)。
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
@@ -323,6 +348,7 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
return rel, true, nil
}
// 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
@@ -331,10 +357,12 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。
func normalizeDeadlineInput(raw string) string {
// 先 trim避免纯空格输入影响后续逻辑。
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
// 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。
replacer := strings.NewReplacer(
"", ":",
"", ",",
@@ -349,6 +377,7 @@ func normalizeDeadlineInput(raw string) string {
// 1) 用户根本没给时间(允许 deadline 为空);
// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL
func hasDeadlineHint(value string) bool {
// 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
@@ -357,6 +386,7 @@ func hasDeadlineHint(value string) bool {
quickNoteWeekdayRegex.MatchString(value) {
return true
}
// 2. 再用词元判断“明天/今晚”等语义线索。
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
@@ -368,6 +398,7 @@ func hasDeadlineHint(value string) bool {
// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。
// 若只提供日期(无时分),默认归一到当天 23:59表示“当日截止”。
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
// 逐个 layout 尝试,命中即返回。
for _, layout := range quickNoteDeadlineLayouts {
var (
t time.Time
@@ -385,9 +416,11 @@ func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, boo
continue
}
// Date-only 输入(例如 2026-03-20默认补到 23:59。
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc)
} else {
// 非 date-only 则统一清零秒级,保持分钟粒度一致。
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
}
return &t, true
@@ -400,11 +433,13 @@ func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, boo
// - 明天交报告(默认 23:59
// - 下周一上午9点开会解析为下周一 09:00
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
// 1. 先确定“哪一天”。
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
// 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
@@ -424,6 +459,7 @@ func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (
// 3) 周几表达(本周/下周);
// 4) 明天/后天/今晚等相对词。
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
// 1) yyyy年MM月dd日
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
@@ -433,6 +469,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
}
}
// 2) MM月dd日自动推断年份若今年已过则滚到明年
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
@@ -451,6 +488,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
return candidate, true
}
// 3) 本周/下周 + 周几
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
@@ -459,6 +497,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
}
}
// 4) 今天/明天/后天/大后天/昨天等相对词
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
@@ -481,10 +520,12 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
// - 24h 表达18:30
// - 中文表达3点、3点半、3点20分
func extractClock(value string) (int, int, bool, error) {
// hour/minute 最终会用于 time.Date需要先做范围约束。
hour := 0
minute := 0
hasClock := false
// 1) 24 小时制18:30
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
@@ -495,6 +536,7 @@ func extractClock(value string) (int, int, bool, error) {
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
// 2) 中文时刻3点 / 3点半 / 3点20分
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
@@ -516,9 +558,11 @@ func extractClock(value string) (int, int, bool, error) {
}
if !hasClock {
// 没有显式时刻并不是错误,交给默认时刻策略处理。
return 0, 0, false, nil
}
// 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。
if isPMHint(value) && hour < 12 {
hour += 12
}
@@ -537,6 +581,7 @@ func extractClock(value string) (int, int, bool, error) {
// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。
func defaultClockByHint(value string) (int, int) {
// 没有明确时刻时按中文语义设置一个“可解释的默认值”。
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
@@ -555,19 +600,23 @@ func defaultClockByHint(value string) (int, int) {
}
func isPMHint(value string) bool {
// 下午/晚上/傍晚通常应映射到 12:00 之后。
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
// “中午 1 点”这类表达通常是 13:00 而非 01:00。
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
// 保留原时区,只把时分秒归零。
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
// 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
@@ -576,6 +625,7 @@ func isValidDate(year, month, day int) bool {
}
func toWeekday(chinese string) (time.Weekday, bool) {
// 把中文周几映射到 Go 的 Weekday 枚举。
switch chinese {
case "一":
return time.Monday, true
@@ -598,12 +648,14 @@ func toWeekday(chinese string) (time.Weekday, bool) {
// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
// 1. 先定位本周周一。
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
// 2. 再根据“本周/下周/无前缀”选择最终日期。
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)