Files
smartmate/backend/agent2/node/quicknote_flow.go
LoveLosita f4ef6fb256 Version: 0.7.5.dev.260324
🐛 fix(agent/schedulerefine): 修复复合微调分支链路问题,并将 MinContextSwitch 重构为固定坑位重排语义

- 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题
-  保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果
- 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch
- 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效
- ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变”
- 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突
- 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题
- 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败

📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程

♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent

- 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构
- 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责
- 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象
- 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力
- 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖
- 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路
- ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法
- 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件
- 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定
- 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档

♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt
2026-03-24 21:35:22 +08:00

396 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentnode
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
"github.com/cloudwego/eino-ext/components/model/ark"
)
// Intent 负责“意图识别 + 聚合规划 + 时间校验”。
//
// 职责边界:
// 1. 负责判断本次请求是否属于随口记;
// 2. 负责把模型规划结果回填到 state
// 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL
// 4. 不负责真正写库。
func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) {
if st == nil {
return nil, errors.New("quick note graph: nil state in intent node")
}
// 1. 若上游路由已经高置信命中 quick_note则直接进入单次聚合规划。
// 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内
// 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。
if n.input.SkipIntentVerification {
n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。")
st.IsQuickNoteIntent = true
st.IntentJudgeReason = "上游路由已命中 quick_note跳过二次意图判定"
st.PlannedBySingleCall = true
n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。")
plan, planErr := planQuickNoteInSingleCall(ctx, n.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 plan.UrgencyThreshold != nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline)
}
if agentmodel.IsValidTaskPriority(plan.PriorityGroup) {
st.ExtractedPriority = plan.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason)
}
st.ExtractedBanter = strings.TrimSpace(plan.Banter)
}
// 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。
if strings.TrimSpace(st.ExtractedTitle) == "" {
st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput)
}
// 1.4 最后一定要做一轮本地时间硬校验。
// 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间;
// 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。
n.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点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
if userDeadline != nil {
st.ExtractedDeadline = userDeadline
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
return st, nil
}
// 2. 常规路径:先做一次意图识别,再做本地时间硬校验。
n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。")
parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput)
if callErr != nil {
// 2.1 这里不直接返回 error而是把它视为“本次未能确认是 quick note”交给上层回退普通聊天。
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
n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。")
// 2.2 先尝试吃模型返回的 deadline_at用于减少后续重复推理。
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
if st.ExtractedDeadlineText != "" {
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
st.ExtractedDeadline = deadline
}
}
// 2.3 再强制对用户原句做一次时间线索校验。
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点。"
n.emitStage("quick_note.failed", "时间校验失败,未执行写入。")
return st, nil
}
// 2.4 若模型没提到 deadline但用户原句能解析出来则以用户原句为准补齐。
if st.ExtractedDeadline == nil && userDeadline != nil {
st.ExtractedDeadline = userDeadline
if st.ExtractedDeadlineText == "" {
st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput)
}
}
return st, nil
}
// Priority 负责“优先级评估”。
//
// 职责边界:
// 1. 负责在 intent 节点之后补齐 priority_group
// 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型;
// 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走;
// 4. 不负责写库。
func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.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
}
// 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。
if agentmodel.IsValidTaskPriority(st.ExtractedPriority) {
if strings.TrimSpace(st.ExtractedPriorityReason) == "" {
st.ExtractedPriorityReason = "复用聚合规划优先级"
}
n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。")
return st, nil
}
// 2. 单请求聚合路径若没有给出合法 priority则直接走本地兜底优先保证低时延。
if n.input.SkipIntentVerification || st.PlannedBySingleCall {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底"
n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。")
return st, nil
}
n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。")
deadlineText := "无"
if st.ExtractedDeadline != nil {
deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline)
}
deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText)
if deadlineClue == "" {
deadlineClue = "无"
}
parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText)
if callErr != nil {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略"
return st, nil
}
if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) {
st.ExtractedPriority = fallbackPriority(st)
st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略"
return st, nil
}
st.ExtractedPriority = parsed.PriorityGroup
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
if thresholdErr == nil {
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
}
}
return st, nil
}
// Persist 负责“调工具写库 + 有限次重试状态回填”。
//
// 职责边界:
// 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参;
// 2. 负责调用 createTaskTool 执行真正写库;
// 3. 负责把成功/失败结果回填到 state供后续分支与回复使用
// 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。
func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.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
}
n.emitStage("quick_note.persisting", "正在写入任务数据。")
priority := st.ExtractedPriority
if !agentmodel.IsValidTaskPriority(priority) {
priority = fallbackPriority(st)
st.ExtractedPriority = priority
}
deadlineText := ""
if st.ExtractedDeadline != nil {
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
}
urgencyThresholdText := ""
if st.ExtractedUrgencyThreshold != nil {
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
}
toolInput := QuickNoteCreateTaskToolInput{
Title: st.ExtractedTitle,
PriorityGroup: priority,
DeadlineAt: deadlineText,
UrgencyThresholdAt: urgencyThresholdText,
}
rawInput, marshalErr := json.Marshal(toolInput)
if marshalErr != nil {
st.RecordToolError("构造工具参数失败: " + marshalErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。"
n.emitStage("quick_note.failed", "参数构造失败,未完成写入。")
}
return st, nil
}
rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput))
if invokeErr != nil {
st.RecordToolError(invokeErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。"
n.emitStage("quick_note.failed", "多次重试后仍未完成写入。")
}
return st, nil
}
toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput)
if parseErr != nil {
st.RecordToolError("解析工具返回失败: " + parseErr.Error())
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。"
n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。")
}
return st, nil
}
if toolOutput.TaskID <= 0 {
st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID))
if !st.CanRetryTool() {
st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。"
n.emitStage("quick_note.failed", "写入结果缺少有效 task_id已终止成功回包。")
}
return st, nil
}
// 1. 只有拿到有效 task_id才视为真正写入成功
// 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。
st.RecordToolSuccess(toolOutput.TaskID)
if strings.TrimSpace(toolOutput.Title) != "" {
st.ExtractedTitle = strings.TrimSpace(toolOutput.Title)
}
if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) {
st.ExtractedPriority = toolOutput.PriorityGroup
}
reply := strings.TrimSpace(toolOutput.Message)
if reply == "" {
reply = fmt.Sprintf("已为你记录:%s%s", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority))
}
st.AssistantReply = reply
n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。")
return st, nil
}
type quickNotePlannedResult struct {
Title string
Deadline *time.Time
DeadlineText string
UrgencyThreshold *time.Time
UrgencyThresholdText 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) {
parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput)
if err != nil {
return nil, err
}
result := &quickNotePlannedResult{
Title: strings.TrimSpace(parsed.Title),
DeadlineText: strings.TrimSpace(parsed.DeadlineAt),
UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt),
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
}
}
if result.UrgencyThresholdText != "" {
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
}
}
return result, nil
}
func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time {
if threshold == nil {
return nil
}
if deadline == nil {
return threshold
}
if threshold.After(*deadline) {
normalized := *deadline
return &normalized
}
return threshold
}
func fallbackPriority(st *agentmodel.QuickNoteState) int {
if st == nil {
return agentmodel.QuickNotePrioritySimpleNotImportant
}
if st.ExtractedDeadline != nil {
if time.Until(*st.ExtractedDeadline) <= 48*time.Hour {
return agentmodel.QuickNotePriorityImportantUrgent
}
return agentmodel.QuickNotePriorityImportantNotUrgent
}
return agentmodel.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
}