🐛 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
396 lines
15 KiB
Go
396 lines
15 KiB
Go
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
|
||
}
|