后端: 1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜 2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写 3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态 4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分 5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定 前端: 6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作 仓库: 7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件 PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
395 lines
14 KiB
Go
395 lines
14 KiB
Go
package newagentnode
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strconv"
|
||
"strings"
|
||
|
||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||
"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,避免复用旧快照窗口。
|
||
// 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(newagentmodel.ContextBlock{
|
||
Key: "rough_build_done",
|
||
Title: "粗排已完成",
|
||
Content: pinnedContent,
|
||
})
|
||
|
||
// 11. 清除粗排标记,并按分流结果进入执行或直接收口。
|
||
//
|
||
// 1. 无明确微调诉求:直接标记 completed,graph 会路由到 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 = &newagentmodel.ContextHook{
|
||
Domain: newagenttools.ToolDomainSchedule,
|
||
Packs: []string{
|
||
newagenttools.ToolPackAnalyze,
|
||
newagenttools.ToolPackMutation,
|
||
},
|
||
Reason: "rough_build_post_refine",
|
||
}
|
||
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_id(SourceID)定位任务;
|
||
// 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
|
||
}
|