From 32bb740b7536f6961cfe4316e729ee0662b96464 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 7 Apr 2026 12:10:56 +0800 Subject: [PATCH] =?UTF-8?q?=20Version:=200.9.3.dev.260407=20=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=20=20=20=20=201.Execute=20=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=97=A0=E9=99=90=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=20/=20=E9=87=8D=E5=A4=8D=E7=A1=AE=E8=AE=A4=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=EF=BC=89=20=20=20=20=20=20=20-=20=E6=9B=B4=E6=96=B0no?= =?UTF-8?q?de/execute.go=EF=BC=9Aspeak=20=E5=86=99=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=EF=BC=88=E4=BF=AE=E5=A4=8D=E6=97=A7=20TODO=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bconfirm=20=E5=8A=A8=E4=BD=9C=20speak=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=B8=A2=E5=A4=B1=EF=BC=9B=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?continue=20=E6=97=A0=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=97=B6=E5=86=99=20reason=20=E4=BF=9D=E8=AF=81=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=8E=A8=E8=BF=9B=EF=BC=9B=E5=8C=BA=E5=88=86?= =?UTF-8?q?=20tool=5Fcall=20=E6=95=B0=E7=BB=84/JSON=E6=8D=9F=E5=9D=8F?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=20=20=20=20=20=20=20=20=20correction=20hint?= =?UTF-8?q?=EF=BC=9Bgoal=5Fcheck=20hint=20=E5=8C=BA=E5=88=86=20plan/ReAct?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=20=20=20=20=20=20=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0node/execute.go=EF=BC=9A=E6=96=B0=E5=A2=9E=20AlwaysExe?= =?UTF-8?q?cute=20=E5=AD=97=E6=AE=B5=EF=BC=8Cextra.always=5Fexecute=3Dtrue?= =?UTF-8?q?=20=E6=97=B6=E5=86=99=E5=B7=A5=E5=85=B7=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=E8=B7=B3=E8=BF=87=E7=A1=AE=E8=AE=A4=E9=97=B8=E9=97=A8?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=89=A7=E8=A1=8C=E5=B9=B6=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20=20=20=20=20=20=20-=20=E6=9B=B4=E6=96=B0model/graph?= =?UTF-8?q?=5Frun=5Fstate.go=EF=BC=9AAgentGraphRequest=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20AlwaysExecute=EF=BC=9B=E6=96=B0=E5=A2=9E=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20WriteSchedulePreviewFunc=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=92=8C=20WriteSchedulePreview=20Dep=20=20=20=20=20=20=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0service/agentsvc/agent.go=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20readAgentExtraBool=20=E8=BE=85=E5=8A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.粗排全链路修复 - 更新service/agentsvc/agent_newagent.go:makeRoughBuildFunc 改用 HybridScheduleEntry 而非 TaskClassItem.EmbeddedTime,普通时段放置不再被丢弃 - 更新conv/schedule_provider.go:LoadScheduleState 从 task class 日期范围推算多周 规划窗口,不再硬编码当前周 7 天;DayMapping 覆盖全部相关周,粗排跨周结果不再 被 WeekDayToDay 静默丢弃 - 更新node/rough_build.go:pinned block 区分有/无未覆盖 pending 任务两种情况, 有 pending 时明确操作顺序(find_free→place)和完成判定,防止 LLM 重复调 list_tasks;新增 countPendingTasks 辅助(只统计 Slots 为空的真正未覆盖任务) - 更新model/common_state.go:新增 StartDirectExecute(),Chat 直接路由 execute 时 清空旧 PlanSteps,修复跨会话 HasPlan() 误判导致 ReAct 走 plan 模式的 bug - 更新node/chat.go:handleRouteExecute 改用 StartDirectExecute() 3.排程预览缓存迁移至 Deliver 节点 - 更新node/agent_nodes.go:Deliver 节点完成后调用 WriteSchedulePreview,只有任务 真正完成才写预览缓存,中断路径不写中间态 - 更新service/agentsvc/agent_newagent.go:注入 makeWriteSchedulePreviewFunc;移除 graph 结束后的内联写入;makeRoughBuildFunc 注释修正 - 更新conv/schedule_preview.go:ScheduleStateToPreview 补设 GeneratedAt - 更新model/agent.go:GetSchedulePlanPreviewResponse 新增 HybridEntries 字段 - 更新service/agentsvc/agent_schedule_preview.go:GET handler Redis/MySQL 两条路径 均透传 HybridEntries 4.Execute thinking 模式修复 - 更新newAgent/llm/ark_adapter.go:thinking 开启时强制 temperature=1,MaxTokens 自 动托底至 16000,调用方与适配层行为对齐 - 更新node/execute.go:调用参数同步改为 temperature=1.0 / MaxTokens=16000 undo: 1.流式推送换行未修复(undo) 2.上下文依然待审视 前端:无 仓库:无 --- backend/conv/schedule_preview.go | 2 + backend/conv/schedule_provider.go | 116 ++++++++++++---- backend/model/agent.go | 11 +- backend/newAgent/llm/ark_adapter.go | 26 +++- backend/newAgent/model/common_state.go | 10 ++ backend/newAgent/model/graph_run_state.go | 26 ++-- backend/newAgent/node/agent_nodes.go | 12 ++ backend/newAgent/node/chat.go | 3 +- backend/newAgent/node/deliver.go | 2 +- backend/newAgent/node/execute.go | 124 ++++++++++++++---- backend/newAgent/node/rough_build.go | 50 +++++-- backend/newAgent/prompt/base.go | 25 ++-- backend/newAgent/prompt/execute.go | 12 +- backend/newAgent/stream/emitter.go | 15 ++- backend/service/agentsvc/agent.go | 19 +++ backend/service/agentsvc/agent_newagent.go | 103 ++++++++------- .../agentsvc/agent_schedule_preview.go | 2 + 17 files changed, 410 insertions(+), 148 deletions(-) diff --git a/backend/conv/schedule_preview.go b/backend/conv/schedule_preview.go index 7308a2f..1282992 100644 --- a/backend/conv/schedule_preview.go +++ b/backend/conv/schedule_preview.go @@ -2,6 +2,7 @@ package conv import ( "fmt" + "time" "github.com/LoveLosita/smartflow/backend/model" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" @@ -105,5 +106,6 @@ func ScheduleStateToPreview( Summary: summary, HybridEntries: entries, TaskClassIDs: taskClassIDs, + GeneratedAt: time.Now(), } } diff --git a/backend/conv/schedule_provider.go b/backend/conv/schedule_provider.go index 62e3e3f..d71175b 100644 --- a/backend/conv/schedule_provider.go +++ b/backend/conv/schedule_provider.go @@ -3,6 +3,7 @@ package conv import ( "context" "fmt" + "sort" "time" "github.com/LoveLosita/smartflow/backend/dao" @@ -31,39 +32,106 @@ func NewScheduleProvider(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskCla } // LoadScheduleState 实现 model.ScheduleStateProvider 接口。 -// 加载用户当前周的日程和所有待安排任务,构建 ScheduleState。 +// +// 窗口策略: +// 1. 优先从 task class 的 StartDate/EndDate 推算规划窗口,覆盖粗排所需的完整日期范围; +// 2. task class 无日期信息时,降级到当前周 7 天(兼容普通查询场景)。 +// +// 日程加载策略:对窗口内每周分别调用 GetUserWeeklySchedule 并合并结果。 func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*newagenttools.ScheduleState, error) { - // 1. 确定当前周。 - now := time.Now() - week, _, err := RealDateToRelativeDate(now.Format(DateFormat)) - if err != nil { - return nil, fmt.Errorf("解析当前日期失败: %w", err) - } - - // 2. 加载当前周的所有日程(含 Event + EmbeddedTask 预加载)。 - schedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, week) - if err != nil { - return nil, fmt.Errorf("加载用户周日程失败: %w", err) - } - - // 3. 加载用户所有任务类(含 Items 预加载)。 - // 两步:先拿 ID 列表,再批量获取完整数据(含 Items)。 + // 1. 加载用户所有任务类(含 Items 预加载)。 taskClasses, err := p.loadCompleteTaskClasses(ctx, userID) if err != nil { return nil, err } - // 4. 构建 WindowDay 列表(当前周 7 天)。 - windowDays := make([]WindowDay, 7) - for i := 0; i < 7; i++ { - windowDays[i] = WindowDay{Week: week, DayOfWeek: i + 1} + // 2. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。 + windowDays, weeks := buildWindowFromTaskClasses(taskClasses) + if len(windowDays) == 0 { + now := time.Now() + currentWeek, _, err := RealDateToRelativeDate(now.Format(DateFormat)) + if err != nil { + return nil, fmt.Errorf("解析当前日期失败: %w", err) + } + windowDays = make([]WindowDay, 7) + for i := 0; i < 7; i++ { + windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1} + } + weeks = []int{currentWeek} } - // 5. 构建额外 item category 映射(已加载全部 taskClass,通常为空)。 - extraItemCategories := buildExtraItemCategories(schedules, taskClasses) + // 3. 按周加载日程(含 Event + EmbeddedTask 预加载)。 + var allSchedules []model.Schedule + for _, w := range weeks { + weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w) + if err != nil { + return nil, fmt.Errorf("加载用户周日程失败 week=%d: %w", w, err) + } + allSchedules = append(allSchedules, weekSchedules...) + } - // 6. 调用已有的 LoadScheduleState 构建内存状态。 - return LoadScheduleState(schedules, taskClasses, extraItemCategories, windowDays), nil + // 4. 构建额外 item category 映射。 + extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses) + + // 5. 调用已有的 LoadScheduleState 构建内存状态。 + return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil +} + +// buildWindowFromTaskClasses 从 task class 的 StartDate/EndDate 推算规划窗口。 +// +// 返回值: +// - windowDays:窗口内每天的 (week, dayOfWeek) 有序列表; +// - weeks:窗口覆盖的周号(去重、升序),供按周加载日程使用; +// - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。 +func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) { + var minDate, maxDate *time.Time + for _, tc := range taskClasses { + if tc.StartDate != nil && (minDate == nil || tc.StartDate.Before(*minDate)) { + t := *tc.StartDate + minDate = &t + } + if tc.EndDate != nil && (maxDate == nil || tc.EndDate.After(*maxDate)) { + t := *tc.EndDate + maxDate = &t + } + } + if minDate == nil || maxDate == nil { + return nil, nil + } + + startWeek, startDay, err := RealDateToRelativeDate(minDate.Format(DateFormat)) + if err != nil { + return nil, nil + } + endWeek, endDay, err := RealDateToRelativeDate(maxDate.Format(DateFormat)) + if err != nil { + return nil, nil + } + + weeksSet := make(map[int]bool) + w, d := startWeek, startDay + for { + windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d}) + weeksSet[w] = true + if w == endWeek && d == endDay { + break + } + d++ + if d > 7 { + d = 1 + w++ + } + if w > endWeek+1 { // 防止因日期转换异常导致无限循环 + break + } + } + + weeks = make([]int, 0, len(weeksSet)) + for wk := range weeksSet { + weeks = append(weeks, wk) + } + sort.Ints(weeks) + return windowDays, weeks } // loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。 diff --git a/backend/model/agent.go b/backend/model/agent.go index 314919a..b0439b6 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -249,11 +249,12 @@ type SchedulePlanPreviewCache struct { } type GetSchedulePlanPreviewResponse struct { - ConversationID string `json:"conversation_id"` - TraceID string `json:"trace_id,omitempty"` - Summary string `json:"summary"` - CandidatePlans []UserWeekSchedule `json:"candidate_plans"` - GeneratedAt time.Time `json:"generated_at"` + ConversationID string `json:"conversation_id"` + TraceID string `json:"trace_id,omitempty"` + Summary string `json:"summary"` + CandidatePlans []UserWeekSchedule `json:"candidate_plans"` + HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"` + GeneratedAt time.Time `json:"generated_at"` } type SSEResponse struct { diff --git a/backend/newAgent/llm/ark_adapter.go b/backend/newAgent/llm/ark_adapter.go index ef5017f..3102115 100644 --- a/backend/newAgent/llm/ark_adapter.go +++ b/backend/newAgent/llm/ark_adapter.go @@ -50,24 +50,36 @@ func WrapArkClient(arkChatModel *ark.ChatModel) *Client { // buildArkStreamOptions 将 newAgent 的 GenerateOptions 转换为 ark 的流式调用选项。 func buildArkStreamOptions(options GenerateOptions) []einoModel.Option { + thinkingEnabled := options.Thinking == ThinkingModeEnabled + // Thinking thinkingType := arkModel.ThinkingTypeDisabled - if options.Thinking == ThinkingModeEnabled { + if thinkingEnabled { thinkingType = arkModel.ThinkingTypeEnabled } - opts := []einoModel.Option{ ark.WithThinking(&arkModel.Thinking{Type: thinkingType}), } - // Temperature - if options.Temperature > 0 { + // Temperature:thinking 模型强制要求 temperature=1,否则 API 静默忽略 thinking。 + if thinkingEnabled { + opts = append(opts, einoModel.WithTemperature(1.0)) + } else if options.Temperature > 0 { opts = append(opts, einoModel.WithTemperature(float32(options.Temperature))) } - // MaxTokens - if options.MaxTokens > 0 { - opts = append(opts, einoModel.WithMaxTokens(options.MaxTokens)) + // MaxTokens:thinking 模式下 thinking token 占用 max_tokens 预算, + // 调用方设定的值仅代表"期望输出长度",实际预算需留出思考空间。 + // 最低保障 16000,避免思考链被截断导致输出为空或非 JSON。 + maxTokens := options.MaxTokens + if thinkingEnabled { + const minThinkingBudget = 16000 + if maxTokens < minThinkingBudget { + maxTokens = minThinkingBudget + } + } + if maxTokens > 0 { + opts = append(opts, einoModel.WithMaxTokens(maxTokens)) } return opts diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index ca2ea44..c6fd48b 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -94,6 +94,16 @@ func (s *CommonState) ConfirmPlan() { s.Phase = PhaseExecuting } +// StartDirectExecute 进入无 plan 的直接执行(ReAct)模式。 +// Chat 节点路由到 execute 时必须调用此方法,而非直接赋值 Phase, +// 否则上一次任务残留的 PlanSteps 会被 HasPlan() 误判为仍有计划, +// 导致 Execute 节点用旧步骤跑 plan 模式而非 ReAct 模式。 +func (s *CommonState) StartDirectExecute() { + s.PlanSteps = nil + s.CurrentStep = 0 + s.Phase = PhaseExecuting +} + // RejectPlan 表示用户拒绝当前计划,清空计划并回退到 planning。 func (s *CommonState) RejectPlan() { s.PlanSteps = nil diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index 99ba0f0..9d33315 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -18,6 +18,7 @@ import ( type AgentGraphRequest struct { UserInput string ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入 + AlwaysExecute bool // true 时写工具跳过确认闸门直接执行,适合前端已展示预览、用户无需逐步确认的场景 } // Normalize 统一清洗请求级输入中的字符串字段。 @@ -43,6 +44,10 @@ type RoughBuildPlacement struct { // 由 service 层封装 HybridScheduleWithPlanMulti 后注入,newAgent 层不直接依赖外层 model。 type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([]RoughBuildPlacement, error) +// WriteSchedulePreviewFunc 是排程预览写入的依赖注入签名。 +// 由 service 层封装 cacheDAO 后注入,deliver 节点在任务完成时调用,保证只有真正完成的结果才写入缓存。 +type WriteSchedulePreviewFunc func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error + // AgentGraphDeps 描述 graph/node 层运行时真正依赖的可插拔能力。 // // 设计目的: @@ -50,16 +55,17 @@ type RoughBuildFunc func(ctx context.Context, userID int, taskClassIDs []int) ([ // 2. Chat/Plan/Execute/Deliver 允许分别挂不同 client,但也允许先复用同一个 client; // 3. ChunkEmitter 统一承接阶段提示、正文、工具事件、确认请求等 SSE 输出。 type AgentGraphDeps struct { - ChatClient *newagentllm.Client - PlanClient *newagentllm.Client - ExecuteClient *newagentllm.Client - DeliverClient *newagentllm.Client - ChunkEmitter *newagentstream.ChunkEmitter - StateStore AgentStateStore - ToolRegistry *newagenttools.ToolRegistry - ScheduleProvider ScheduleStateProvider // 按 DAO 注入,Execute 节点按需加载 ScheduleState - SchedulePersistor SchedulePersistor // 按 DAO 注入,用于写工具执行后持久化变更 - RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口 + ChatClient *newagentllm.Client + PlanClient *newagentllm.Client + ExecuteClient *newagentllm.Client + DeliverClient *newagentllm.Client + ChunkEmitter *newagentstream.ChunkEmitter + StateStore AgentStateStore + ToolRegistry *newagenttools.ToolRegistry + ScheduleProvider ScheduleStateProvider // 按 DAO 注入,Execute 节点按需加载 ScheduleState + SchedulePersistor SchedulePersistor // 按 DAO 注入,用于写工具执行后持久化变更 + RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口 + WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口 } // EnsureChunkEmitter 保证 graph 运行时始终有一个可用的 chunk 发射器。 diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index e9407c7..033324a 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" @@ -214,6 +215,7 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt ScheduleState: scheduleState, SchedulePersistor: st.Deps.SchedulePersistor, OriginalScheduleState: st.OriginalScheduleState, + AlwaysExecute: st.Request.AlwaysExecute, }, ); err != nil { return nil, err @@ -247,6 +249,16 @@ func (n *AgentNodes) Deliver(ctx context.Context, st *newagentmodel.AgentGraphSt return nil, err } + // 任务完成后写排程预览缓存:只有走到 Deliver 才代表排程结果已稳定, + // 中断(confirm/ask_user)路径不写,避免把中间态暴露给前端。 + if st.Deps.WriteSchedulePreview != nil && st.ScheduleState != nil { + flowState := st.EnsureFlowState() + if err := st.Deps.WriteSchedulePreview(ctx, st.ScheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil { + // 写缓存失败不阻断主流程,降级为仅 log。 + log.Printf("[WARN] deliver: 写入排程预览缓存失败 chat=%s: %v", flowState.ConversationID, err) + } + } + saveAgentState(ctx, st) return st, nil } diff --git a/backend/newAgent/node/chat.go b/backend/newAgent/node/chat.go index 5b5ad02..c81e2b3 100644 --- a/backend/newAgent/node/chat.go +++ b/backend/newAgent/node/chat.go @@ -158,7 +158,8 @@ func handleRouteExecute( // 推送轻量状态通知,让前端知道请求已接收。 _ = emitter.EmitStatus(chatStatusBlockID, chatStageName, "accepted", speak, false) - flowState.Phase = newagentmodel.PhaseExecuting + // 清空旧 PlanSteps 并设 PhaseExecuting,避免上一次任务残留的步骤被 HasPlan() 误判。 + flowState.StartDirectExecute() // 安全兜底:只有真正持有 task_class_ids 时才开粗排。 if decision.NeedsRoughBuild && len(flowState.TaskClassIDs) > 0 { diff --git a/backend/newAgent/node/deliver.go b/backend/newAgent/node/deliver.go index 7787085..8d91455 100644 --- a/backend/newAgent/node/deliver.go +++ b/backend/newAgent/node/deliver.go @@ -122,7 +122,7 @@ func generateDeliverSummary( return buildMechanicalSummary(flowState) } - return strings.TrimSpace(result.Text) + return normalizeSpeak(result.Text) } // buildMechanicalSummary 在 LLM 不可用时,机械拼接一份最小可用总结。 diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index 9c844ae..a721116 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "regexp" "strings" "time" @@ -50,6 +51,7 @@ type ExecuteNodeInput struct { ScheduleState *newagenttools.ScheduleState SchedulePersistor newagentmodel.SchedulePersistor OriginalScheduleState *newagenttools.ScheduleState + AlwaysExecute bool // true 时写工具跳过确认闸门直接执行 } // ExecuteRoundObservation 记录执行阶段每轮的关键观察。 @@ -141,9 +143,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { input.Client, messages, newagentllm.GenerateOptions{ - Temperature: 0.3, - MaxTokens: 1200, - Thinking: newagentllm.ThinkingModeDisabled, + Temperature: 1.0, // thinking 模式强制要求 temperature=1 + MaxTokens: 16000, // 需为 thinking chain 留出足够预算 + Thinking: newagentllm.ThinkingModeEnabled, Metadata: map[string]any{ "stage": executeStageName, "step_index": flowState.CurrentStep, @@ -166,12 +168,18 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return fmt.Errorf("连续 %d 次输出非 JSON,终止执行: 原始输出=%s", flowState.ConsecutiveCorrections, rawText) } - AppendLLMCorrectionWithHint( - conversationContext, - rawText, - "你的输出不是合法 JSON,无法解析。", - "你必须输出严格的 JSON 格式,不要使用 [NEXT_PLAN] 等纯文本标记。合法格式示例:{\"speak\":\"...\",\"action\":\"next_plan\",\"goal_check\":\"...\",\"reason\":\"...\"}", - ) + // 区分两种常见失败: + // 1. tool_call 是数组(LLM 想批量调工具)→ 告知只能单次调用,保留已有上下文; + // 2. 真正的 JSON 格式损坏 → 要求重新输出合法 JSON。 + var errorDesc, optionHint string + if strings.Contains(rawText, `"tool_call": [`) || strings.Contains(rawText, `"tool_call":[`) { + errorDesc = "你在 tool_call 字段传入了数组,但每轮只能调用一个工具,不支持批量格式。" + optionHint = "请把多个工具调用拆开,每轮只调一个,拿到结果后再继续下一步。示例:{\"speak\":\"...\",\"action\":\"continue\",\"reason\":\"...\",\"tool_call\":{\"name\":\"get_task_info\",\"arguments\":{\"task_id\":1}}}" + } else { + errorDesc = "你的输出不是合法 JSON,无法解析。" + optionHint = "你必须输出严格的 JSON 格式。合法格式示例:{\"speak\":\"...\",\"action\":\"continue\",\"reason\":\"...\",\"tool_call\":{\"name\":\"工具名\",\"arguments\":{}}}" + } + AppendLLMCorrectionWithHint(conversationContext, rawText, errorDesc, optionHint) return nil } @@ -223,6 +231,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 决策合法,重置连续修正计数。 flowState.ConsecutiveCorrections = 0 + // speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。 + decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n + // 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。 if decision.Action == newagentmodel.ExecuteActionNextPlan || decision.Action == newagentmodel.ExecuteActionDone { @@ -231,31 +242,52 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections { return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections) } + // hint 区分有 plan / ReAct 两种模式: + // - 有 plan:要求对照 done_when 逐条验证; + // - ReAct:没有 done_when,只要求总结完成事实。 + var goalCheckHint string + if flowState.HasPlan() { + goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action) + } else { + goalCheckHint = fmt.Sprintf("输出 %s 时,必须在 goal_check 中总结任务已完成的事实证据(调用了哪些工具、得到了什么结果)。", decision.Action) + } AppendLLMCorrectionWithHint( conversationContext, decision.Speak, fmt.Sprintf("你输出了 action=%s,但 goal_check 为空。", decision.Action), - fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action), + goalCheckHint, ) return nil } } - // 6. 若 LLM 先对用户说话,且不是 ask_user / confirm(二者交给下游节点收口),则伪流式推送。 - if strings.TrimSpace(decision.Speak) != "" && - decision.Action != newagentmodel.ExecuteActionAskUser && - decision.Action != newagentmodel.ExecuteActionConfirm { - if err := emitter.EmitPseudoAssistantText( - ctx, - executeSpeakBlockID, - executeStageName, - decision.Speak, - newagentstream.DefaultPseudoStreamOptions(), - ); err != nil { - return fmt.Errorf("执行文案推送失败: %w", err) + // 6. speak 推流与历史写入。 + // + // AlwaysExecute=true 时,confirm 动作不走确认卡片,speak 和 continue 一样直接推流; + // AlwaysExecute=false 时,confirm 的 speak 不推流(由确认卡片展示),但仍写入历史, + // 防止 LLM 下一轮忘记自己的计划,形成重复确认循环。 + speakText := decision.Speak // 已由 normalizeSpeak 处理,末尾含 \n + if speakText != "" { + isConfirmWithCard := decision.Action == newagentmodel.ExecuteActionConfirm && !input.AlwaysExecute + isAskUser := decision.Action == newagentmodel.ExecuteActionAskUser + + if !isConfirmWithCard && !isAskUser { + // 推流给前端 + if err := emitter.EmitPseudoAssistantText( + ctx, + executeSpeakBlockID, + executeStageName, + speakText, + newagentstream.DefaultPseudoStreamOptions(), + ); err != nil { + return fmt.Errorf("执行文案推送失败: %w", err) + } } - // 将 LLM 的话追加到对话历史,保证下一轮上下文连续。 - // TODO: 后续需要把工具调用结果也追加到历史,这里先留占位。 + // 始终写入历史(confirm 卡片场景下也写,保证上下文连续) + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: speakText, + }) } // 7. 按 LLM 决策执行动作,后端信任 LLM 判断,不做语义校验。 @@ -266,7 +298,15 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { if decision.ToolCall != nil { return executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState) } - // 无工具调用,仅对话,继续下一轮。 + // 无工具调用且 speak 为空(speak 非空时已在步骤 6 写入历史)。 + // 若 history 本轮完全没有更新,下一轮 LLM 会收到完全相同的上下文,容易死循环。 + // 把 reason 写入历史,保证上下文向前推进。 + if strings.TrimSpace(decision.Speak) == "" && strings.TrimSpace(decision.Reason) != "" { + conversationContext.AppendHistory(&schema.Message{ + Role: schema.Assistant, + Content: decision.Reason, + }) + } return nil case newagentmodel.ExecuteActionAskUser: @@ -276,8 +316,20 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { return nil case newagentmodel.ExecuteActionConfirm: - // LLM 申报了写操作意图,需要用户确认后才能真正执行。 - // 步骤:1) 把 ToolCallIntent 转成快照暂存;2) 设 Phase → 下游 confirm 节点接管。 + // AlwaysExecute=true:跳过确认闸门,直接执行写工具并持久化,不走 confirm 节点。 + if input.AlwaysExecute && decision.ToolCall != nil { + if err := executeToolCall(ctx, flowState, conversationContext, decision.ToolCall, emitter, input.ToolRegistry, input.ScheduleState); err != nil { + return err + } + if input.SchedulePersistor != nil && input.OriginalScheduleState != nil { + cs := runtimeState.EnsureCommonState() + if persistErr := input.SchedulePersistor.PersistScheduleChanges(ctx, input.OriginalScheduleState, input.ScheduleState, cs.UserID); persistErr != nil { + log.Printf("[WARN] execute always-execute 持久化失败: %v", persistErr) + } + } + return nil + } + // AlwaysExecute=false(默认):暂存工具意图,设 Phase → 下游 confirm 节点接管。 return handleExecuteActionConfirm(decision, runtimeState, flowState) case newagentmodel.ExecuteActionNextPlan: @@ -588,6 +640,24 @@ func executePendingTool( return nil } +// listItemRe 匹配被粘连在一起的列表序号(如 ")2. " "水课3. "),用于自动补换行。 +// 规则:非换行字符后紧跟 2-9 的序号("2. " "3、" 等),说明 LLM 漏写了换行。 +var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`) + +// normalizeSpeak 对 LLM 输出的 speak 做后处理: +// 1. 在列表序号(2. 3. …)前补 \n,防止列表项粘连; +// 2. 统一补尾部 \n,防止多轮 speak 推流时文字头尾粘连。 +func normalizeSpeak(speak string) string { + speak = strings.TrimSpace(speak) + if speak == "" { + return speak + } + if !strings.Contains(speak, "\n") { + speak = listItemRe.ReplaceAllString(speak, "$1\n$2") + } + return speak + "\n" +} + // truncateText 截断文本到指定长度。 // // 用于状态推送时避免超长文本影响前端展示。 diff --git a/backend/newAgent/node/rough_build.go b/backend/newAgent/node/rough_build.go index f61ef40..89dc855 100644 --- a/backend/newAgent/node/rough_build.go +++ b/backend/newAgent/node/rough_build.go @@ -80,17 +80,33 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e false, ) - // 8. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接跳过"触发粗排", - // 进入验证和微调,避免 LLM 误以为需要自己运行算法而浪费一轮工具调用。 - st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{ - Key: "rough_build_done", - Title: "粗排已完成", - Content: fmt.Sprintf( + // 8. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入验证和微调。 + stillPending := countPendingTasks(scheduleState) + var pinnedContent string + if stillPending > 0 { + pinnedContent = fmt.Sprintf( "后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+ + "注意:仍有 %d 个任务未被粗排覆盖,处于待安排(pending)状态,必须在微调阶段手动安排完毕。\n\n"+ + "处理 pending 任务的正确操作顺序:\n"+ + "1. 调用 get_overview 或 find_free 确认可用空位(不要反复调用 list_tasks,list_tasks 只能看任务列表,看不出空位)\n"+ + "2. 调用 place 将 pending 任务放入空位\n"+ + "3. 重复上述步骤,直到 get_overview 显示待安排任务剩余为 0\n\n"+ + "微调完成的判定标准:所有 pending 任务均已 place(待安排任务剩余=0),且现有排课无明显失衡。\n"+ + "无需再次触发粗排。", + len(placements), stillPending, + ) + } else { + pinnedContent = fmt.Sprintf( + "后端已自动运行粗排算法,初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+ "请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+ - "无需再次触发粗排,也不要在 plan_steps 里描述触发粗排相关的操作。", + "无需再次触发粗排。", len(placements), - ), + ) + } + st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{ + Key: "rough_build_done", + Title: "粗排已完成", + Content: pinnedContent, }) // 9. 清除标记,进入执行阶段。 @@ -99,6 +115,24 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e return nil } +// countPendingTasks 统计粗排后仍无位置的待安排任务数。 +// +// 粗排只设 Slots,不改 Status(仍为 "pending"), +// 所以"真正未覆盖"= pending 且 Slots 为空,需要手动 place。 +func countPendingTasks(state *newagenttools.ScheduleState) int { + if state == nil { + return 0 + } + count := 0 + for i := range state.Tasks { + t := &state.Tasks[i] + if t.Status == "pending" && len(t.Slots) == 0 { + count++ + } + } + return count +} + // applyRoughBuildPlacements 把粗排结果写入 ScheduleState 对应任务的 Slots。 // // 设计说明: diff --git a/backend/newAgent/prompt/base.go b/backend/newAgent/prompt/base.go index db5ae24..9c343c8 100644 --- a/backend/newAgent/prompt/base.go +++ b/backend/newAgent/prompt/base.go @@ -10,26 +10,28 @@ import ( // buildStageMessages 组装某个阶段通用的 messages。 // -// 步骤说明: -// 1. 先合并 context 自带 system prompt 与阶段 prompt,保证通用约束和阶段约束都生效; -// 2. 再把置顶上下文块和工具摘要补成 system message,尽量顶在 history 前面; -// 3. 最后追加历史消息与本轮 user prompt,保持"新约束在前、历史在后"的稳定顺序。 +// 消息排列策略(利用 LLM 近因效应): +// 1. system prompt(角色 + 阶段规则)— 始终最顶部,定义基本身份; +// 2. tool schemas(能力边界)— 稳定参考信息,放在 history 前即可; +// 3. history(对话历史、工具调用、修正反馈)— 按时间顺序排列; +// 4. pinned blocks(当前计划、当前步骤、粗排结果等最新约束)— 紧贴 user prompt, +// 利用近因效应让 LLM 优先关注本轮最相关的约束,而非被历史消息分散注意力; +// 5. user prompt(阶段性指令)— 始终在末尾,是本轮回答的核心触发。 func buildStageMessages(stageSystemPrompt string, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) []*schema.Message { messages := make([]*schema.Message, 0, 4) + // 1. 合并 system prompt:基础角色约束 + 阶段规则,始终在最顶部。 mergedSystemPrompt := mergeSystemPrompts(ctx, stageSystemPrompt) if mergedSystemPrompt != "" { messages = append(messages, schema.SystemMessage(mergedSystemPrompt)) } - if pinnedText := renderPinnedBlocks(ctx); pinnedText != "" { - messages = append(messages, schema.SystemMessage(pinnedText)) - } - + // 2. 工具摘要:稳定参考信息,放在 history 前即可。 if toolText := renderToolSchemas(ctx); toolText != "" { messages = append(messages, schema.SystemMessage(toolText)) } + // 3. 对话历史:按时间顺序,包含工具调用结果和修正反馈。 if ctx != nil { history := ctx.HistorySnapshot() if len(history) > 0 { @@ -48,6 +50,13 @@ func buildStageMessages(stageSystemPrompt string, ctx *newagentmodel.Conversatio } } + // 4. 置顶上下文块:当前计划、当前步骤、粗排结果等最新约束。 + // 放在 history 之后、user prompt 之前,利用 LLM 近因效应提升对最新约束的注意力。 + if pinnedText := renderPinnedBlocks(ctx); pinnedText != "" { + messages = append(messages, schema.SystemMessage(pinnedText)) + } + + // 5. 阶段性用户提示词:始终在末尾,是本轮回答的核心触发。 runtimeUserPrompt = strings.TrimSpace(runtimeUserPrompt) if runtimeUserPrompt != "" { messages = append(messages, schema.UserMessage(runtimeUserPrompt)) diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 63ee89d..6d537bf 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -196,9 +196,9 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string { return strings.TrimSpace(sb.String()) } - if currentStep, ok := state.CurrentPlanStep(); ok { + if _, ok := state.CurrentPlanStep(); ok { sb.WriteString("执行要求:\n") - sb.WriteString("1. 始终围绕下面这个当前步骤行动。\n") + sb.WriteString("1. 始终围绕上方「当前步骤内容」行动。\n") sb.WriteString("2. 若当前步骤未完成,请继续思考-执行-观察循环。\n") sb.WriteString("3. 若当前步骤已完成,请输出 action=next_plan,并填写 goal_check 说明完成依据。\n") sb.WriteString("4. 若整个任务已完成,请输出 action=done,并填写 goal_check 总结整体证据。\n") @@ -206,14 +206,6 @@ func BuildExecuteUserPrompt(state *newagentmodel.CommonState) string { sb.WriteString("6. 输出 next_plan 或 done 时,goal_check 不能为空,必须对照 done_when 逐条验证。\n") sb.WriteString("\n") sb.WriteString(BuildExecuteDecisionContractText()) - sb.WriteString("\n当前步骤正文:\n") - sb.WriteString(strings.TrimSpace(currentStep.Content)) - sb.WriteString("\n") - if strings.TrimSpace(currentStep.DoneWhen) != "" { - sb.WriteString("\n当前步骤完成判定:\n") - sb.WriteString(strings.TrimSpace(currentStep.DoneWhen)) - sb.WriteString("\n") - } } else { sb.WriteString("当前 plan 已存在,但当前步骤索引无效;请不要擅自执行其他步骤。\n") } diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index be98915..712fba6 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -473,7 +473,8 @@ func (e *ChunkEmitter) emitPseudoText(ctx context.Context, text string, options if emitChunk == nil { return nil } - text = strings.TrimSpace(text) + // 只剥首尾空格和制表符,保留结尾 \n,让上层加的段落分隔符能作为内容的一部分推出。 + text = strings.TrimRight(strings.TrimLeft(text, " \t\r\n"), " \t\r") if text == "" { return nil } @@ -499,7 +500,8 @@ func (e *ChunkEmitter) emitPseudoText(ctx context.Context, text string, options // 2. 若长时间遇不到合适边界,则在 MaxChunkRunes 处强制切块,避免整段卡太久; // 3. 对中文文本优先按 rune 长度处理,避免多字节字符被截断。 func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { - text = strings.TrimSpace(text) + hasTrailingNewline := strings.HasSuffix(strings.TrimRight(text, " \t"), "\n") + text = strings.TrimRight(strings.TrimLeft(text, " \t\r\n"), " \t\r") if text == "" { return nil } @@ -507,6 +509,9 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { options = normalizePseudoStreamOptions(options) runes := []rune(text) if len(runes) <= options.MaxChunkRunes { + if hasTrailingNewline { + return []string{text + "\n"} + } return []string{text} } @@ -543,8 +548,14 @@ func SplitPseudoStreamText(text string, options PseudoStreamOptions) []string { } if len(chunks) == 0 { + if hasTrailingNewline { + return []string{text + "\n"} + } return []string{text} } + if hasTrailingNewline { + chunks[len(chunks)-1] += "\n" + } return chunks } diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 3f856d4..96c891d 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -289,6 +289,25 @@ func readAgentExtraInt(extra map[string]any, key string) int { return value } +func readAgentExtraBool(extra map[string]any, key string) bool { + if len(extra) == 0 { + return false + } + raw, ok := extra[key] + if !ok { + return false + } + switch v := raw.(type) { + case bool: + return v + case float64: + return v != 0 + case string: + return strings.ToLower(strings.TrimSpace(v)) == "true" + } + return false +} + // readAgentExtraIntSlice 从 extra 中提取 []int。 // 支持 JSON 数组格式([]any,每个元素为 float64/int)。 func readAgentExtraIntSlice(extra map[string]any, key string) []int { diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index cde761c..a5eaae6 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -18,6 +18,7 @@ import ( "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/LoveLosita/smartflow/backend/respond" eventsvc "github.com/LoveLosita/smartflow/backend/service/events" ) @@ -101,18 +102,23 @@ func (s *AgentService) runNewAgentGraph( conversationContext = s.loadConversationContext(requestCtx, chatID, userMessage) } - // 5.5 若 extra 携带 task_class_ids,写入 CommonState(仅首轮/尚未设置时生效,跨轮持久化)。 + // 5.5 若 extra 携带 task_class_ids,校验后写入 CommonState(仅首轮/尚未设置时生效,跨轮持久化)。 + // 校验:通过 LoadTaskClassMetas → GetCompleteTaskClassesByIDs 检查所有 ID 是否存在且属于当前用户; + // 校验失败时向 errChan 推送 WrongTaskClassID(code=40040),前端收到 SSE 错误事件。 if taskClassIDs := readAgentExtraIntSlice(extra, "task_class_ids"); len(taskClassIDs) > 0 { cs := runtimeState.EnsureCommonState() if len(cs.TaskClassIDs) == 0 { - cs.TaskClassIDs = taskClassIDs - if s.scheduleProvider != nil { - if metas, metaErr := s.scheduleProvider.LoadTaskClassMetas(requestCtx, userID, taskClassIDs); metaErr != nil { - log.Printf("加载任务类约束元数据失败 chat=%s err=%v", chatID, metaErr) - } else { - cs.TaskClasses = metas - } + if s.scheduleProvider == nil { + pushErrNonBlocking(errChan, respond.WrongTaskClassID) + return } + metas, metaErr := s.scheduleProvider.LoadTaskClassMetas(requestCtx, userID, taskClassIDs) + if metaErr != nil { + pushErrNonBlocking(errChan, respond.WrongTaskClassID) + return + } + cs.TaskClassIDs = taskClassIDs + cs.TaskClasses = metas } } @@ -124,6 +130,7 @@ func (s *AgentService) runNewAgentGraph( graphRequest := newagentmodel.AgentGraphRequest{ UserInput: userMessage, ConfirmAction: confirmAction, + AlwaysExecute: readAgentExtraBool(extra, "always_execute"), } graphRequest.Normalize() @@ -139,16 +146,17 @@ func (s *AgentService) runNewAgentGraph( // 9. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。 deps := newagentmodel.AgentGraphDeps{ - ChatClient: chatClient, - PlanClient: planClient, - ExecuteClient: executeClient, - DeliverClient: deliverClient, - ChunkEmitter: chunkEmitter, - StateStore: s.agentStateStore, - ToolRegistry: s.toolRegistry, - ScheduleProvider: s.scheduleProvider, - SchedulePersistor: s.schedulePersistor, - RoughBuildFunc: s.makeRoughBuildFunc(), + ChatClient: chatClient, + PlanClient: planClient, + ExecuteClient: executeClient, + DeliverClient: deliverClient, + ChunkEmitter: chunkEmitter, + StateStore: s.agentStateStore, + ToolRegistry: s.toolRegistry, + ScheduleProvider: s.scheduleProvider, + SchedulePersistor: s.schedulePersistor, + RoughBuildFunc: s.makeRoughBuildFunc(), + WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(), } // 10. 构造 AgentGraphRunInput 并运行 graph。 @@ -181,23 +189,8 @@ func (s *AgentService) runNewAgentGraph( eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID) } - // 11.6. 将排程结果写入 Redis 预览缓存,复用旧 agent 的 SchedulePlanPreviewCache 格式。 - // 前端通过 GET /agent/schedule-preview 获取,无需改动。 - if finalState != nil && finalState.ScheduleState != nil { - flowState := finalState.EnsureFlowState() - preview := conv.ScheduleStateToPreview( - finalState.ScheduleState, - userID, - chatID, - flowState.TaskClassIDs, - "", // summary 由转换函数自动生成 - ) - if preview != nil && s.cacheDAO != nil { - if err := s.cacheDAO.SetSchedulePlanPreviewToCache(requestCtx, userID, chatID, preview); err != nil { - log.Printf("[WARN] 写入排程预览缓存失败 chat=%s: %v", chatID, err) - } - } - } + // 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func), + // 保证只有任务真正完成时才写,中断路径不写中间态。 // 12. 发送 OpenAI 兼容的流式结束标记,告知客户端 stream 已完成。 _ = chunkEmitter.EmitDone() @@ -425,34 +418,54 @@ func (s *AgentService) persistChatAfterGraph( } // makeRoughBuildFunc 把 AgentService 上的 HybridScheduleWithPlanMultiFunc 封装成 -// newAgent 层的 RoughBuildFunc,完成外层 model.TaskClassItem → RoughBuildPlacement 的转换。 +// newAgent 层的 RoughBuildFunc,将 HybridScheduleWithPlanMultiFunc 的结果转换为 RoughBuildPlacement。 // HybridScheduleWithPlanMultiFunc 未注入时返回 nil,RoughBuild 节点会静默跳过粗排。 +// +// 修复说明: +// 旧实现使用第二个返回值 []TaskClassItem,只有 EmbeddedTime != nil 的条目(嵌入水课)才生成 +// placement,普通时段放置的任务全部被丢弃。 +// 正确做法:使用第一个返回值 []HybridScheduleEntry,过滤 Status="suggested" 且 TaskItemID>0 的条目, +// 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。 func (s *AgentService) makeRoughBuildFunc() newagentmodel.RoughBuildFunc { if s.HybridScheduleWithPlanMultiFunc == nil { return nil } return func(ctx context.Context, userID int, taskClassIDs []int) ([]newagentmodel.RoughBuildPlacement, error) { - _, items, err := s.HybridScheduleWithPlanMultiFunc(ctx, userID, taskClassIDs) + entries, _, err := s.HybridScheduleWithPlanMultiFunc(ctx, userID, taskClassIDs) if err != nil { return nil, err } - placements := make([]newagentmodel.RoughBuildPlacement, 0, len(items)) - for _, item := range items { - if item.EmbeddedTime == nil { + placements := make([]newagentmodel.RoughBuildPlacement, 0, len(entries)) + for _, entry := range entries { + if entry.Status != "suggested" || entry.TaskItemID == 0 { continue } placements = append(placements, newagentmodel.RoughBuildPlacement{ - TaskItemID: item.ID, - Week: item.EmbeddedTime.Week, - DayOfWeek: item.EmbeddedTime.DayOfWeek, - SectionFrom: item.EmbeddedTime.SectionFrom, - SectionTo: item.EmbeddedTime.SectionTo, + TaskItemID: entry.TaskItemID, + Week: entry.Week, + DayOfWeek: entry.DayOfWeek, + SectionFrom: entry.SectionFrom, + SectionTo: entry.SectionTo, }) } return placements, nil } } +// makeWriteSchedulePreviewFunc 封装 cacheDAO 写排程预览缓存的操作,供 Deliver 节点注入。 +func (s *AgentService) makeWriteSchedulePreviewFunc() newagentmodel.WriteSchedulePreviewFunc { + if s.cacheDAO == nil { + return nil + } + return func(ctx context.Context, state *newagenttools.ScheduleState, userID int, conversationID string, taskClassIDs []int) error { + preview := conv.ScheduleStateToPreview(state, userID, conversationID, taskClassIDs, "") + if preview == nil { + return nil + } + return s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, conversationID, preview) + } +} + // --- 依赖注入字段 --- // toolRegistry 由 cmd/start.go 注入 diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go index ab57850..f786301 100644 --- a/backend/service/agentsvc/agent_schedule_preview.go +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -101,6 +101,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c TraceID: strings.TrimSpace(preview.TraceID), Summary: strings.TrimSpace(preview.Summary), CandidatePlans: plans, + HybridEntries: cloneHybridEntries(preview.HybridEntries), GeneratedAt: preview.GeneratedAt, }, nil } @@ -212,6 +213,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap TraceID: strings.TrimSpace(snapshot.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), CandidatePlans: plans, + HybridEntries: cloneHybridEntries(snapshot.HybridEntries), GeneratedAt: generatedAt, } }