Files
smartmate/backend/newAgent/node/rough_build.go
Losita 070d4c3459 Version: 0.9.15.dev.260412
后端:
1. 排程工具从 tools/ 根目录拆分为 tools/schedule 独立子包
- 12 个排程工具文件等价迁入 tools/schedule/,tools/ 根目录仅保留 registry.go 作为统一注册入口
- 所有依赖方(conv / model / node / prompt / service)import 统一切到 schedule 子包
2. Web 搜索工具链落地(tools/web 子包)
- 新增 web_search(结构化检索)与 web_fetch(正文抓取)两个读工具,支持博查 API / mock 降级
- 启动流程按配置选择 provider,未识别类型自动降级为 mock,不阻断主流程
- 执行提示补齐 web 工具使用约束与返回值示例
- config.example.yaml 补齐 websearch 配置段
前端:无
仓库:无
2026-04-12 19:02:54 +08:00

340 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)
// 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
}