Version: 0.7.8.dev.260325
后端: 迁移了schedule_plan逻辑并探索了新的架构组织思路 删除了一些Codex测试时产生的单测文件 前端: 做了一些改进
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("期望非复合直出分支继续沿用原有预览持久化策略")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user