Files
smartmate/backend/newAgent/node/rough_build.go
Losita 668af5f6c0 Version: 0.9.31.dev.260419
后端:
1. 日程暂存接口——前端拖拽调整后保存到 Redis 快照
  - api/agent.go:新增 SaveScheduleState handler,解析绝对时间格式请求体,3 秒超时保护
  - routers/routers.go:注册 POST /schedule-state
  - model/agent.go:新增 SaveScheduleStatePlacedItem / SaveScheduleStateRequest 结构体
  - respond/respond.go:新增 5 个排程状态错误码(40058~40062)
  - 新增 service/agentsvc/agent_schedule_state.go:Load 快照 → ApplyPlacedItems → Save 回 Redis,校验归属
  - 新增 newAgent/conv/schedule_state_apply.go:ApplyPlacedItems 绝对坐标→相对 day_index 转换,去重/坐标/嵌入关系校验
2. SchedulePersistor 持久化层全面下线
  - 删除 newAgent/conv/schedule_persist.go(280 行,DiffScheduleState → applyChange → 事务写库整条链路)
  - model/state_store.go:移除 SchedulePersistor 接口
  - model/graph_run_state.go / node/execute.go / node/agent_nodes.go / service/agent.go / service/agent_newagent.go /
  cmd/start.go:移除 SchedulePersistor 字段、参数、注入六处
3. schedule_completed 事件推送——deliver 节点排程完毕信号
  - model/common_state.go:新增 HasScheduleChanges 标记,ResetForNextRun 清理
  - node/execute.go / node/rough_build.go:写工具和粗排成功后置 HasScheduleChanges=true
  - node/deliver.go:IsCompleted && HasScheduleChanges 时调用 EmitScheduleCompleted
  - stream/emitter.go:新增 EmitScheduleCompleted 方法
  - stream/openai.go:新增 StreamExtraKindScheduleCompleted + NewScheduleCompletedExtra
4. 预览接口补全 task_class_id
  - model/agent.go:GetSchedulePlanPreviewResponse 新增 TaskClassIDs
  - model/schedule.go:HybridScheduleEntry 新增 TaskClassID
  - conv/schedule_preview.go / service/agent_schedule_preview.go / service/schedule.go:三处透传填充
前端:
5. 排程完毕卡片 + 精排弹窗集成
  - 新增 api/schedule_agent.ts:getSchedulePreview / saveScheduleState / applyBatchIntoSchedule
  - types/dashboard.ts:新增 HybridScheduleEntry / SchedulePreviewData / PlacedItem 类型
  - components/dashboard/AssistantPanel.vue:监听 schedule_completed 事件异步拉取排程渲染卡片,集成 ScheduleResultCard + ScheduleFineTuneModal;confirm 交互从文本消息改为 resume 协议(approve/reject/cancel)
6. ToolTracePrototypeView 原型页新增日程小卡片 + 拖拽编排弹窗演示
7. DashboardView import 区域尺寸微调
2026-04-19 13:53:07 +08:00

345 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package newagentnode
import (
"context"
"fmt"
"log"
"strconv"
"strings"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
const (
roughBuildStageName = "rough_build"
roughBuildStatusBlock = "rough_build.status"
roughBuildSampleLimit = 3
)
type roughBuildApplyStats struct {
AppliedCount int
DayMappingMissCount int
TaskItemMatchMissCount int
DayMappingMissSamples []string
TaskItemMatchMissSamples []string
}
// RunRoughBuildNode 执行粗排节点逻辑。
//
// 步骤说明:
// 1. 推送"正在粗排"状态给前端;
// 2. 从 CommonState 读取 TaskClassIDs确认有需要排课的任务类
// 3. 加载 ScheduleState含 DayMapping
// 4. 调用 RoughBuildFunc 拿到粗排结果([]RoughBuildPlacement
// 5. 把粗排结果写入 ScheduleState把已落位任务标记为 suggested
// 6. 若粗排后仍存在真实 pending则写入正式 abort 结果并结束本轮;
// 7. 否则按“是否需要粗排后立即微调”分流:
// - 无明确微调诉求:直接 Done -> Deliver
// - 有明确微调诉求:进入 Execute。
func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
if st == nil {
return fmt.Errorf("rough build node: state is nil")
}
flowState := st.EnsureFlowState()
emitter := st.EnsureChunkEmitter()
// 1. 推送状态:告知前端进入粗排环节。
_ = emitter.EmitStatus(
roughBuildStatusBlock,
roughBuildStageName,
"rough_building",
"正在为你生成初始排课方案,请稍候。",
true,
)
// 2. 校验依赖。
if st.Deps.RoughBuildFunc == nil {
return fmt.Errorf("rough build node: RoughBuildFunc 未注入")
}
// 3. 读取任务类 IDs。
taskClassIDs := flowState.TaskClassIDs
if len(taskClassIDs) == 0 {
// 没有任务类 ID 时静默跳过粗排,直接进入执行阶段。
flowState.Phase = newagentmodel.PhaseExecuting
flowState.NeedsRoughBuild = false
flowState.NeedsRefineAfterRoughBuild = false
return nil
}
// 4. 加载 ScheduleState含 DayMapping用于坐标转换
scheduleState, err := st.EnsureScheduleState(ctx)
if err != nil {
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
}
if scheduleState == nil {
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
}
// 5. 调用粗排算法。
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
if err != nil {
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
}
// 6. 把粗排结果写入 ScheduleState。
applyStats := applyRoughBuildPlacements(scheduleState, placements)
// 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。
if applyStats.AppliedCount > 0 {
flowState.HasScheduleChanges = true
}
// 7. 先校验粗排后是否仍有真实 pending。
stillPending := countPendingTasks(scheduleState, taskClassIDs)
log.Printf(
"[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d",
taskClassIDs,
len(placements),
applyStats.AppliedCount,
applyStats.DayMappingMissCount,
applyStats.TaskItemMatchMissCount,
stillPending,
len(scheduleState.Tasks),
len(scheduleState.Window.DayMapping),
)
if applyStats.DayMappingMissCount > 0 {
log.Printf(
"[DEBUG] rough_build day_mapping_miss_samples=%v window=%s",
applyStats.DayMappingMissSamples,
summarizeRoughBuildWindow(scheduleState),
)
}
if applyStats.TaskItemMatchMissCount > 0 {
log.Printf(
"[DEBUG] rough_build task_item_match_miss_samples=%v scoped_task_samples=%v",
applyStats.TaskItemMatchMissSamples,
collectScopedTaskSamples(scheduleState, taskClassIDs),
)
}
if stillPending > 0 {
failureMessage := fmt.Sprintf(
"初始排课方案构建异常:粗排后仍有 %d 个任务未获得初始落位。按当前规则,本轮不进入微调,请检查粗排算法或任务数据。",
stillPending,
)
_ = emitter.EmitStatus(
roughBuildStatusBlock,
roughBuildStageName,
"rough_build_failed",
failureMessage,
true,
)
flowState.NeedsRoughBuild = false
flowState.Abort(
roughBuildStageName,
"rough_build_pending_remaining",
failureMessage,
fmt.Sprintf("rough build finished with %d real pending tasks remaining", stillPending),
)
return nil
}
// 8. 计算是否需要“粗排后立即微调”。
//
// 1. 只在“无计划直执行”链路下应用该止血分流;
// 2. 有计划链路依旧进入 execute避免改变既有 plan->execute 语义;
// 3. chat 路由明确标记 needs_refine_after_rough_build=true 时才进微调。
shouldRefineAfterRoughBuild := flowState.HasPlan() || flowState.NeedsRefineAfterRoughBuild
// 9. 推送完成状态(区分“继续微调”与“直接收口”两种路径)。
doneStatus := "rough_build_done"
doneMessage := fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排,进入微调阶段。", len(placements))
if !shouldRefineAfterRoughBuild {
doneStatus = "rough_build_done_no_refine"
doneMessage = fmt.Sprintf("初始排课方案已生成,共 %d 个任务已预排。本轮按默认策略先结束;如需优化,请继续告诉我你的偏好。", len(placements))
}
_ = emitter.EmitStatus(
roughBuildStatusBlock,
roughBuildStageName,
doneStatus,
doneMessage,
false,
)
// 10. 把粗排完成信息写入 pinned context让后续节点能拿到一致事实。
// 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。
idParts := make([]string, len(taskClassIDs))
for i, id := range taskClassIDs {
idParts[i] = strconv.Itoa(id)
}
idStr := strings.Join(idParts, ", ")
pinnedContent := fmt.Sprintf(
"后端已自动运行粗排算法(任务类 ID[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+
"这些预排任务已标记为 suggested表示“可继续优化的建议落位”不是待补排任务。\n"+
"本轮不需要再调用 place也无需再次触发粗排。",
idStr, len(placements),
)
if shouldRefineAfterRoughBuild {
pinnedContent += "\n请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。"
} else {
pinnedContent += "\n当前未收到明确微调偏好流程将先收口如需进一步优化请基于本次结果提出调整要求。"
}
st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{
Key: "rough_build_done",
Title: "粗排已完成",
Content: pinnedContent,
})
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
//
// 1. 无明确微调诉求:直接标记 completedgraph 会路由到 deliver
// 2. 有明确微调诉求:进入 execute 节点继续工具微调;
// 3. 无论哪条路径,都要重置粗排相关标记,避免污染后续轮次。
flowState.NeedsRoughBuild = false
flowState.NeedsRefineAfterRoughBuild = false
if !shouldRefineAfterRoughBuild {
flowState.Done()
return nil
}
flowState.Phase = newagentmodel.PhaseExecuting
return nil
}
// countPendingTasks 统计粗排后仍无位置的待安排任务数。
//
// 说明:
// 1. 第一轮修复后,粗排成功会把任务直接标记为 suggested
// 2. 为兼容旧快照仍按“pending 且 Slots 为空”认定真正未覆盖;
// 3. 只要这里仍大于 0就应视为粗排异常而不是交给 LLM 补排。
func countPendingTasks(state *schedule.ScheduleState, taskClassIDs []int) int {
if state == nil {
return 0
}
count := 0
for i := range state.Tasks {
task := state.Tasks[i]
if !schedule.IsPendingTask(task) {
continue
}
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
continue
}
if schedule.IsPendingTask(task) {
count++
}
}
return count
}
// applyRoughBuildPlacements 把粗排结果写入 ScheduleState 对应任务的 Slots。
//
// 设计说明:
// 1. 通过 task_item_idSourceID定位任务
// 2. 用 DayMapping 把 (week, dayOfWeek) 转为 day_index
// 3. 对成功落位的任务写入 Slots并显式标记为 suggested
// 4. suggested 表示“粗排建议位”,后续可用 move/swap/unplace 微调;
// 5. 转换失败的条目静默跳过,不中断整体流程。
func applyRoughBuildPlacements(
state *schedule.ScheduleState,
placements []newagentmodel.RoughBuildPlacement,
) roughBuildApplyStats {
stats := roughBuildApplyStats{}
if state == nil {
return stats
}
taskIndexByItemID := make(map[int][]int)
for i := range state.Tasks {
task := state.Tasks[i]
if task.Source != "task_item" {
continue
}
taskIndexByItemID[task.SourceID] = append(taskIndexByItemID[task.SourceID], i)
}
for _, p := range placements {
day, ok := state.WeekDayToDay(p.Week, p.DayOfWeek)
if !ok {
stats.DayMappingMissCount++
stats.DayMappingMissSamples = appendPlacementSample(stats.DayMappingMissSamples, p)
continue // DayMapping 里没有对应 day跳过
}
matched := false
for _, index := range taskIndexByItemID[p.TaskItemID] {
t := &state.Tasks[index]
t.Slots = []schedule.TaskSlot{
{Day: day, SlotStart: p.SectionFrom, SlotEnd: p.SectionTo},
}
t.Status = schedule.TaskStatusSuggested
stats.AppliedCount++
matched = true
break
}
if !matched {
stats.TaskItemMatchMissCount++
stats.TaskItemMatchMissSamples = appendPlacementSample(stats.TaskItemMatchMissSamples, p)
}
}
return stats
}
// appendPlacementSample 记录有限数量的 miss 样本,避免 debug 日志爆量。
func appendPlacementSample(samples []string, placement newagentmodel.RoughBuildPlacement) []string {
if len(samples) >= roughBuildSampleLimit {
return samples
}
return append(samples, fmt.Sprintf(
"task_item_id=%d week=%d day=%d sections=%d-%d",
placement.TaskItemID,
placement.Week,
placement.DayOfWeek,
placement.SectionFrom,
placement.SectionTo,
))
}
// summarizeRoughBuildWindow 提供 DayMapping 的紧凑摘要,便于判断窗口是否退化到错误周。
func summarizeRoughBuildWindow(state *schedule.ScheduleState) string {
if state == nil || len(state.Window.DayMapping) == 0 {
return "empty"
}
first := state.Window.DayMapping[0]
last := state.Window.DayMapping[len(state.Window.DayMapping)-1]
return fmt.Sprintf(
"days=%d first=W%dD%d last=W%dD%d",
len(state.Window.DayMapping),
first.Week,
first.DayOfWeek,
last.Week,
last.DayOfWeek,
)
}
// collectScopedTaskSamples 提供当前 state 中可用于匹配的 task_item 样本,便于排查 ID 对不上。
func collectScopedTaskSamples(state *schedule.ScheduleState, taskClassIDs []int) []string {
if state == nil {
return nil
}
samples := make([]string, 0, roughBuildSampleLimit)
for i := range state.Tasks {
task := state.Tasks[i]
if task.Source != "task_item" {
continue
}
if len(taskClassIDs) > 0 && !schedule.IsTaskInRequestedClassScope(task, taskClassIDs) {
continue
}
samples = append(samples, fmt.Sprintf(
"source_id=%d task_class_id=%d status=%s name=%q",
task.SourceID,
task.TaskClassID,
task.Status,
task.Name,
))
if len(samples) >= roughBuildSampleLimit {
break
}
}
return samples
}