Version: 0.7.8.dev.260325

后端:
迁移了schedule_plan逻辑并探索了新的架构组织思路
删除了一些Codex测试时产生的单测文件
前端:
做了一些改进
This commit is contained in:
LoveLosita
2026-03-25 20:37:55 +08:00
parent a4b5b549d3
commit aa04bfb452
22 changed files with 4627 additions and 704 deletions

View File

@@ -1,40 +0,0 @@
package agentsvc
import (
"strings"
"testing"
"github.com/cloudwego/eino/schema"
)
// TestNormalizeConversationTitle
// 目的:确保标题清洗逻辑能去掉引号/前缀并裁剪到上限长度。
func TestNormalizeConversationTitle(t *testing.T) {
raw := "标题:\"明天上午去机场接人并顺路取快递,记得提前出门\""
got := normalizeConversationTitle(raw)
if strings.HasPrefix(got, "标题") {
t.Fatalf("标题前缀未清洗got=%s", got)
}
if len([]rune(got)) > conversationTitleMaxChars {
t.Fatalf("标题长度超限got=%s", got)
}
if strings.TrimSpace(got) == "" {
t.Fatalf("清洗后标题不应为空")
}
}
// TestBuildConversationTitleUserPrompt
// 目的:确保 prompt 构造时能正确标注用户/助手角色并包含有效内容。
func TestBuildConversationTitleUserPrompt(t *testing.T) {
msgs := []*schema.Message{
{Role: schema.User, Content: "明天早上九点去机场接人"},
{Role: schema.Assistant, Content: "收到,我帮你记下了。"},
}
prompt := buildConversationTitleUserPrompt(msgs)
if !strings.Contains(prompt, "用户:明天早上九点去机场接人") {
t.Fatalf("prompt 未包含用户内容prompt=%s", prompt)
}
if !strings.Contains(prompt, "助手:收到,我帮你记下了。") {
t.Fatalf("prompt 未包含助手内容prompt=%s", prompt)
}
}

View File

@@ -1,99 +0,0 @@
package agentsvc
import (
"strings"
"testing"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
)
// TestParseQuickNoteRouteControlTag_QuickNote
// 目的:
// 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 := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
if decision.Action != agentrouter.ActionQuickNoteCreate {
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action)
}
if strings.TrimSpace(decision.Reason) == "" {
t.Fatalf("reason 不应为空")
}
}
// TestParseRouteControlTag_TaskQuery
// 目的:验证通用分流控制码在 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 := agentrouter.ParseRouteControlTag(raw, nonce)
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if decision == nil {
t.Fatalf("decision 不应为空")
}
if decision.Action != agentrouter.ActionTaskQuery {
t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action)
}
}
// TestParseQuickNoteRouteControlTag_NonceMismatch
// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求。
func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
raw := `<SMARTFLOW_ROUTE nonce="wrongnonce" action="chat"></SMARTFLOW_ROUTE>`
if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
t.Fatalf("期望 nonce 不匹配时报错,但未报错")
}
}
// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
// 目的:
// 1. 即使状态被错误标记为 Persisted=true
// 2. 只要没有有效 task_id就不能回成功文案
// 3. 避免出现“回复成功但库里没数据”的假成功体验。
func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
state := &agentmodel.QuickNoteState{
Persisted: true,
PersistedTaskID: 0,
ExtractedTitle: "去下馆子",
}
reply := buildQuickNoteFinalReply(nil, nil, "我今天晚上6点要去下馆子记得喊我", state)
if strings.Contains(reply, "给你安排上了") || strings.Contains(reply, "已安排") {
t.Fatalf("不应返回成功文案,实际回复=%s", reply)
}
}
// TestBuildQuickNoteFinalReply_UseExtractedBanter
// 目的:
// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用;
// 2. 避免为了润色再次调用模型,增加不必要时延。
func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
state := &agentmodel.QuickNoteState{
Persisted: true,
PersistedTaskID: 12,
ExtractedTitle: "明天去取快递",
ExtractedPriority: 2,
ExtractedBanter: "取件路上注意保暖,别被风吹懵了。",
}
reply := buildQuickNoteFinalReply(nil, nil, "明天上午12点我要去取快递到时候记得q我", state)
if !strings.Contains(reply, "取件路上注意保暖") {
t.Fatalf("期望复用 ExtractedBanter实际回复=%s", reply)
}
}

View File

@@ -6,7 +6,9 @@ import (
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/pkg"
@@ -107,7 +109,7 @@ func (s *AgentService) runSchedulePlanFlow(
// 4. 执行 graph 主流程。
// 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。
// 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。
state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
state := agentmodel.NewSchedulePlanState(traceID, userID, chatID)
// 4.3 连续对话微调注入:
// 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state
// 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。
@@ -118,10 +120,10 @@ func (s *AgentService) runSchedulePlanFlow(
state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems)
state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans)
}
finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{
Model: selectedModel,
State: state,
Deps: scheduleplan.SchedulePlanToolDeps{
Deps: agentnode.SchedulePlanToolDeps{
SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc,
HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc,
ResolvePlanningWindow: s.ResolvePlanningWindowFunc,
@@ -155,6 +157,6 @@ func (s *AgentService) runSchedulePlanFlow(
// 6. 旁路写入排程预览缓存(结构化 JSON给查询接口拉取。
// 6.1 失败只记日志,不影响本次对话回复;
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, finalState)
return reply, nil
}

View File

@@ -8,6 +8,8 @@ import (
"time"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
@@ -69,6 +71,62 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
}
}
// saveSchedulePlanPreviewAgent2 把 agent2 的 schedule_plan 结果写入 Redis 预览与 MySQL 快照。
//
// 职责边界:
// 1. 负责承接“新 agent2 首次排程链路”的最终状态;
// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写;
// 3. 不负责 refine 状态转换refine 仍继续走旧链路的 saveSchedulePlanPreview。
func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
// 1. 基础前置校验state 为空时直接返回,避免写入半成品快照。
if s == nil || finalState == nil {
return
}
normalizedChatID := strings.TrimSpace(chatID)
if normalizedChatID == "" {
return
}
// 2. 组装缓存快照。
// 2.1 summary 优先取 final summary空值时使用统一兜底文案
// 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片;
// 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。
summary := strings.TrimSpace(finalState.FinalSummary)
if summary == "" {
summary = "排程流程已完成,但未生成结果摘要。"
}
preview := &model.SchedulePlanPreviewCache{
UserID: userID,
ConversationID: normalizedChatID,
TraceID: strings.TrimSpace(finalState.TraceID),
Summary: summary,
CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
HybridEntries: cloneHybridEntries(finalState.HybridEntries),
AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
GeneratedAt: time.Now(),
}
// 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。
// 3.1 Redis 是“快路径”;
// 3.2 失败只记录日志,不中断聊天主链路。
if s.cacheDAO != nil {
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
}
}
// 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。
// 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖;
// 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。
if s.repo != nil {
snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
}
}
}
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
//
// 职责边界:
@@ -137,62 +195,17 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
if len(src) == 0 {
return nil
}
dst := make([]model.UserWeekSchedule, 0, len(src))
for _, week := range src {
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
copy(eventsCopy, week.Events)
dst = append(dst, model.UserWeekSchedule{
Week: week.Week,
Events: eventsCopy,
})
}
return dst
return agentshared.CloneWeekSchedules(src)
}
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
if len(src) == 0 {
return nil
}
dst := make([]model.HybridScheduleEntry, len(src))
copy(dst, src)
return dst
return agentshared.CloneHybridEntries(src)
}
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
if len(src) == 0 {
return nil
}
dst := make([]model.TaskClassItem, 0, len(src))
for _, item := range src {
copied := item
if item.CategoryID != nil {
v := *item.CategoryID
copied.CategoryID = &v
}
if item.Order != nil {
v := *item.Order
copied.Order = &v
}
if item.Content != nil {
v := *item.Content
copied.Content = &v
}
if item.Status != nil {
v := *item.Status
copied.Status = &v
}
if item.EmbeddedTime != nil {
t := *item.EmbeddedTime
copied.EmbeddedTime = &t
}
dst = append(dst, copied)
}
return dst
return agentshared.CloneTaskClassItems(src)
}
// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。
@@ -224,6 +237,35 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s
}
}
// buildSchedulePlanSnapshotFromAgent2State 把 agent2 的排程状态映射成可持久化快照 DTO。
//
// 调用目的:
// 1. 这轮只迁移 schedule_plan不动 refine
// 2. 因此 preview/快照协议继续复用老结构但要补一个“agent2 state -> snapshot DTO”的映射层
// 3. 这样可以做到:计划创建链路切到 agent2而 refine / 预览查询链路暂时无需大改。
func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
if st == nil {
return nil
}
return &model.SchedulePlanStateSnapshot{
UserID: userID,
ConversationID: conversationID,
StateVersion: model.SchedulePlanStateVersionV1,
TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
Constraints: append([]string(nil), st.Constraints...),
HybridEntries: cloneHybridEntries(st.HybridEntries),
AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
UserIntent: strings.TrimSpace(st.UserIntent),
Strategy: strings.TrimSpace(st.Strategy),
AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
RestartRequested: st.RestartRequested,
FinalSummary: strings.TrimSpace(st.FinalSummary),
Completed: st.Completed,
TraceID: strings.TrimSpace(st.TraceID),
}
}
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
if snapshot == nil {

View File

@@ -1,52 +0,0 @@
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("期望非复合直出分支继续沿用原有预览持久化策略")
}
}