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

@@ -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)