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
This commit is contained in:
LoveLosita
2026-03-24 21:35:22 +08:00
parent e6941f98f2
commit f4ef6fb256
55 changed files with 5492 additions and 235 deletions

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/agent/route"
agentchat "github.com/LoveLosita/smartflow/backend/agent2/chat"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
@@ -164,7 +164,7 @@ func (s *AgentService) runNormalChatFlow(
// 3. 计算本次请求可用的历史 token 预算,并执行历史裁剪。
// 这样可以在上下文增长时稳定控制模型窗口,避免超长上下文引发报错或高延迟。
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, chat.SystemPrompt, userMessage)
historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agentchat.SystemPrompt, userMessage)
trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget)
chatHistory = trimmedHistory
@@ -192,7 +192,7 @@ func (s *AgentService) runNormalChatFlow(
// 6. 执行真正的流式聊天。
// fullText 用于后续写 Redis/持久化outChan 用于把流片段实时推给前端。
fullText, streamUsage, streamErr := chat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart)
fullText, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart)
if streamErr != nil {
pushErrNonBlocking(errChan, streamErr)
return
@@ -321,7 +321,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
// 3.2 chat直接走普通聊天主链路。
if routing.Action == route.ActionChat {
if routing.Action == agentrouter.ActionChat {
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
@@ -331,7 +331,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
progress.Emit("request.accepted", routing.Detail)
// 3.4 quick_note_create执行随口记 graph。
if routing.Action == route.ActionQuickNoteCreate {
if routing.Action == agentrouter.ActionQuickNoteCreate {
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
requestCtx,
selectedModel,
@@ -371,7 +371,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
// 3.5 task_query执行任务查询 tool-calling。
if routing.Action == route.ActionTaskQuery {
if routing.Action == agentrouter.ActionTaskQuery {
reply, queryErr := s.runTaskQueryFlow(requestCtx, selectedModel, userMessage, userID, progress.Emit)
if queryErr != nil {
// 3.5.1 任务查询失败时回退普通聊天,避免请求直接中断。
@@ -393,7 +393,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
// 3.6 schedule_plan执行智能排程 graph。
if routing.Action == route.ActionSchedulePlanCreate {
if routing.Action == agentrouter.ActionSchedulePlanCreate {
reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName)
if planErr != nil {
log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr)
@@ -413,7 +413,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
}
// 3.7 schedule_plan_refine执行“连续微调排程”graph。
if routing.Action == route.ActionSchedulePlanRefine {
if routing.Action == agentrouter.ActionSchedulePlanRefine {
reply, refineErr := s.runScheduleRefineFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, progress.Emit, outChan, resolvedModelName)
if refineErr != nil {
// 连续微调失败不再回落普通聊天,直接上报错误。

View File

@@ -7,20 +7,21 @@ import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/agent/chat"
"github.com/LoveLosita/smartflow/backend/agent/quicknote"
"github.com/LoveLosita/smartflow/backend/agent/route"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
"github.com/LoveLosita/smartflow/backend/model"
"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"
)
// quickNoteRoutingDecision 只是路由层结果的本地别名。
// 保留这个别名是为了尽量少改调用侧agent.go 中的字段访问保持不变)。
type quickNoteRoutingDecision = route.RoutingDecision
type quickNoteRoutingDecision = agentrouter.RoutingDecision
// quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。
// 设计目标:
@@ -69,24 +70,17 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
return
}
reasoning := fmt.Sprintf("阶段:%s", stage)
if detail != "" {
reasoning += "\n" + detail
}
// 2.1 每条阶段消息末尾补双换行,避免客户端把多条 chunk 紧贴在同一行显示。
// 这里统一在 emitter 层处理,所有接入 emitStage 的链路都会受益。
reasoning += "\n\n"
// 3. 复用 OpenAI 兼容封装:把阶段文本伪装成 reasoning_content。
chunk, err := chat.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false)
// 3. 调用目的:阶段提示统一走 agent2/stream 的 reasoning chunk 包装,
// 避免 service 层继续自己拼 OpenAI 兼容 JSON。
err := agentstream.EmitStageAsReasoning(func(payload string) error {
e.outChan <- payload
return nil
}, e.requestID, e.modelName, e.created, stage, detail, false)
if err != nil {
// 3.1 阶段推送失败不应影响主链路,只打日志即可。
log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err)
return
}
if chunk != "" {
e.outChan <- chunk
}
}
// tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。
@@ -103,28 +97,28 @@ func (s *AgentService) tryHandleQuickNoteWithGraph(
traceID string,
trustRoute bool,
emitStage func(stage, detail string),
) (handled bool, state *quicknote.QuickNoteState, err error) {
) (handled bool, state *agentmodel.QuickNoteState, err error) {
// 1. 依赖预检taskRepo 或模型未注入时,不做随口记处理,交给上层回落聊天。
if s.taskRepo == nil || selectedModel == nil {
return false, nil, nil
}
// 2. 初始化随口记状态对象(贯穿 graph 全流程的共享上下文)。
state = quicknote.NewQuickNoteState(traceID, userID, chatID, userMessage)
state = agentmodel.NewQuickNoteState(traceID, userID, chatID, userMessage)
// 3. 执行 quick note graph。
// 本次依赖注入了两个“工具能力”:
// 3.1 ResolveUserID从当前请求上下文确定 user_id
// 3.2 CreateTask真正执行任务写库。
finalState, runErr := quicknote.RunQuickNoteGraph(ctx, quicknote.QuickNoteGraphRunInput{
finalState, runErr := agentgraph.RunQuickNoteGraph(ctx, agentnode.QuickNoteGraphRunInput{
Model: selectedModel,
State: state,
Deps: quicknote.QuickNoteToolDeps{
Deps: agentnode.QuickNoteToolDeps{
ResolveUserID: func(ctx context.Context) (int, error) {
// 当前链路 userID 已由上层鉴权拿到,这里直接复用。
return userID, nil
},
CreateTask: func(ctx context.Context, req quicknote.QuickNoteCreateTaskRequest) (*quicknote.QuickNoteCreateTaskResult, error) {
CreateTask: func(ctx context.Context, req agentnode.QuickNoteCreateTaskRequest) (*agentnode.QuickNoteCreateTaskResult, error) {
// 3.2.1 把 quick note 的工具入参映射成项目 Task 模型。
taskModel := &model.Task{
UserID: req.UserID,
@@ -142,7 +136,7 @@ func (s *AgentService) tryHandleQuickNoteWithGraph(
}
// 3.2.3 把写库结果回填给 graph 状态,用于后续回复拼装。
return &quicknote.QuickNoteCreateTaskResult{
return &agentnode.QuickNoteCreateTaskResult{
TaskID: created.ID,
Title: created.Title,
PriorityGroup: created.Priority,
@@ -179,23 +173,17 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
// 2. 正文 chunk完整一次性输出不做人为拆片
chunk, err := chat.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true)
if err != nil {
emit := func(payload string) error {
outChan <- payload
return nil
}
if err := agentstream.EmitAssistantReply(emit, requestID, modelName, created, reply, true); err != nil {
return err
}
if chunk != "" {
outChan <- chunk
}
// 3. 按 OpenAI 风格补 finish chunk + [DONE],确保客户端可正确收尾。
finishChunk, err := chat.ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
if err := agentstream.EmitFinish(emit, requestID, modelName, created); err != nil {
return err
}
outChan <- finishChunk
outChan <- "[DONE]"
return nil
return agentstream.EmitDone(emit)
}
// buildQuickNoteFinalReply 生成最终的一次性正文回复。
@@ -203,7 +191,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin
// 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确;
// 2) 轻松跟进句交给 AI 生成,贴合用户话题;
// 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。
func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *quicknote.QuickNoteState) string {
func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agentmodel.QuickNoteState) string {
// 1. 极端兜底:状态为空时给出稳定失败文案,避免返回空字符串。
if state == nil {
return "我这次没成功记上,别急,再发我一次我马上补上。"
@@ -218,8 +206,8 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
}
priorityText := "已安排优先级"
if quicknote.IsValidTaskPriority(state.ExtractedPriority) {
priorityText = fmt.Sprintf("优先级:%s", quicknote.PriorityLabelCN(state.ExtractedPriority))
if agentmodel.IsValidTaskPriority(state.ExtractedPriority) {
priorityText = fmt.Sprintf("优先级:%s", agentmodel.PriorityLabelCN(state.ExtractedPriority))
}
deadlineText := ""
@@ -239,7 +227,7 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
}
// 2.3 兜底生成轻松跟进句;失败则降级固定文案,确保体验连续。
banter, err := generateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText)
banter, err := agentllm.GenerateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText)
if err != nil {
return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。"
}
@@ -262,81 +250,13 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel,
return "这次没成功写入任务,我没跑路,再给我一次我就把它稳稳记上。"
}
// generateQuickNoteBanter 让模型根据用户原话生成一条“贴题轻松句”。
// 约束:
// 1) 只生成跟进语气,不承担事实表达;
// 2) 不得改动任务事实;
// 3) 输出控制在一句,方便直接拼接在事实句后。
func generateQuickNoteBanter(
ctx context.Context,
selectedModel *ark.ChatModel,
userMessage string,
title string,
priorityText string,
deadlineText string,
) (string, error) {
// 1. 模型防御校验。
if selectedModel == nil {
return "", fmt.Errorf("model is nil")
}
// 2. 把事实信息显式塞入 prompt约束模型只能“润色语气”。
prompt := fmt.Sprintf(`用户原话:%s
已确认事实:
- 任务标题:%s
- %s
- %s
请输出一句轻松自然的跟进话术(仅一句)。`,
strings.TrimSpace(userMessage),
strings.TrimSpace(title),
strings.TrimSpace(priorityText),
strings.TrimSpace(deadlineText),
)
// 3. 构造消息:
// - system定义输出边界一句话、不改事实
// - user提供本次上下文素材。
messages := []*schema.Message{
schema.SystemMessage(quicknote.QuickNoteReplyBanterPrompt),
schema.UserMessage(prompt),
}
// 4. 调用模型生成 banter并显式关闭 thinking减少额外延迟。
resp, err := selectedModel.Generate(ctx, messages,
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0.7),
einoModel.WithMaxTokens(72),
)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("empty response")
}
// 5. 输出清洗:
// 5.1 去首尾空白与引号;
// 5.2 若模型多行输出,只取第一行;
// 5.3 最终为空则视为失败,让上层走降级文案。
text := strings.TrimSpace(resp.Content)
text = strings.Trim(text, "\"'“”‘’")
if text == "" {
return "", fmt.Errorf("empty content")
}
if idx := strings.Index(text, "\n"); idx >= 0 {
text = strings.TrimSpace(text[:idx])
}
return text, nil
}
// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。
// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 agent/route 包。
func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision {
// 这里保留方法是为了让 AgentService 对外语义完整,
// 同时避免上层调用方直接依赖 route 包,降低耦合。
_ = s
return route.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
}
// persistChatAfterReply 在“随口记 graph”返回后复用当前项目的后置持久化策略

View File

@@ -4,28 +4,29 @@ import (
"strings"
"testing"
"github.com/LoveLosita/smartflow/backend/agent/quicknote"
"github.com/LoveLosita/smartflow/backend/agent/route"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
)
// TestParseQuickNoteRouteControlTag_QuickNote
// 目的:验证模型控制码在 action=quick_note 时可被稳定解析,
// 并且会校验 nonce避免历史脏内容或伪造片段误命中。
// 目的:
// 1. 验证旧 quick note 兼容入口仍然可以解析控制码;
// 2. 验证旧 action=quick_note 会被统一映射到新动作 quick_note_create
// 3. 验证 reason 仍然会被保留下来,方便上层做阶段提示与排障。
func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
nonce := "abc123nonce"
raw := `<SMARTFLOW_ROUTE nonce="abc123nonce" action="quick_note"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>用户明确在请求未来提醒</SMARTFLOW_REASON>`
decision, err := route.ParseQuickNoteRouteControlTag(raw, nonce)
decision, err := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
// 兼容逻辑:历史 quick_note 会被统一映射到 quick_note_create
if decision.Action != route.ActionQuickNoteCreate {
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNoteCreate, decision.Action)
if decision.Action != agentrouter.ActionQuickNoteCreate {
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action)
}
if strings.TrimSpace(decision.Reason) == "" {
t.Fatalf("reason 不应为空")
@@ -33,37 +34,40 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
}
// TestParseRouteControlTag_TaskQuery
// 目的:验证通用分流 action=task_query 的控制码可稳定解析。
// 目的:验证通用分流控制码在 action=task_query 时可以被稳定解析。
func TestParseRouteControlTag_TaskQuery(t *testing.T) {
nonce := "taskquerynonce"
raw := `<SMARTFLOW_ROUTE nonce="taskquerynonce" action="task_query"></SMARTFLOW_ROUTE>
<SMARTFLOW_REASON>用户在查最紧急任务</SMARTFLOW_REASON>`
decision, err := route.ParseRouteControlTag(raw, nonce)
decision, err := agentrouter.ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
if decision.Action != route.ActionTaskQuery {
t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionTaskQuery, decision.Action)
if decision.Action != agentrouter.ActionTaskQuery {
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action)
}
}
// TestParseQuickNoteRouteControlTag_NonceMismatch
// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求控制码当作有效路由
// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
raw := `<SMARTFLOW_ROUTE nonce="wrongnonce" action="chat"></SMARTFLOW_ROUTE>`
if _, err := route.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
t.Fatalf("期望 nonce 不匹配时报错,但未报错")
}
}
// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
// 目的:即使 state.Persisted 被错误置为 true只要 task_id 无效,也不能返回“安排成功”文案。
// 目的:
// 1. 即使状态被错误标记为 Persisted=true
// 2. 只要没有有效 task_id就不能回成功文案
// 3. 避免出现“回复成功但库里没数据”的假成功体验。
func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
state := &quicknote.QuickNoteState{
state := &agentmodel.QuickNoteState{
Persisted: true,
PersistedTaskID: 0,
ExtractedTitle: "去下馆子",
@@ -76,9 +80,11 @@ func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
}
// TestBuildQuickNoteFinalReply_UseExtractedBanter
// 目的:当聚合规划阶段已经产出 banter 时,最终回复应直接复用,避免再次调用润色模型。
// 目的:
// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用;
// 2. 避免为了润色再次调用模型,增加不必要时延。
func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
state := &quicknote.QuickNoteState{
state := &agentmodel.QuickNoteState{
Persisted: true,
PersistedTaskID: 12,
ExtractedTitle: "明天去取快递",

View File

@@ -3,7 +3,7 @@ package agentsvc
import (
"context"
"github.com/LoveLosita/smartflow/backend/agent/route"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
"github.com/cloudwego/eino-ext/components/model/ark"
)
@@ -12,7 +12,7 @@ import (
// 设计目的:
// 1. 让 AgentService 对 route 包保持“最小接触面”;
// 2. 后续若 route 包返回结构调整,只需改这个桥接文件。
type actionRoutingDecision = route.RoutingDecision
type actionRoutingDecision = agentrouter.RoutingDecision
// decideActionRouting 决定当前请求走向哪条业务链路。
//
@@ -23,5 +23,5 @@ type actionRoutingDecision = route.RoutingDecision
func (s *AgentService) decideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) actionRoutingDecision {
// 这里保留方法封装,是为了避免上层直接依赖 route 包,降低耦合。
_ = s
return route.DecideActionRouting(ctx, selectedModel, userMessage)
return agentrouter.DecideActionRouting(ctx, selectedModel, userMessage)
}

View File

@@ -66,8 +66,12 @@ func (s *AgentService) runScheduleRefineFlow(
// 4. 调用目的:
// 4.1 saveSchedulePlanPreview 目前是“预览缓存 + MySQL 快照”的统一写入口;
// 4.2 这里把 refine state 映射为 scheduleplan state复用已有落盘链路
// 4.3 这样可以保证 create/refine 两条链路写入口径一致,便于后续统一维护
s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
// 4.3 但若是“独立复合分支已出站、终审仍失败”,则不覆盖上一版预览,避免外部误以为新方案已验证通过
if shouldPersistScheduleRefinePreview(finalState) {
s.saveSchedulePlanPreview(ctx, userID, chatID, convertRefineStateToPlanState(finalState))
} else {
emitStage("schedule_refine.preview.skipped", "复合分支终审未通过,本轮结果不覆盖上一版预览。")
}
reply := strings.TrimSpace(finalState.FinalSummary)
if reply == "" {
@@ -152,3 +156,19 @@ func convertRefineStateToPlanState(st *schedulerefine.ScheduleRefineState) *sche
Completed: st.Completed,
}
}
// shouldPersistScheduleRefinePreview 判断“本轮微调结果是否应覆盖上一版预览”。
//
// 职责边界:
// 1. 默认沿用原有 refine 持久化策略,保证普通 ReAct 微调链路不受影响;
// 2. 仅当“独立复合分支已直接出站,但终审未通过”时,拒绝覆盖上一版预览;
// 3. 这样可以避免外层把未经验证的复合结果当成新的基线继续滚动微调。
func shouldPersistScheduleRefinePreview(st *schedulerefine.ScheduleRefineState) bool {
if st == nil {
return false
}
if st.CompositeRouteSucceeded && !schedulerefine.FinalHardCheckPassed(st) {
return false
}
return true
}

View File

@@ -0,0 +1,52 @@
package agentsvc
import (
"testing"
"github.com/LoveLosita/smartflow/backend/agent/schedulerefine"
)
func TestShouldPersistScheduleRefinePreviewSkipsFailedCompositeRoute(t *testing.T) {
st := &schedulerefine.ScheduleRefineState{
CompositeRouteSucceeded: true,
HardCheck: schedulerefine.HardCheckReport{
PhysicsPassed: true,
OrderPassed: true,
IntentPassed: false,
},
}
if shouldPersistScheduleRefinePreview(st) {
t.Fatalf("期望复合分支终审失败时不覆盖上一版预览")
}
}
func TestShouldPersistScheduleRefinePreviewAllowsPassedCompositeRoute(t *testing.T) {
st := &schedulerefine.ScheduleRefineState{
CompositeRouteSucceeded: true,
HardCheck: schedulerefine.HardCheckReport{
PhysicsPassed: true,
OrderPassed: true,
IntentPassed: true,
},
}
if !shouldPersistScheduleRefinePreview(st) {
t.Fatalf("期望复合分支终审通过时允许覆盖预览")
}
}
func TestShouldPersistScheduleRefinePreviewKeepsReactPathBehavior(t *testing.T) {
st := &schedulerefine.ScheduleRefineState{
CompositeRouteSucceeded: false,
HardCheck: schedulerefine.HardCheckReport{
PhysicsPassed: true,
OrderPassed: true,
IntentPassed: false,
},
}
if !shouldPersistScheduleRefinePreview(st) {
t.Fatalf("期望非复合直出分支继续沿用原有预览持久化策略")
}
}