Files
smartmate/backend/services/agent/node/rough_build.go
Losita d7184b776b Version: 0.9.75.dev.260505
后端:
1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent
- 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge
- 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段
- 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流
- 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
2026-05-05 16:00:57 +08:00

395 lines
14 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 agentnode
import (
"context"
"fmt"
"log"
"strconv"
"strings"
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
"github.com/LoveLosita/smartflow/backend/services/agent/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 *agentmodel.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 = agentmodel.PhaseExecuting
flowState.NeedsRoughBuild = false
flowState.NeedsRefineAfterRoughBuild = false
return nil
}
// 4. 粗排前强制刷新 ScheduleState避免复用旧快照窗口。
// 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids而不是沿用历史“当前周”窗口。
// 4.2 做法:主动丢弃内存中的旧 state让 EnsureScheduleState 走 provider 重新加载。
// 4.3 失败策略若任务类缺少有效起止日期provider 会返回错误,由上层统一透传并让用户补齐字段。
st.ScheduleState = nil
st.OriginalScheduleState = nil
// 5. 加载 ScheduleState含 DayMapping用于坐标转换
scheduleState, err := st.EnsureScheduleState(ctx)
if err != nil {
// 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。
// 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。
if strings.Contains(err.Error(), "任务类缺少有效时间窗") {
failureMessage := "开始智能编排前我需要任务类的起止日期start_date / end_date。请先补齐时间窗再让我继续排课。"
_ = emitter.EmitStatus(
roughBuildStatusBlock,
roughBuildStageName,
"rough_build_need_time_window",
failureMessage,
true,
)
flowState.NeedsRoughBuild = false
flowState.Abort(
roughBuildStageName,
"rough_build_window_missing",
failureMessage,
err.Error(),
)
return nil
}
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
}
if scheduleState == nil {
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
}
// 6. 调用粗排算法。
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
if err != nil {
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
}
// 7. 把粗排结果写入 ScheduleState。
applyStats := applyRoughBuildPlacements(scheduleState, placements)
// 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。
if applyStats.AppliedCount > 0 {
flowState.HasScheduleChanges = true
}
// 8. 先校验粗排后是否仍有真实 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(agentmodel.ContextBlock{
Key: "rough_build_done",
Title: "粗排已完成",
Content: pinnedContent,
})
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
//
// 1. 无明确微调诉求:直接标记 completedgraph 会路由到 deliver
// 2. 有明确微调诉求:进入 execute 节点继续工具微调;
// 3. 无论哪条路径,都要重置粗排相关标记,避免污染后续轮次。
flowState.NeedsRoughBuild = false
flowState.NeedsRefineAfterRoughBuild = false
if !shouldRefineAfterRoughBuild {
flowState.ActiveOptimizeOnly = false
flowState.Done()
return nil
}
if strings.TrimSpace(flowState.OptimizationMode) == "" {
flowState.OptimizationMode = "first_full"
}
// 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。
// 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面,
// 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。
// 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。
flowState.ActiveOptimizeOnly = true
// 12. 粗排后进入 execute 微调时,补一条一次性 context hook。
//
// 1. 目的:即使这条链路不回 plan也能在 execute 首轮拿到建议工具面analyze + mutation
// 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add仍由 execute 按统一入口消费。
// 3. 回退hook 无效时 execute 会自动忽略并清空,不影响主流程。
flowState.PendingContextHook = &agentmodel.ContextHook{
Domain: agenttools.ToolDomainSchedule,
Packs: []string{
agenttools.ToolPackAnalyze,
agenttools.ToolPackMutation,
},
Reason: "rough_build_post_refine",
}
flowState.Phase = agentmodel.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 []agentmodel.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 agentmodel.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
}