Version: 0.7.1.dev.260321
feat(agent): ✨ 重构智能排程分流与双通道交付,补齐周级预算并接入连续微调复用 - 🔀 通用路由升级为 action 分流(chat/quick_note_create/task_query/schedule_plan),路由失败直接返回内部错误,不再回落聊天 - 🧭 智能排程链路重构:统一图编排与节点职责,完善日级/周级调优协作与提示词约束 - 📊 周级预算改为“有效周保底 + 负载加权分配”,避免有效周零预算并提升资源利用率 - ⚙️ 日级并发优化细化:按天拆分 DayGroup 并发执行,低收益天(suggested<=2)跳过,单天失败仅回退该天结果并继续全局 - 🧵 周级并发优化细化:按周并发 worker 执行,单周“单步动作”循环(每轮仅 1 个 Move/Swap 或 done),失败周保留原方案不影响其它周 - 🛰️ 新增排程预览双通道:聊天主链路输出终审文本,结构化 candidate_plans 通过 /api/v1/agent/schedule-preview 拉取 - 🗃️ 增补 Redis 预览缓存读写与清理逻辑,新增对应 API、路由、模型与错误码支持 - ♻️ 接入连续对话微调复用:命中同会话历史预览时复用上轮 HybridEntries,避免每轮重跑粗排 - 🛡️ 增加复用保护:仅当本轮与上轮 task_class_ids 集合一致才复用;不一致回退全量粗排 - 🧰 扩展预览缓存字段(task_class_ids/hybrid_entries/allocated_items),支撑微调承接链路 - 🗺️ 更新 README 5.4 Mermaid(总分流图 + 智能排程流转图)并补充决策文档 - ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
This commit is contained in:
@@ -89,6 +89,16 @@ type RoutingDecision struct {
|
||||
Action Action
|
||||
TrustRoute bool
|
||||
Detail string
|
||||
// RouteFailed 标记“控制码路由是否失败”。
|
||||
//
|
||||
// 语义:
|
||||
// 1. true:路由阶段发生异常(模型调用失败、控制码解析失败等);
|
||||
// 2. false:路由阶段正常完成(无论最终 action 是 chat 还是其它分支)。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该字段用于让上层决定“是否直接报错而不是回落聊天”;
|
||||
// 2. 历史行为是失败回落 chat,本字段用于支持新的“失败即报错”策略。
|
||||
RouteFailed bool
|
||||
}
|
||||
|
||||
// DecideActionRouting 通过“模型控制码”决定本次请求走向。
|
||||
@@ -97,21 +107,22 @@ type RoutingDecision struct {
|
||||
// 1. Action=quick_note_create:进入随口记写入图;
|
||||
// 2. Action=task_query:进入任务查询 tool-calling;
|
||||
// 3. Action=chat:进入普通聊天流;
|
||||
// 4. 路由失败时回落 chat,保证可用性优先。
|
||||
// 4. 路由失败时会标记 RouteFailed=true,由上层直接返回内部错误。
|
||||
func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
|
||||
decision, err := routeByModelControlTag(ctx, selectedModel, userMessage)
|
||||
if err != nil {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
|
||||
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
|
||||
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
|
||||
} else {
|
||||
log.Printf("通用分流控制码失败,回落 chat: err=%v parent_deadline=none route_timeout_ms=%d",
|
||||
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d",
|
||||
err, ControlTimeout.Milliseconds())
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +133,10 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user
|
||||
reason = "识别到新增任务请求,准备执行随口记流程。"
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionQuickNoteCreate,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
Action: ActionQuickNoteCreate,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
RouteFailed: false,
|
||||
}
|
||||
case ActionTaskQuery:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
@@ -132,9 +144,10 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user
|
||||
reason = "识别到任务查询请求,准备调用任务查询工具。"
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionTaskQuery,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
Action: ActionTaskQuery,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
RouteFailed: false,
|
||||
}
|
||||
case ActionSchedulePlan:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
@@ -142,23 +155,26 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user
|
||||
reason = "识别到排程请求,准备执行智能排程流程。"
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionSchedulePlan,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
Action: ActionSchedulePlan,
|
||||
TrustRoute: true,
|
||||
Detail: reason,
|
||||
RouteFailed: false,
|
||||
}
|
||||
case ActionChat:
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: false,
|
||||
}
|
||||
default:
|
||||
// 兜底:未知动作一律回落 chat,避免误入错误分支。
|
||||
log.Printf("通用分流出现未知动作,回落 chat: action=%s raw=%s", decision.Action, decision.Raw)
|
||||
// 兜底:未知动作视为路由异常,标记 RouteFailed 让上层统一报错。
|
||||
log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw)
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,9 +289,10 @@ func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, u
|
||||
return decision
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: decision.RouteFailed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
315
backend/agent/scheduleplan/daily_refine.go
Normal file
315
backend/agent/scheduleplan/daily_refine.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package scheduleplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// dailyReactRoundTimeout 是日内单轮模型调用超时。
|
||||
// 日内节点走并发调用,超时要比周级更保守,避免占满资源。
|
||||
dailyReactRoundTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
// runDailyRefineNode 负责“并发日内优化”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责按 DayGroup 并发调用单日 ReAct;
|
||||
// 2. 负责输出“按天开始/完成”的阶段状态块(不推 reasoning 细流);
|
||||
// 3. 负责把单日失败回退到原始数据,确保全链路可继续;
|
||||
// 4. 不负责跨天配平(交给 weekly_refine),不负责最终总结(交给 final_check)。
|
||||
func runDailyRefineNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
chatModel *ark.ChatModel,
|
||||
dailyRefineConcurrency int,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
if st == nil || len(st.DailyGroups) == 0 {
|
||||
return st, nil
|
||||
}
|
||||
if chatModel == nil {
|
||||
return st, fmt.Errorf("schedule plan daily refine: model is nil")
|
||||
}
|
||||
|
||||
// 1. 并发度兜底:
|
||||
// 1.1 优先使用注入参数;
|
||||
// 1.2 若注入参数非法,则回退到 state 值;
|
||||
// 1.3 state 也非法时,回退到编译期默认值。
|
||||
if dailyRefineConcurrency <= 0 {
|
||||
dailyRefineConcurrency = st.DailyRefineConcurrency
|
||||
}
|
||||
if dailyRefineConcurrency <= 0 {
|
||||
dailyRefineConcurrency = schedulePlanDefaultDailyRefineConcurrency
|
||||
}
|
||||
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.start",
|
||||
fmt.Sprintf("正在并发优化各天日程,并发度=%d。", dailyRefineConcurrency),
|
||||
)
|
||||
|
||||
// 2. 拉平所有 DayGroup 并排序,确保日志与阶段输出稳定可读。
|
||||
allGroups := flattenAndSortDayGroups(st.DailyGroups)
|
||||
if len(allGroups) == 0 {
|
||||
st.DailyResults = make(map[int]map[int][]model.HybridScheduleEntry)
|
||||
emitStage("schedule_plan.daily_refine.done", "没有可优化的天,跳过日内优化。")
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 3. 并发执行:
|
||||
// 3.1 sem 控制并发上限;
|
||||
// 3.2 wg 等待全部 goroutine 完成;
|
||||
// 3.3 mu 保护 results/firstErr,避免竞态。
|
||||
sem := make(chan struct{}, dailyRefineConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
totalGroups := int32(len(allGroups))
|
||||
var finishedGroups int32
|
||||
|
||||
results := make(map[int]map[int][]model.HybridScheduleEntry)
|
||||
var firstErr error
|
||||
|
||||
for _, group := range allGroups {
|
||||
g := group
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// 3.4 先申请并发令牌;若 ctx 已取消,直接回退原始数据并结束。
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = ctx.Err()
|
||||
}
|
||||
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
|
||||
mu.Unlock()
|
||||
// 3.4.1 取消场景也要计入进度,避免前端看到“卡住不动”。
|
||||
done := atomic.AddInt32(&finishedGroups, 1)
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.day_done",
|
||||
fmt.Sprintf("W%dD%d 已取消并回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.day_start",
|
||||
fmt.Sprintf("正在安排 W%dD%d。(当前进度 %d/%d)", g.Week, g.DayOfWeek, atomic.LoadInt32(&finishedGroups), totalGroups),
|
||||
)
|
||||
|
||||
// 3.5 低收益天直接跳过模型调用,原样透传。
|
||||
if g.SkipRefine {
|
||||
mu.Lock()
|
||||
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
|
||||
mu.Unlock()
|
||||
done := atomic.AddInt32(&finishedGroups, 1)
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.day_done",
|
||||
fmt.Sprintf("W%dD%d suggested 较少,已跳过优化。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.6 深拷贝输入,避免并发场景下意外修改共享切片。
|
||||
localEntries := deepCopyEntries(g.Entries)
|
||||
|
||||
// 3.7 动态轮次:
|
||||
// 3.7.1 suggested <= 4:1轮足够;
|
||||
// 3.7.2 suggested > 4:最多2轮,提升复杂天优化质量。
|
||||
maxRounds := 1
|
||||
if countSuggested(localEntries) > 4 {
|
||||
maxRounds = 2
|
||||
}
|
||||
|
||||
optimized, refineErr := runSingleDayReact(ctx, chatModel, localEntries, maxRounds, g.Week, g.DayOfWeek)
|
||||
if refineErr != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = refineErr
|
||||
}
|
||||
// 3.8 单天失败回退:
|
||||
// 3.8.1 保证失败只影响该天;
|
||||
// 3.8.2 保证总流程可继续推进到 merge/weekly/final。
|
||||
ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
|
||||
mu.Unlock()
|
||||
done := atomic.AddInt32(&finishedGroups, 1)
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.day_done",
|
||||
fmt.Sprintf("W%dD%d 优化失败,已回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
ensureDayResult(results, g.Week, g.DayOfWeek, optimized)
|
||||
mu.Unlock()
|
||||
done := atomic.AddInt32(&finishedGroups, 1)
|
||||
emitStage(
|
||||
"schedule_plan.daily_refine.day_done",
|
||||
fmt.Sprintf("W%dD%d 已安排完成。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
st.DailyResults = results
|
||||
if firstErr != nil {
|
||||
emitStage("schedule_plan.daily_refine.partial_error", fmt.Sprintf("部分天优化失败,已自动回退。原因:%s", firstErr.Error()))
|
||||
}
|
||||
emitStage("schedule_plan.daily_refine.done", "日内优化阶段完成。")
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// runSingleDayReact 执行单天封闭式 ReAct 优化。
|
||||
//
|
||||
// 关键约束:
|
||||
// 1. prompt 只包含当天数据;
|
||||
// 2. 代码层再做“Move 不能跨天”硬校验;
|
||||
// 3. Thinking 默认关闭,优先降低日内并发阶段的长尾时延。
|
||||
func runSingleDayReact(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
entries []model.HybridScheduleEntry,
|
||||
maxRounds int,
|
||||
week int,
|
||||
dayOfWeek int,
|
||||
) ([]model.HybridScheduleEntry, error) {
|
||||
hybridJSON, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(SchedulePlanDailyReactPrompt),
|
||||
schema.UserMessage(fmt.Sprintf(
|
||||
"以下是今天的日程(JSON):\n%s\n\n仅优化这一天的数据,不要跨天移动。",
|
||||
string(hybridJSON),
|
||||
)),
|
||||
}
|
||||
|
||||
for round := 0; round < maxRounds; round++ {
|
||||
roundCtx, cancel := context.WithTimeout(ctx, dailyReactRoundTimeout)
|
||||
resp, generateErr := chatModel.Generate(
|
||||
roundCtx,
|
||||
messages,
|
||||
// 1. 日内优化只做“单天局部微调”,任务边界清晰,默认关闭 thinking 以降低时延。
|
||||
// 2. 周级全局配平仍保留 thinking(在 weekly_refine),这里不承担跨天复杂推理职责。
|
||||
// 3. 若后续观测到质量回退,可只在 suggested 很多时按条件重开 thinking,而不是全量开启。
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||||
)
|
||||
cancel()
|
||||
if generateErr != nil {
|
||||
return entries, fmt.Errorf("日内 ReAct 第%d轮失败: %w", round+1, generateErr)
|
||||
}
|
||||
if resp == nil {
|
||||
return entries, fmt.Errorf("日内 ReAct 第%d轮返回为空", round+1)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
parsed, parseErr := parseReactLLMOutput(content)
|
||||
if parseErr != nil {
|
||||
// 解析失败时回退当前轮,不把异常向上放大成整条链路失败。
|
||||
return entries, nil
|
||||
}
|
||||
if parsed.Done || len(parsed.ToolCalls) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 1. 执行工具调用。
|
||||
// 1.1 每个调用都经过“日内策略约束”校验;
|
||||
// 1.2 任何单次调用失败都只返回 failed result,不中断整轮。
|
||||
results := make([]reactToolResult, 0, len(parsed.ToolCalls))
|
||||
for _, call := range parsed.ToolCalls {
|
||||
var result reactToolResult
|
||||
entries, result = dispatchDailyReactTool(entries, call, week, dayOfWeek)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// 2. 把“本轮模型输出 + 工具执行结果”拼入下一轮上下文。
|
||||
// 2.1 这样模型可以看到操作反馈,继续迭代;
|
||||
// 2.2 若下一轮仍无有效动作,会自然在 done/空 tool_calls 退出。
|
||||
messages = append(messages, schema.AssistantMessage(content, nil))
|
||||
resultJSON, _ := json.Marshal(results)
|
||||
messages = append(messages, schema.UserMessage(
|
||||
fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化或输出 {\"done\":true,\"summary\":\"...\"}。", string(resultJSON)),
|
||||
))
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// dispatchDailyReactTool 在通用工具分发前增加“日内硬约束”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责校验 Move 的目标是否仍在当前天;
|
||||
// 2. 通过后复用 dispatchReactTool 执行;
|
||||
// 3. 不负责复杂冲突判定(冲突判定由底层工具函数处理)。
|
||||
func dispatchDailyReactTool(entries []model.HybridScheduleEntry, call reactToolCall, week int, dayOfWeek int) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
if call.Tool == "Move" {
|
||||
toWeek, weekOK := paramInt(call.Params, "to_week")
|
||||
toDay, dayOK := paramInt(call.Params, "to_day")
|
||||
if !weekOK || !dayOK {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: "参数缺失:to_week/to_day",
|
||||
}
|
||||
}
|
||||
if toWeek != week || toDay != dayOfWeek {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("日内优化禁止跨天移动:当前仅允许 W%dD%d", week, dayOfWeek),
|
||||
}
|
||||
}
|
||||
}
|
||||
return dispatchReactTool(entries, call)
|
||||
}
|
||||
|
||||
// flattenAndSortDayGroups 把 map 结构摊平成有序切片,便于稳定并发调度。
|
||||
func flattenAndSortDayGroups(groups map[int]map[int]*DayGroup) []*DayGroup {
|
||||
out := make([]*DayGroup, 0)
|
||||
for _, dayMap := range groups {
|
||||
for _, g := range dayMap {
|
||||
if g != nil {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Week != out[j].Week {
|
||||
return out[i].Week < out[j].Week
|
||||
}
|
||||
return out[i].DayOfWeek < out[j].DayOfWeek
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ensureDayResult 确保 results[week][day] 存在并写入值。
|
||||
func ensureDayResult(results map[int]map[int][]model.HybridScheduleEntry, week int, day int, entries []model.HybridScheduleEntry) {
|
||||
if results[week] == nil {
|
||||
results[week] = make(map[int][]model.HybridScheduleEntry)
|
||||
}
|
||||
results[week][day] = entries
|
||||
}
|
||||
|
||||
// deepCopyEntries 深拷贝 HybridScheduleEntry 切片。
|
||||
func deepCopyEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||||
dst := make([]model.HybridScheduleEntry, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
93
backend/agent/scheduleplan/daily_split.go
Normal file
93
backend/agent/scheduleplan/daily_split.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package scheduleplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// runDailySplitNode 负责“按天拆分 + 标签注入 + 跳过判断”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把全量 HybridEntries 拆成 DayGroup,供后续并发日内优化;
|
||||
// 2. 负责把 TaskTags(task_item_id -> tag) 注入到条目的 ContextTag;
|
||||
// 3. 负责识别“低收益天”(suggested<=2)并标记 SkipRefine;
|
||||
// 4. 不负责调用模型,不负责并发执行,不负责结果合并。
|
||||
func runDailySplitNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
_ = ctx
|
||||
if st == nil || len(st.HybridEntries) == 0 {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.daily_split.start", "正在按天拆分排程并标记优化单元。")
|
||||
|
||||
// 1. 初始化容器:
|
||||
// 1.1 groups 以 week/day 二级索引保存 DayGroup;
|
||||
// 1.2 这么做的目的是后续 daily_refine 可以直接并发遍历,不再重复分组。
|
||||
groups := make(map[int]map[int]*DayGroup)
|
||||
|
||||
// 2. 遍历混合条目,执行“标签注入 + 分组”。
|
||||
for i := range st.HybridEntries {
|
||||
entry := &st.HybridEntries[i]
|
||||
|
||||
// 2.1 仅对 suggested 条目注入 ContextTag。
|
||||
// 2.1.1 existing 条目是固定课表/已落库任务,不参与认知标签优化。
|
||||
// 2.1.2 注入失败时兜底 General,避免后续 prompt 出现空标签。
|
||||
if entry.Status == "suggested" && entry.TaskItemID > 0 {
|
||||
if tag, ok := st.TaskTags[entry.TaskItemID]; ok {
|
||||
entry.ContextTag = normalizeContextTag(tag)
|
||||
} else {
|
||||
entry.ContextTag = "General"
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 建立分组索引。
|
||||
if groups[entry.Week] == nil {
|
||||
groups[entry.Week] = make(map[int]*DayGroup)
|
||||
}
|
||||
if groups[entry.Week][entry.DayOfWeek] == nil {
|
||||
groups[entry.Week][entry.DayOfWeek] = &DayGroup{
|
||||
Week: entry.Week,
|
||||
DayOfWeek: entry.DayOfWeek,
|
||||
}
|
||||
}
|
||||
groups[entry.Week][entry.DayOfWeek].Entries = append(groups[entry.Week][entry.DayOfWeek].Entries, *entry)
|
||||
}
|
||||
|
||||
// 3. 逐天计算 suggested 数量,标记是否跳过日内优化。
|
||||
//
|
||||
// 3.1 为什么阈值设为 <=2:
|
||||
// 3.1.1 suggested 很少时,模型优化收益通常不足以覆盖请求成本;
|
||||
// 3.1.2 直接跳过可减少无效模型调用和阶段等待。
|
||||
// 3.2 失败策略:
|
||||
// 3.2.1 这里只做内存标记,不会失败;
|
||||
// 3.2.2 即使阈值判断不完美,也只影响优化深度,不影响功能正确性。
|
||||
totalDays := 0
|
||||
skipDays := 0
|
||||
for _, dayMap := range groups {
|
||||
for _, dayGroup := range dayMap {
|
||||
totalDays++
|
||||
suggestedCount := 0
|
||||
for _, e := range dayGroup.Entries {
|
||||
if e.Status == "suggested" {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
if suggestedCount <= 2 {
|
||||
dayGroup.SkipRefine = true
|
||||
skipDays++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回填状态,交给后续节点使用。
|
||||
st.DailyGroups = groups
|
||||
emitStage(
|
||||
"schedule_plan.daily_split.done",
|
||||
fmt.Sprintf("已拆分为 %d 天,其中 %d 天跳过日内优化。", totalDays, skipDays),
|
||||
)
|
||||
return st, nil
|
||||
}
|
||||
171
backend/agent/scheduleplan/final_check.go
Normal file
171
backend/agent/scheduleplan/final_check.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package scheduleplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// runFinalCheckNode 负责“终审校验 + 总结生成”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责执行物理校验(冲突、节次越界、数量核对);
|
||||
// 2. 负责在校验失败时回退到 MergeSnapshot;
|
||||
// 3. 负责生成最终给用户看的自然语言总结;
|
||||
// 4. 不负责写库(本期只做预览)。
|
||||
func runFinalCheckNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
chatModel *ark.ChatModel,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("schedule plan final check: nil state")
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.final_check.start", "正在进行终审校验。")
|
||||
|
||||
// 1. 先做物理校验。
|
||||
issues := physicsCheck(st)
|
||||
if len(issues) > 0 {
|
||||
emitStage("schedule_plan.final_check.issues", fmt.Sprintf("发现 %d 个问题,已回退到日内优化结果。", len(issues)))
|
||||
// 1.1 回退策略:
|
||||
// 1.1.1 优先回退到 merge 快照(已经过冲突校验);
|
||||
// 1.1.2 若快照为空,保留当前结果继续走总结,保证可返回。
|
||||
if len(st.MergeSnapshot) > 0 {
|
||||
st.HybridEntries = deepCopyEntries(st.MergeSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成人性化总结。
|
||||
//
|
||||
// 2.1 总结失败不影响主流程;
|
||||
// 2.2 失败时使用兜底文案,保证前端始终有可展示文本。
|
||||
summary, err := generateHumanSummary(ctx, chatModel, st.HybridEntries, st.Constraints, st.WeeklyActionLogs)
|
||||
if err != nil || strings.TrimSpace(summary) == "" {
|
||||
st.FinalSummary = fmt.Sprintf("排程优化完成,共安排了 %d 个任务。", countSuggested(st.HybridEntries))
|
||||
} else {
|
||||
st.FinalSummary = strings.TrimSpace(summary)
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.final_check.done", "终审校验完成。")
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// physicsCheck 执行物理层面校验。
|
||||
//
|
||||
// 校验项:
|
||||
// 1. 时间冲突:同一 slot 不允许多任务占用;
|
||||
// 2. 节次越界:section 必须落在 1..12 且 from<=to;
|
||||
// 3. 数量核对:suggested 数量应与原始 AllocatedItems 数量一致。
|
||||
func physicsCheck(st *SchedulePlanState) []string {
|
||||
issues := make([]string, 0)
|
||||
if st == nil {
|
||||
return append(issues, "state 为空")
|
||||
}
|
||||
|
||||
// 1. 时间冲突校验。
|
||||
if conflict := detectConflicts(st.HybridEntries); conflict != "" {
|
||||
issues = append(issues, "时间冲突:"+conflict)
|
||||
}
|
||||
|
||||
// 2. 节次越界校验。
|
||||
for _, entry := range st.HybridEntries {
|
||||
if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo {
|
||||
issues = append(
|
||||
issues,
|
||||
fmt.Sprintf("节次越界:[%s] W%dD%d 第%d-%d节", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 数量一致性校验。
|
||||
// 3.1 判断依据:suggested 表示“待应用任务块”,应与 allocatedItems 数量匹配;
|
||||
// 3.2 若不匹配,可能表示工具调用丢失或重复覆盖。
|
||||
suggestedCount := countSuggested(st.HybridEntries)
|
||||
if suggestedCount != len(st.AllocatedItems) {
|
||||
issues = append(
|
||||
issues,
|
||||
fmt.Sprintf("任务数量不匹配:suggested=%d,原始分配=%d", suggestedCount, len(st.AllocatedItems)),
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// countSuggested 统计 suggested 条目数量。
|
||||
func countSuggested(entries []model.HybridScheduleEntry) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.Status == "suggested" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// generateHumanSummary 调用模型生成“用户可读”的总结文案。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做读模型,不修改任何 state;
|
||||
// 2. 输出纯文本;
|
||||
// 3. 失败时把错误返回给上层,由上层决定兜底文案。
|
||||
func generateHumanSummary(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
entries []model.HybridScheduleEntry,
|
||||
constraints []string,
|
||||
actionLogs []string,
|
||||
) (string, error) {
|
||||
if chatModel == nil {
|
||||
return "", fmt.Errorf("final summary model is nil")
|
||||
}
|
||||
entriesJSON, _ := json.Marshal(entries)
|
||||
constraintText := "无"
|
||||
if len(constraints) > 0 {
|
||||
constraintText = strings.Join(constraints, "、")
|
||||
}
|
||||
actionLogText := "无"
|
||||
if len(actionLogs) > 0 {
|
||||
// 1. 只取最后 30 条动作日志,避免上下文无限膨胀。
|
||||
// 2. 周级优化是“渐进式动作链”,取尾部更能体现最终收敛过程。
|
||||
// 3. 这里仅做展示收敛,不改原日志,保证调试信息完整保留在 state 中。
|
||||
start := 0
|
||||
if len(actionLogs) > 30 {
|
||||
start = len(actionLogs) - 30
|
||||
}
|
||||
actionLogText = strings.Join(actionLogs[start:], "\n")
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"以下是最终排程方案(JSON):\n%s\n\n用户约束:%s\n\n以下是本次周级优化动作日志(按时间顺序):\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结,重点说明本方案的优点和改进点。",
|
||||
string(entriesJSON),
|
||||
constraintText,
|
||||
actionLogText,
|
||||
)
|
||||
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
[]*schema.Message{
|
||||
schema.SystemMessage(SchedulePlanFinalCheckPrompt),
|
||||
schema.UserMessage(userPrompt),
|
||||
},
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||||
einoModel.WithTemperature(0.4),
|
||||
einoModel.WithMaxTokens(256),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("final summary response is nil")
|
||||
}
|
||||
return strings.TrimSpace(resp.Content), nil
|
||||
}
|
||||
@@ -12,24 +12,31 @@ import (
|
||||
const (
|
||||
// 图节点:意图识别与约束提取
|
||||
schedulePlanGraphNodePlan = "schedule_plan_plan"
|
||||
// 图节点:调用粗排算法生成候选方案
|
||||
schedulePlanGraphNodePreview = "schedule_plan_preview"
|
||||
// 图节点:退出(用于提前终止分支)
|
||||
// 图节点:粗排构建(替代旧 preview + hybridBuild)
|
||||
schedulePlanGraphNodeRoughBuild = "schedule_plan_rough_build"
|
||||
// 图节点:提前退出
|
||||
schedulePlanGraphNodeExit = "schedule_plan_exit"
|
||||
// 图节点:构建混合日程(ReAct 精排前置)
|
||||
schedulePlanGraphNodeHybridBuild = "schedule_plan_hybrid_build"
|
||||
// 图节点:ReAct 精排循环
|
||||
schedulePlanGraphNodeReactRefine = "schedule_plan_react_refine"
|
||||
// 图节点:返回精排预览结果(不落库)
|
||||
// 图节点:按天拆分并注入上下文标签
|
||||
schedulePlanGraphNodeDailySplit = "schedule_plan_daily_split"
|
||||
// 图节点:并发日内优化
|
||||
schedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine"
|
||||
// 图节点:合并日内优化结果
|
||||
schedulePlanGraphNodeMerge = "schedule_plan_merge"
|
||||
// 图节点:周级配平优化(单步动作模式,输出阶段状态)
|
||||
schedulePlanGraphNodeWeeklyRefine = "schedule_plan_weekly_refine"
|
||||
// 图节点:终审校验
|
||||
schedulePlanGraphNodeFinalCheck = "schedule_plan_final_check"
|
||||
// 图节点:返回预览结果(不落库)
|
||||
schedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview"
|
||||
)
|
||||
|
||||
// SchedulePlanGraphRunInput 是运行"智能排程 graph"所需的输入依赖。
|
||||
// SchedulePlanGraphRunInput 是执行“智能排程 graph”所需输入。
|
||||
//
|
||||
// 说明:
|
||||
// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块);
|
||||
// 2) Extra 传递前端附加参数(如 task_class_id);
|
||||
// 3) ChatHistory 用于连续对话微调场景。
|
||||
// 字段说明:
|
||||
// 1. Extra:前端附加参数(重点是 task_class_ids);
|
||||
// 2. ChatHistory:支持连续对话微调;
|
||||
// 3. OutChan/ModelName:保留兼容字段(当前 weekly refine 主要输出阶段状态);
|
||||
// 4. DailyRefineConcurrency/WeeklyAdjustBudget:可选运行参数覆盖。
|
||||
type SchedulePlanGraphRunInput struct {
|
||||
Model *ark.ChatModel
|
||||
State *SchedulePlanState
|
||||
@@ -38,22 +45,30 @@ type SchedulePlanGraphRunInput struct {
|
||||
Extra map[string]any
|
||||
ChatHistory []*schema.Message
|
||||
EmitStage func(stage, detail string)
|
||||
// ── ReAct 精排所需 ──
|
||||
OutChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content
|
||||
ModelName string // 模型名称,用于构造 OpenAI 兼容 chunk
|
||||
|
||||
OutChan chan<- string
|
||||
ModelName string
|
||||
|
||||
DailyRefineConcurrency int
|
||||
WeeklyAdjustBudget int
|
||||
}
|
||||
|
||||
// RunSchedulePlanGraph 执行"智能排程"图编排。
|
||||
// RunSchedulePlanGraph 执行“智能排程”图编排。
|
||||
//
|
||||
// 图结构:
|
||||
// 当前链路:
|
||||
// START
|
||||
// -> plan
|
||||
// -> roughBuild
|
||||
// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
|
||||
// -> finalCheck
|
||||
// -> returnPreview
|
||||
// -> END
|
||||
//
|
||||
// START -> plan -> [branch] -> preview -> [branch] -> hybridBuild -> [branch] -> reactRefine -> returnPreview -> END
|
||||
// | | |
|
||||
// exit exit exit
|
||||
//
|
||||
// 该文件只负责"连线与分支",节点内部逻辑全部下沉到 nodes.go。
|
||||
// 说明:
|
||||
// 1. exit 分支可从 plan/roughBuild 直接提前终止;
|
||||
// 2. 本文件只负责“连线与分支”,节点内业务都在 nodes/daily/weekly 文件中。
|
||||
func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) {
|
||||
// 1. 启动前硬校验:模型、状态、依赖缺一不可。
|
||||
// 1. 启动前硬校验。
|
||||
if input.Model == nil {
|
||||
return nil, errors.New("schedule plan graph: model is nil")
|
||||
}
|
||||
@@ -64,14 +79,20 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 统一封装阶段推送函数,避免各节点反复判空。
|
||||
// 2. 注入运行时配置(可选覆盖)。
|
||||
if input.DailyRefineConcurrency > 0 {
|
||||
input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
|
||||
}
|
||||
if input.WeeklyAdjustBudget > 0 {
|
||||
input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget
|
||||
}
|
||||
|
||||
emitStage := func(stage, detail string) {
|
||||
if input.EmitStage != nil {
|
||||
input.EmitStage(stage, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构造 runner,收口节点依赖。
|
||||
runner := newSchedulePlanRunner(
|
||||
input.Model,
|
||||
input.Deps,
|
||||
@@ -81,100 +102,100 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput)
|
||||
input.ChatHistory,
|
||||
input.OutChan,
|
||||
input.ModelName,
|
||||
input.State.DailyRefineConcurrency,
|
||||
)
|
||||
|
||||
// 4. 创建状态图容器:输入/输出类型都为 *SchedulePlanState。
|
||||
graph := compose.NewGraph[*SchedulePlanState, *SchedulePlanState]()
|
||||
|
||||
// 5. 注册节点。
|
||||
// 3. 注册节点。
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodePreview, compose.InvokableLambda(runner.previewNode)); err != nil {
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeRoughBuild, compose.InvokableLambda(runner.roughBuildNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeHybridBuild, compose.InvokableLambda(runner.hybridBuildNode)); err != nil {
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeDailySplit, compose.InvokableLambda(runner.dailySplitNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeReactRefine, compose.InvokableLambda(runner.reactRefineNode)); err != nil {
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeDailyRefine, compose.InvokableLambda(runner.dailyRefineNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeMerge, compose.InvokableLambda(runner.mergeNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(runner.weeklyRefineNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeFinalCheck, compose.InvokableLambda(runner.finalCheckNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddLambdaNode(schedulePlanGraphNodeReturnPreview, compose.InvokableLambda(runner.returnPreviewNode)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ── 连线 ──
|
||||
|
||||
// 6. START -> plan
|
||||
// 4. 连线:START -> plan
|
||||
if err := graph.AddEdge(compose.START, schedulePlanGraphNodePlan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. plan -> [branch] -> preview | exit
|
||||
// 5. plan 分支:roughBuild | exit
|
||||
if err := graph.AddBranch(schedulePlanGraphNodePlan, compose.NewGraphBranch(
|
||||
runner.nextAfterPlan,
|
||||
map[string]bool{
|
||||
schedulePlanGraphNodePreview: true,
|
||||
schedulePlanGraphNodeExit: true,
|
||||
schedulePlanGraphNodeRoughBuild: true,
|
||||
schedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. preview -> [branch] -> hybridBuild | exit
|
||||
if err := graph.AddBranch(schedulePlanGraphNodePreview, compose.NewGraphBranch(
|
||||
runner.nextAfterPreview,
|
||||
// 6. roughBuild 分支:dailySplit | weeklyRefine | exit
|
||||
if err := graph.AddBranch(schedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
|
||||
runner.nextAfterRoughBuild,
|
||||
map[string]bool{
|
||||
schedulePlanGraphNodeHybridBuild: true,
|
||||
schedulePlanGraphNodeExit: true,
|
||||
schedulePlanGraphNodeDailySplit: true,
|
||||
schedulePlanGraphNodeWeeklyRefine: true,
|
||||
schedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. hybridBuild -> [branch] -> reactRefine | exit
|
||||
if err := graph.AddBranch(schedulePlanGraphNodeHybridBuild, compose.NewGraphBranch(
|
||||
runner.nextAfterHybridBuild,
|
||||
map[string]bool{
|
||||
schedulePlanGraphNodeReactRefine: true,
|
||||
schedulePlanGraphNodeExit: true,
|
||||
},
|
||||
)); err != nil {
|
||||
// 7. 固定边:dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeDailySplit, schedulePlanGraphNodeDailyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 10. reactRefine -> returnPreview(固定边)
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeReactRefine, schedulePlanGraphNodeReturnPreview); err != nil {
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeDailyRefine, schedulePlanGraphNodeMerge); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeMerge, schedulePlanGraphNodeWeeklyRefine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeWeeklyRefine, schedulePlanGraphNodeFinalCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeFinalCheck, schedulePlanGraphNodeReturnPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 11. returnPreview -> END
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeReturnPreview, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 12. exit -> END
|
||||
if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 13. 运行步数上限:plan + preview + hybridBuild + reactRefine + returnPreview = 5 步,
|
||||
// 加余量到 15,防止异常分支导致无限循环。
|
||||
maxSteps := 15
|
||||
|
||||
// 15. 编译图得到可执行实例。
|
||||
// 8. 编译并执行。
|
||||
// 路径最多约 8~9 个节点,保守预留 20 步避免误判。
|
||||
runnable, err := graph.Compile(ctx,
|
||||
compose.WithGraphName("SchedulePlanGraph"),
|
||||
compose.WithMaxRunSteps(maxSteps),
|
||||
compose.WithMaxRunSteps(20),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 16. 执行图并返回最终状态。
|
||||
return runnable.Invoke(ctx, input.State)
|
||||
}
|
||||
|
||||
86
backend/agent/scheduleplan/merge.go
Normal file
86
backend/agent/scheduleplan/merge.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package scheduleplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// runMergeNode 负责“合并日内结果 + 冲突校验 + 回退快照”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 DailyResults 合并回全量 HybridEntries;
|
||||
// 2. 负责执行时间冲突检测;
|
||||
// 3. 负责在冲突时回退原始数据;
|
||||
// 4. 负责产出 MergeSnapshot,供 final_check 失败时回退。
|
||||
func runMergeNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
_ = ctx
|
||||
if st == nil || len(st.DailyResults) == 0 {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.merge.start", "正在合并日内优化结果。")
|
||||
|
||||
// 1. 先保存 merge 前原始数据,作为冲突时的第一层回退兜底。
|
||||
originalEntries := deepCopyEntries(st.HybridEntries)
|
||||
|
||||
// 2. 展平 daily results。
|
||||
merged := make([]model.HybridScheduleEntry, 0)
|
||||
for _, dayMap := range st.DailyResults {
|
||||
for _, dayEntries := range dayMap {
|
||||
merged = append(merged, dayEntries...)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 冲突校验。
|
||||
//
|
||||
// 3.1 判断依据:同一 (week, day, section) 只能有一个条目占用;
|
||||
// 3.2 失败处理:一旦冲突,整批回退到 merge 前原始结果;
|
||||
// 3.3 回退策略:回退后仍继续链路,避免请求直接失败。
|
||||
if conflict := detectConflicts(merged); conflict != "" {
|
||||
st.HybridEntries = originalEntries
|
||||
emitStage("schedule_plan.merge.conflict", fmt.Sprintf("检测到冲突并回退:%s", conflict))
|
||||
} else {
|
||||
st.HybridEntries = merged
|
||||
emitStage("schedule_plan.merge.done", fmt.Sprintf("合并完成,共 %d 个条目。", len(merged)))
|
||||
}
|
||||
|
||||
// 4. 无论是否冲突,都生成“可回退快照”。
|
||||
st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// detectConflicts 检测条目是否存在时间冲突。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 返回空字符串:无冲突;
|
||||
// 2. 返回非空字符串:冲突描述,可直接用于日志/阶段提示。
|
||||
func detectConflicts(entries []model.HybridScheduleEntry) string {
|
||||
type slotKey struct {
|
||||
week, day, section int
|
||||
}
|
||||
occupied := make(map[slotKey]string)
|
||||
for _, entry := range entries {
|
||||
// 1. 仅“阻塞建议任务”的条目参与冲突校验。
|
||||
// 2. 可嵌入且当前未占用的课程槽位不应被判定为冲突。
|
||||
if !entryBlocksSuggested(entry) {
|
||||
continue
|
||||
}
|
||||
for section := entry.SectionFrom; section <= entry.SectionTo; section++ {
|
||||
key := slotKey{week: entry.Week, day: entry.DayOfWeek, section: section}
|
||||
if prevName, exists := occupied[key]; exists {
|
||||
return fmt.Sprintf(
|
||||
"W%dD%d 第%d节 冲突:[%s] 与 [%s]",
|
||||
entry.Week, entry.DayOfWeek, section, prevName, entry.Name,
|
||||
)
|
||||
}
|
||||
occupied[key] = entry.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
@@ -14,25 +15,29 @@ import (
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ── plan 节点模型输出结构 ──
|
||||
|
||||
// schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。
|
||||
//
|
||||
// 兼容说明:
|
||||
// 1. 新主语义是 task_class_ids(数组);
|
||||
// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析;
|
||||
// 3. TaskTags 的 key 兼容两种写法:
|
||||
// 3.1 推荐:task_item_id(例如 "12");
|
||||
// 3.2 兼容:任务名称(例如 "高数复习")。
|
||||
type schedulePlanIntentOutput struct {
|
||||
Intent string `json:"intent"`
|
||||
Constraints []string `json:"constraints"`
|
||||
TaskClassID int `json:"task_class_id"`
|
||||
Strategy string `json:"strategy"`
|
||||
Intent string `json:"intent"`
|
||||
Constraints []string `json:"constraints"`
|
||||
TaskClassIDs []int `json:"task_class_ids"`
|
||||
TaskClassID int `json:"task_class_id"`
|
||||
Strategy string `json:"strategy"`
|
||||
TaskTags map[string]string `json:"task_tags"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// plan 节点
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// runPlanNode 负责"意图识别 + 约束提取"。
|
||||
// runPlanNode 负责“识别排程意图 + 提取约束 + 收敛任务类 ID”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1) 从用户消息中提取排程意图、约束条件、策略;
|
||||
// 2) task_class_id 优先从 Extra 字段获取,模型推断作为兜底;
|
||||
// 3) 不负责调用粗排算法,只做意图分析。
|
||||
// 1. 负责把用户自然语言和 extra 参数收敛为统一状态;
|
||||
// 2. 负责输出后续节点需要的最小上下文(TaskClassIDs/约束/策略/标签);
|
||||
// 3. 不负责调用粗排算法,不负责写库。
|
||||
func runPlanNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
@@ -46,62 +51,80 @@ func runPlanNode(
|
||||
return nil, errors.New("schedule plan graph: nil state in plan node")
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求...")
|
||||
emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求。")
|
||||
|
||||
// 1. 优先从 Extra 字段获取 task_class_id,避免依赖模型推断。
|
||||
// 1. 先收敛 extra 中显式传入的任务类 ID(优先级高于模型推断)。
|
||||
// 1.1 先读 task_class_ids 数组;
|
||||
// 1.2 再兼容读取单值 task_class_id;
|
||||
// 1.3 最后统一做过滤 + 去重,防止非法值或重复值污染状态机。
|
||||
if extra != nil {
|
||||
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
|
||||
st.TaskClassID = tcID
|
||||
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+2)
|
||||
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
|
||||
if tcIDs, ok := ExtraIntSlice(extra, "task_class_ids"); ok {
|
||||
mergedIDs = append(mergedIDs, tcIDs...)
|
||||
}
|
||||
if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
|
||||
mergedIDs = append(mergedIDs, tcID)
|
||||
}
|
||||
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
|
||||
}
|
||||
// 1.4 若本轮请求没带 task_class_ids,但会话里存在上一次排程快照,则用快照中的任务类兜底。
|
||||
// 1.4.1 这样用户可以直接说“把周三晚上的高数挪到周五”,无需每轮都重复传任务类集合;
|
||||
// 1.4.2 失败兜底:若快照也没有任务类,后续按原逻辑处理(可能提前退出并提示补参)。
|
||||
if len(st.TaskClassIDs) == 0 && len(st.PreviousTaskClassIDs) > 0 {
|
||||
st.TaskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
|
||||
}
|
||||
|
||||
// 2. 检查对话历史中是否包含上版排程方案(用于连续对话微调)。
|
||||
// 2. 识别“是否为连续对话微调”场景。
|
||||
// 2.1 只做历史探测,不做历史改写;
|
||||
// 2.2 探测失败不影响主链路,只是少一个 prompt hint。
|
||||
if st.HasPreviousPreview && len(st.PreviousHybridEntries) > 0 {
|
||||
st.IsAdjustment = true
|
||||
}
|
||||
previousPlan := extractPreviousPlanFromHistory(chatHistory)
|
||||
if previousPlan != "" {
|
||||
st.PreviousPlanJSON = previousPlan
|
||||
st.IsAdjustment = true
|
||||
}
|
||||
|
||||
// 3. 构造 prompt 让模型分析意图和约束。
|
||||
// 3. 组装模型提示词。
|
||||
adjustmentHint := ""
|
||||
if st.IsAdjustment {
|
||||
adjustmentHint = "\n注意:这是对已有排程的微调请求。用户可能只想调整部分内容(如'早八不想学习'),请只提取变更部分的约束。"
|
||||
adjustmentHint = "\n注意:这是对已有排程的微调请求,请重点抽取本次新增或变更的约束。"
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`当前时间(北京时间):%s
|
||||
用户输入:%s%s
|
||||
|
||||
请分析用户的排程意图并提取约束条件。`,
|
||||
prompt := fmt.Sprintf(
|
||||
"当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。",
|
||||
st.RequestNowText,
|
||||
strings.TrimSpace(userMessage),
|
||||
adjustmentHint,
|
||||
)
|
||||
|
||||
// 3.1 模型调用失败时保守处理:只要有 task_class_id 就继续,否则报错。
|
||||
// 4. 调模型拿结构化输出。
|
||||
// 4.1 如果失败但已经有 TaskClassIDs,则降级继续;
|
||||
// 4.2 如果失败且没有任务类 ID,直接给出可执行错误提示。
|
||||
raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanIntentPrompt, prompt, 256)
|
||||
if callErr != nil {
|
||||
if st.TaskClassID > 0 {
|
||||
// 有 task_class_id 就可以继续,意图用兜底值。
|
||||
if len(st.TaskClassIDs) > 0 {
|
||||
st.UserIntent = strings.TrimSpace(userMessage)
|
||||
emitStage("schedule_plan.plan.fallback", "意图分析失败,使用默认配置继续。")
|
||||
emitStage("schedule_plan.plan.fallback", "意图识别失败,已使用请求参数兜底继续。")
|
||||
return st, nil
|
||||
}
|
||||
st.FinalSummary = "抱歉,我没能理解你的排程需求,请再描述一下或直接传入任务类 ID。"
|
||||
st.FinalSummary = "抱歉,我没拿到有效的任务类信息。请在请求中传入 task_class_ids。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 3.2 解析模型输出。
|
||||
parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw)
|
||||
if parseErr != nil {
|
||||
if st.TaskClassID > 0 {
|
||||
if len(st.TaskClassIDs) > 0 {
|
||||
st.UserIntent = strings.TrimSpace(userMessage)
|
||||
emitStage("schedule_plan.plan.fallback", "模型返回解析失败,已使用请求参数兜底继续。")
|
||||
return st, nil
|
||||
}
|
||||
st.FinalSummary = "抱歉,我没能解析排程意图,请再试一次。"
|
||||
st.FinalSummary = "抱歉,我没能解析排程意图。请重试,或直接传入 task_class_ids。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 4. 回填状态。
|
||||
// 5. 回填基础字段。
|
||||
st.UserIntent = strings.TrimSpace(parsed.Intent)
|
||||
if st.UserIntent == "" {
|
||||
st.UserIntent = strings.TrimSpace(userMessage)
|
||||
@@ -109,73 +132,57 @@ func runPlanNode(
|
||||
if len(parsed.Constraints) > 0 {
|
||||
st.Constraints = parsed.Constraints
|
||||
}
|
||||
if st.TaskClassID <= 0 && parsed.TaskClassID > 0 {
|
||||
st.TaskClassID = parsed.TaskClassID
|
||||
}
|
||||
if parsed.Strategy == "rapid" {
|
||||
if strings.EqualFold(strings.TrimSpace(parsed.Strategy), "rapid") {
|
||||
st.Strategy = "rapid"
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.plan.done", fmt.Sprintf("已理解排程意图:%s", st.UserIntent))
|
||||
// 6. 合并任务类 ID(新字段 + 旧字段双兼容)。
|
||||
// 6.1 先拼接已有值与模型输出;
|
||||
// 6.2 再统一清洗,保证后续节点使用稳定语义。
|
||||
mergedIDs := make([]int, 0, len(st.TaskClassIDs)+len(parsed.TaskClassIDs)+1)
|
||||
mergedIDs = append(mergedIDs, st.TaskClassIDs...)
|
||||
mergedIDs = append(mergedIDs, parsed.TaskClassIDs...)
|
||||
if parsed.TaskClassID > 0 {
|
||||
mergedIDs = append(mergedIDs, parsed.TaskClassID)
|
||||
}
|
||||
st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
|
||||
|
||||
// 7. 回填任务标签映射(给 daily_split 注入 context_tag 用)。
|
||||
// 7.1 TaskTags(按 task_item_id)优先;
|
||||
// 7.2 无法转成 ID 的 key 先存到 TaskTagHintsByName,等 roughBuild 阶段再映射;
|
||||
// 7.3 单条标签解析失败不影响主流程。
|
||||
if st.TaskTags == nil {
|
||||
st.TaskTags = make(map[int]string)
|
||||
}
|
||||
if st.TaskTagHintsByName == nil {
|
||||
st.TaskTagHintsByName = make(map[string]string)
|
||||
}
|
||||
for rawKey, rawTag := range parsed.TaskTags {
|
||||
tag := normalizeContextTag(rawTag)
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if id, convErr := strconv.Atoi(key); convErr == nil && id > 0 {
|
||||
st.TaskTags[id] = tag
|
||||
continue
|
||||
}
|
||||
st.TaskTagHintsByName[key] = tag
|
||||
}
|
||||
|
||||
emitStage(
|
||||
"schedule_plan.plan.done",
|
||||
fmt.Sprintf("已识别排程意图,任务类数量=%d。", len(st.TaskClassIDs)),
|
||||
)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// preview 节点
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// runPreviewNode 负责调用粗排算法生成候选方案。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1) 调用 SmartPlanningRaw 服务,同时获取展示结构和已分配的任务项;
|
||||
// 2) 展示结构供 SSE 阶段推送给前端预览;
|
||||
// 3) 已分配的任务项供 materialize 节点直接转换为落库请求,无需模型介入。
|
||||
func runPreviewNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
deps SchedulePlanToolDeps,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("schedule plan graph: nil state in preview node")
|
||||
}
|
||||
|
||||
// 1. 校验 task_class_id 必须有效。
|
||||
if st.TaskClassID <= 0 {
|
||||
st.FinalSummary = "缺少任务类 ID,无法生成排程方案。请在请求中传入 task_class_id。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.preview.generating", "正在调用排程算法生成候选方案...")
|
||||
|
||||
// 2. 调用粗排服务,同时拿到展示结构和已分配的任务项。
|
||||
displayPlans, allocatedItems, err := deps.SmartPlanningRaw(ctx, st.UserID, st.TaskClassID)
|
||||
if err != nil {
|
||||
st.FinalSummary = fmt.Sprintf("排程算法执行失败:%s。请检查任务类配置是否正确。", err.Error())
|
||||
return st, nil
|
||||
}
|
||||
|
||||
if len(allocatedItems) == 0 {
|
||||
st.FinalSummary = "排程算法未找到可用时间槽,可能是课表已排满或任务类时间范围内无空闲。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
st.CandidatePlans = displayPlans
|
||||
st.AllocatedItems = allocatedItems
|
||||
emitStage("schedule_plan.preview.done", fmt.Sprintf("已生成候选方案,共 %d 个任务项已分配。", len(allocatedItems)))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 分支决策函数
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// selectNextAfterPlan 根据 plan 节点结果决定下一步。
|
||||
//
|
||||
// 分支规则:
|
||||
// 1) FinalSummary 非空 -> exit(plan 阶段已确定无法继续)
|
||||
// 2) TaskClassID 无效 -> exit
|
||||
// 3) 其余 -> preview
|
||||
// 1. 如果 FinalSummary 已经有内容,说明已确定要提前退出 -> exit;
|
||||
// 2. 如果任务类为空,说明无法继续构建方案 -> exit;
|
||||
// 3. 其余情况 -> roughBuild。
|
||||
func selectNextAfterPlan(st *SchedulePlanState) string {
|
||||
if st == nil {
|
||||
return schedulePlanGraphNodeExit
|
||||
@@ -183,21 +190,174 @@ func selectNextAfterPlan(st *SchedulePlanState) string {
|
||||
if strings.TrimSpace(st.FinalSummary) != "" {
|
||||
return schedulePlanGraphNodeExit
|
||||
}
|
||||
if st.TaskClassID <= 0 {
|
||||
if len(st.TaskClassIDs) == 0 {
|
||||
return schedulePlanGraphNodeExit
|
||||
}
|
||||
return schedulePlanGraphNodePreview
|
||||
return schedulePlanGraphNodeRoughBuild
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// runRoughBuildNode 负责“一次性完成粗排结果构建”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 调用多任务类混排能力,生成 HybridEntries + AllocatedItems;
|
||||
// 2. 把 HybridEntries 转成 CandidatePlans,便于后续预览输出;
|
||||
// 3. 不做 daily/weekly 优化本身,只提供下游输入。
|
||||
func runRoughBuildNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
deps SchedulePlanToolDeps,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("schedule plan graph: nil state in roughBuild node")
|
||||
}
|
||||
if deps.HybridScheduleWithPlanMulti == nil {
|
||||
return nil, errors.New("schedule plan graph: HybridScheduleWithPlanMulti dependency not injected")
|
||||
}
|
||||
|
||||
// callScheduleModelForJSON 调用模型并期望返回 JSON 结果。
|
||||
// 1. 清洗并校验任务类 ID。
|
||||
// 1.1 统一在节点入口做一次最终收敛,避免上游遗漏导致语义漂移;
|
||||
// 1.2 若最终仍为空,直接结束,避免无意义调用下游服务。
|
||||
taskClassIDs := normalizeTaskClassIDs(st.TaskClassIDs)
|
||||
// 1.3 连续对话兜底:若本轮任务类为空且命中历史快照,则回退到上轮任务类集合。
|
||||
if len(taskClassIDs) == 0 && st.IsAdjustment && len(st.PreviousTaskClassIDs) > 0 {
|
||||
taskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
|
||||
}
|
||||
if len(taskClassIDs) == 0 {
|
||||
st.FinalSummary = "缺少有效的任务类 ID,无法生成排程方案。请传入 task_class_ids。"
|
||||
return st, nil
|
||||
}
|
||||
st.TaskClassIDs = taskClassIDs
|
||||
|
||||
// 2. 连续对话微调优先复用上一版混合日程作为起点,避免“每轮都重新粗排”。
|
||||
// 2.1 触发条件:IsAdjustment=true 且 PreviousHybridEntries 非空;
|
||||
// 2.2 失败兜底:若快照不完整(例如 AllocatedItems 为空),会构造最小占位任务块,保持下游校验可运行;
|
||||
// 2.3 回退策略:若没有可复用快照,再走全量粗排构建路径。
|
||||
canReusePreviousPlan := st.IsAdjustment &&
|
||||
len(st.PreviousHybridEntries) > 0 &&
|
||||
sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs)
|
||||
if canReusePreviousPlan {
|
||||
emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。")
|
||||
st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries)
|
||||
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
|
||||
st.AllocatedItems = deepCopyTaskClassItems(st.PreviousAllocatedItems)
|
||||
if len(st.AllocatedItems) == 0 {
|
||||
st.AllocatedItems = buildAllocatedItemsFromHybridEntries(st.HybridEntries)
|
||||
}
|
||||
|
||||
// 2.2 复用模式下同样尝试解析窗口边界,保证周级 Move 约束仍然有效。
|
||||
if deps.ResolvePlanningWindow != nil {
|
||||
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
|
||||
if windowErr != nil {
|
||||
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
|
||||
return st, nil
|
||||
}
|
||||
st.HasPlanningWindow = true
|
||||
st.PlanStartWeek = startWeek
|
||||
st.PlanStartDay = startDay
|
||||
st.PlanEndWeek = endWeek
|
||||
st.PlanEndDay = endDay
|
||||
}
|
||||
|
||||
st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
|
||||
suggestedCount := 0
|
||||
for _, e := range st.HybridEntries {
|
||||
if e.Status == "suggested" {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
emitStage(
|
||||
"schedule_plan.rough_build.done",
|
||||
fmt.Sprintf("已复用历史方案,条目总数=%d,可优化条目=%d。", len(st.HybridEntries), suggestedCount),
|
||||
)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.rough_build.building", "正在构建粗排候选方案。")
|
||||
|
||||
// 3. 调用服务层统一能力构建混合日程。
|
||||
// 3.1 该能力内部会完成“多任务类粗排 + 既有日程合并”;
|
||||
// 3.2 这里不再拆成 preview/hybrid 两段,避免跨节点重复计算。
|
||||
entries, allocatedItems, err := deps.HybridScheduleWithPlanMulti(ctx, st.UserID, taskClassIDs)
|
||||
if err != nil {
|
||||
st.FinalSummary = fmt.Sprintf("构建粗排方案失败:%s。", err.Error())
|
||||
return st, nil
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
st.FinalSummary = "没有生成可优化的排程条目,请检查任务类时间范围或课表占用。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 4. 回填状态。
|
||||
st.HybridEntries = entries
|
||||
st.AllocatedItems = allocatedItems
|
||||
st.CandidatePlans = hybridEntriesToWeekSchedules(entries)
|
||||
|
||||
// 4.1 解析全局排程窗口(可选依赖)。
|
||||
// 4.1.1 目的:给周级 Move 增加“首尾不足一周”的硬边界校验;
|
||||
// 4.1.2 失败策略:若依赖已注入但解析失败,直接结束本次排程,避免带着错误窗口继续优化。
|
||||
if deps.ResolvePlanningWindow != nil {
|
||||
startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
|
||||
if windowErr != nil {
|
||||
st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
|
||||
return st, nil
|
||||
}
|
||||
st.HasPlanningWindow = true
|
||||
st.PlanStartWeek = startWeek
|
||||
st.PlanStartDay = startDay
|
||||
st.PlanEndWeek = endWeek
|
||||
st.PlanEndDay = endDay
|
||||
}
|
||||
|
||||
// 4.2 记录 merge 快照:
|
||||
// 4.2.1 单任务类路径可直接作为 final_check 回退基线;
|
||||
// 4.2.2 多任务类路径后续 merge 节点会覆盖成“日内优化后快照”。
|
||||
st.MergeSnapshot = deepCopyEntries(entries)
|
||||
|
||||
// 5. 把“按名称提示的标签”尽可能映射到 task_item_id。
|
||||
// 5.1 目的:后续 daily_split 统一按 task_item_id 维度写入 context_tag;
|
||||
// 5.2 失败策略:映射不上不报错,后续默认走 General 标签。
|
||||
if st.TaskTags == nil {
|
||||
st.TaskTags = make(map[int]string)
|
||||
}
|
||||
if len(st.TaskTagHintsByName) > 0 {
|
||||
for i := range st.HybridEntries {
|
||||
entry := &st.HybridEntries[i]
|
||||
if entry.Status != "suggested" || entry.TaskItemID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := st.TaskTags[entry.TaskItemID]; exists {
|
||||
continue
|
||||
}
|
||||
if tag, ok := st.TaskTagHintsByName[entry.Name]; ok {
|
||||
st.TaskTags[entry.TaskItemID] = normalizeContextTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "suggested" {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
emitStage(
|
||||
"schedule_plan.rough_build.done",
|
||||
fmt.Sprintf("粗排构建完成,条目总数=%d,可优化条目=%d。", len(entries), suggestedCount),
|
||||
)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// callScheduleModelForJSON 调用模型并要求返回 JSON。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅负责模型调用参数装配,不做业务字段解释;
|
||||
// 2. 统一关闭 thinking,减少路由/抽取场景的延迟和 token 开销。
|
||||
func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
|
||||
if chatModel == nil {
|
||||
return "", errors.New("schedule plan: model is nil")
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(systemPrompt),
|
||||
schema.UserMessage(userPrompt),
|
||||
@@ -225,14 +385,16 @@ func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, sys
|
||||
}
|
||||
|
||||
// parseScheduleJSON 解析模型返回的 JSON 内容。
|
||||
// 兼容 ```json ... ``` 包裹和额外文本。
|
||||
//
|
||||
// 兼容策略:
|
||||
// 1. 兼容 ```json ... ``` 包裹;
|
||||
// 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。
|
||||
func parseScheduleJSON[T any](raw string) (*T, error) {
|
||||
clean := strings.TrimSpace(raw)
|
||||
if clean == "" {
|
||||
return nil, errors.New("empty response")
|
||||
}
|
||||
|
||||
// 兼容 ```json ... ``` 包裹。
|
||||
if strings.HasPrefix(clean, "```") {
|
||||
clean = strings.TrimPrefix(clean, "```json")
|
||||
clean = strings.TrimPrefix(clean, "```")
|
||||
@@ -245,7 +407,6 @@ func parseScheduleJSON[T any](raw string) (*T, error) {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 提取最外层 JSON 对象。
|
||||
start := strings.Index(clean, "{")
|
||||
end := strings.LastIndex(clean, "}")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
@@ -258,16 +419,11 @@ func parseScheduleJSON[T any](raw string) (*T, error) {
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// extractPreviousPlanFromHistory 从对话历史中提取上版排程方案。
|
||||
//
|
||||
// 策略:
|
||||
// 在助手消息中查找包含"排程完成"标记的最近一条,提取其中的方案信息。
|
||||
// 当前版本使用简单的文本匹配,后续可升级为结构化存储。
|
||||
// extractPreviousPlanFromHistory 从对话历史中提取最近一次排程结果文本。
|
||||
func extractPreviousPlanFromHistory(history []*schema.Message) string {
|
||||
if len(history) == 0 {
|
||||
return ""
|
||||
}
|
||||
// 从后往前遍历,找最近的排程成功消息。
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
@@ -281,82 +437,26 @@ func extractPreviousPlanFromHistory(history []*schema.Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// hybridBuild 节点
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// runHybridBuildNode 负责构建"混合日程":将既有日程与粗排建议合并。
|
||||
// runReturnPreviewNode 负责把优化后的 HybridEntries 转成“前端可直接展示”的预览结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1) 调用 HybridScheduleWithPlan 服务方法;
|
||||
// 2) 将结果写入 State.HybridEntries,供 ReAct 精排节点操作;
|
||||
// 3) 同时保留 AllocatedItems,供后续可能的落库使用。
|
||||
func runHybridBuildNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
deps SchedulePlanToolDeps,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("schedule plan graph: nil state in hybridBuild node")
|
||||
}
|
||||
if deps.HybridScheduleWithPlan == nil {
|
||||
return nil, errors.New("schedule plan graph: HybridScheduleWithPlan dependency not injected")
|
||||
}
|
||||
if st.TaskClassID <= 0 {
|
||||
st.FinalSummary = "缺少任务类 ID,无法构建混合日程。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.hybrid.building", "正在构建混合日程...")
|
||||
|
||||
entries, allocatedItems, err := deps.HybridScheduleWithPlan(ctx, st.UserID, st.TaskClassID)
|
||||
if err != nil {
|
||||
st.FinalSummary = fmt.Sprintf("构建混合日程失败:%s", err.Error())
|
||||
return st, nil
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
st.FinalSummary = "混合日程为空,无可优化内容。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
st.HybridEntries = entries
|
||||
st.AllocatedItems = allocatedItems
|
||||
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "suggested" {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
emitStage("schedule_plan.hybrid.done", fmt.Sprintf("混合日程已构建,共 %d 个条目(%d 个可优化)。", len(entries), suggestedCount))
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// returnPreview 节点
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// runReturnPreviewNode 负责将 ReAct 优化后的混合日程转为前端预览格式。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1) 从 HybridEntries 中提取最终排程结果;
|
||||
// 2) 转换为 []UserWeekSchedule 格式(复用 sectionTimeMap);
|
||||
// 3) 设置 FinalSummary 为 ReAct 的优化摘要;
|
||||
// 4) 不落库——用户需确认后再走落库链路。
|
||||
// 1. 把 suggested 结果回填到 AllocatedItems,便于后续确认后直接落库;
|
||||
// 2. 生成 CandidatePlans;
|
||||
// 3. 生成最终文案;
|
||||
// 4. 不执行实际写库。
|
||||
func runReturnPreviewNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
_ = ctx
|
||||
if st == nil {
|
||||
return nil, errors.New("schedule plan graph: nil state in returnPreview node")
|
||||
}
|
||||
|
||||
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览...")
|
||||
emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览。")
|
||||
|
||||
// 1. 将 HybridEntries 中 suggested 的任务回写到 AllocatedItems 的 EmbeddedTime。
|
||||
// 这样后续如果用户确认,可以直接走 materialize → apply 落库。
|
||||
// 1. 把 HybridEntries 中 suggested 的最终位置回填到 AllocatedItems。
|
||||
suggestedMap := make(map[int]*model.HybridScheduleEntry)
|
||||
for i := range st.HybridEntries {
|
||||
e := &st.HybridEntries[i]
|
||||
@@ -374,24 +474,162 @@ func runReturnPreviewNode(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 将 HybridEntries 转为 CandidatePlans([]UserWeekSchedule)供前端展示。
|
||||
// 2. 生成前端预览结构。
|
||||
st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
|
||||
|
||||
// 3. 设置最终摘要。
|
||||
if strings.TrimSpace(st.ReactSummary) != "" {
|
||||
st.FinalSummary = st.ReactSummary
|
||||
} else {
|
||||
st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排。请确认后落库。", len(suggestedMap))
|
||||
// 3. 生成最终摘要:
|
||||
// 3.1 优先保留 final_check 的输出;
|
||||
// 3.2 若没有 final_check 输出,则回退 weekly refine 摘要;
|
||||
// 3.3 都没有时给兜底文案。
|
||||
if strings.TrimSpace(st.FinalSummary) == "" {
|
||||
if strings.TrimSpace(st.ReactSummary) != "" {
|
||||
st.FinalSummary = st.ReactSummary
|
||||
} else {
|
||||
st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排,请确认后应用。", len(suggestedMap))
|
||||
}
|
||||
}
|
||||
st.Completed = true
|
||||
|
||||
emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待确认。")
|
||||
emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待你确认。")
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// hybridEntriesToWeekSchedules 将混合日程条目转为前端展示格式。
|
||||
// buildAllocatedItemsFromHybridEntries 根据 suggested 条目构造最小可用的任务块快照。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 连续微调复用历史方案时,若缓存里没有 AllocatedItems,仍然保证 final_check 的数量核对可运行;
|
||||
// 2. return_preview 仍可依据 TaskItemID 回填最终 embedded_time;
|
||||
// 3. 该函数只做“兜底构造”,不替代真实粗排输出。
|
||||
func buildAllocatedItemsFromHybridEntries(entries []model.HybridScheduleEntry) []model.TaskClassItem {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]model.TaskClassItem, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.Status != "suggested" {
|
||||
continue
|
||||
}
|
||||
embedded := &model.TargetTime{
|
||||
Week: entry.Week,
|
||||
DayOfWeek: entry.DayOfWeek,
|
||||
SectionFrom: entry.SectionFrom,
|
||||
SectionTo: entry.SectionTo,
|
||||
}
|
||||
taskID := entry.TaskItemID
|
||||
items = append(items, model.TaskClassItem{
|
||||
ID: taskID,
|
||||
EmbeddedTime: embedded,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// deepCopyTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨节点共享引用。
|
||||
func deepCopyTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]model.TaskClassItem, 0, len(src))
|
||||
for _, item := range src {
|
||||
copied := item
|
||||
if item.CategoryID != nil {
|
||||
v := *item.CategoryID
|
||||
copied.CategoryID = &v
|
||||
}
|
||||
if item.Order != nil {
|
||||
v := *item.Order
|
||||
copied.Order = &v
|
||||
}
|
||||
if item.Content != nil {
|
||||
v := *item.Content
|
||||
copied.Content = &v
|
||||
}
|
||||
if item.Status != nil {
|
||||
v := *item.Status
|
||||
copied.Status = &v
|
||||
}
|
||||
if item.EmbeddedTime != nil {
|
||||
t := *item.EmbeddedTime
|
||||
copied.EmbeddedTime = &t
|
||||
}
|
||||
dst = append(dst, copied)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// normalizeContextTag 归一化任务标签。
|
||||
//
|
||||
// 失败兜底:
|
||||
// 1. 未识别/空值统一回落到 General;
|
||||
// 2. 保证后续 prompt 构造不会出现空标签。
|
||||
func normalizeContextTag(raw string) string {
|
||||
tag := strings.TrimSpace(raw)
|
||||
if tag == "" {
|
||||
return "General"
|
||||
}
|
||||
switch strings.ToLower(tag) {
|
||||
case "high-logic", "high_logic", "logic":
|
||||
return "High-Logic"
|
||||
case "memory":
|
||||
return "Memory"
|
||||
case "review":
|
||||
return "Review"
|
||||
case "general":
|
||||
return "General"
|
||||
default:
|
||||
return "General"
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeTaskClassIDs 清洗 task_class_ids(去重 + 过滤非法值)。
|
||||
func normalizeTaskClassIDs(ids []int) []int {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
out := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameTaskClassSet 判断两组 task_class_ids 是否表示同一集合(忽略顺序,忽略重复)。
|
||||
//
|
||||
// 语义:
|
||||
// 1. 两边经清洗后都为空,返回 false(空集合不作为“可复用历史方案”的依据);
|
||||
// 2. 元素集合完全一致返回 true;
|
||||
// 3. 任一元素差异返回 false。
|
||||
func sameTaskClassSet(left []int, right []int) bool {
|
||||
l := normalizeTaskClassIDs(left)
|
||||
r := normalizeTaskClassIDs(right)
|
||||
if len(l) == 0 || len(r) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(l) != len(r) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(l))
|
||||
for _, id := range l {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range r {
|
||||
if _, ok := seen[id]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hybridEntriesToWeekSchedules 把内存中的混合条目转换成前端周视图格式。
|
||||
func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule {
|
||||
// sectionTimeMap 与 conv/schedule.go 保持一致。
|
||||
sectionTimeMap := map[int][2]string{
|
||||
1: {"08:00", "08:45"}, 2: {"08:55", "09:40"},
|
||||
3: {"10:15", "11:00"}, 4: {"11:10", "11:55"},
|
||||
@@ -401,7 +639,6 @@ func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.U
|
||||
11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
|
||||
}
|
||||
|
||||
// 按周分组
|
||||
weekMap := make(map[int][]model.WeeklyEventBrief)
|
||||
for _, e := range entries {
|
||||
startTime := ""
|
||||
@@ -428,12 +665,13 @@ func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.U
|
||||
weekMap[e.Week] = append(weekMap[e.Week], brief)
|
||||
}
|
||||
|
||||
// 排序输出
|
||||
result := make([]model.UserWeekSchedule, 0, len(weekMap))
|
||||
for w, events := range weekMap {
|
||||
result = append(result, model.UserWeekSchedule{Week: w, Events: events})
|
||||
for week, events := range weekMap {
|
||||
result = append(result, model.UserWeekSchedule{
|
||||
Week: week,
|
||||
Events: events,
|
||||
})
|
||||
}
|
||||
// 按周次排序
|
||||
for i := 0; i < len(result); i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if result[j].Week < result[i].Week {
|
||||
|
||||
@@ -3,18 +3,29 @@ package scheduleplan
|
||||
const (
|
||||
// SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
|
||||
//
|
||||
// 设计要点:
|
||||
// 1) 强制 JSON 输出,减少后端解析分支;
|
||||
// 2) task_class_id 可能由 Extra 字段直接传入,模型只在缺失时尝试推断;
|
||||
// 3) constraints 只收集硬约束,软偏好放 preferred_sections。
|
||||
// 职责边界:
|
||||
// 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行;
|
||||
// 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
|
||||
// 3. 不负责做排程计算,不负责做工具调用。
|
||||
//
|
||||
// 输出约束:
|
||||
// 1. 必须只输出 JSON,禁止附加解释文本;
|
||||
// 2. task_class_ids 是主语义;
|
||||
// 3. task_class_id 仅作为兼容字段保留,便于老链路平滑过渡。
|
||||
SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
|
||||
请根据用户输入,提取排程意图与约束条件。
|
||||
|
||||
必须完成以下任务:
|
||||
1) 用一句话概括用户的排程意图(intent)。
|
||||
2) 提取所有硬约束(constraints),如"早八不排"、"周末休息"等。
|
||||
3) 如果用户明确提到了任务类名称或ID,输出 task_class_id(整数);否则输出 -1。
|
||||
4) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
|
||||
2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。
|
||||
3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。
|
||||
4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。
|
||||
5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
|
||||
6) 尝试给任务打认知标签 task_tags(可选):
|
||||
- 推荐键:task_item_id(字符串形式,例如 "12")
|
||||
- 兼容键:任务名称(例如 "高数复习")
|
||||
- 值只能是:High-Logic / Memory / Review / General
|
||||
- 如果无法判断,输出空对象 {}
|
||||
|
||||
输出要求:
|
||||
- 仅输出 JSON,不要 markdown,不要解释。
|
||||
@@ -22,67 +33,133 @@ const (
|
||||
{
|
||||
"intent": "用户排程意图摘要",
|
||||
"constraints": ["约束1", "约束2"],
|
||||
"task_class_id": -1,
|
||||
"strategy": "steady"
|
||||
"task_class_ids": [12, 13],
|
||||
"task_class_id": 12,
|
||||
"strategy": "steady",
|
||||
"task_tags": {"12":"High-Logic","英语阅读":"Memory"}
|
||||
}`
|
||||
|
||||
// SchedulePlanReactSystemPrompt 用于 ReAct 精排节点:
|
||||
// LLM 开启深度思考,通过 Tool 调用对粗排结果进行语义化优化。
|
||||
// SchedulePlanDailyReactPrompt 用于 daily_refine 节点。
|
||||
//
|
||||
// 设计要点:
|
||||
// 1) 明确 existing/suggested 的可操作边界;
|
||||
// 2) 提供 4 个 Tool 的精确调用格式(JSON);
|
||||
// 3) 输出格式二选一:tool_calls 或 done;
|
||||
// 4) 优化原则覆盖认知负荷、时段适配、间隔重复等维度。
|
||||
SchedulePlanReactSystemPrompt = `你是 SmartFlow 智能排程精排优化器。
|
||||
// 职责边界:
|
||||
// 1. 只处理“单天”数据,避免跨天决策污染;
|
||||
// 2. 通过工具调用做小步调整;
|
||||
// 3. 不负责周级配平,不负责最终总结。
|
||||
SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。
|
||||
|
||||
你将收到一份"混合日程表"(JSON 数组),其中每个条目包含:
|
||||
你将收到一天内的日程安排(JSON 数组),其中:
|
||||
- status="existing":已确定的课程或任务,不可移动
|
||||
- status="suggested":粗排算法建议的学习任务,你可以通过工具调整它们的时间
|
||||
- status="suggested":粗排算法建议的学习任务,你可以调整
|
||||
- context_tag:任务认知类型(High-Logic/Memory/Review/General)
|
||||
|
||||
你的目标是优化 suggested 任务的时间安排,使最终方案科学合理。
|
||||
你的目标是优化这一天内 suggested 任务的时间安排。
|
||||
|
||||
## 优化原则
|
||||
|
||||
1. 上下文切换成本:相同或相近科目的任务尽量安排在相邻时段,减少频繁切换带来的认知损耗
|
||||
1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。
|
||||
2. 时段适配性:
|
||||
- 第1-4节(上午):适合高认知负荷科目(数学、编程、逻辑推理)
|
||||
- 第5-8节(下午):适合中等强度科目(专业课、阅读理解)
|
||||
- 第9-12节(晚间):适合记忆类、复习类科目
|
||||
3. 学习效率曲线:避免连续安排超过4节高强度学习,适当穿插不同类型的任务
|
||||
4. 间隔重复:同一科目的复习任务在时间上适当分散到不同天,符合遗忘曲线规律
|
||||
5. 用户约束:严格遵守用户提出的约束条件(如有)
|
||||
- 第1-4节(上午):适合 High-Logic(数学、编程)
|
||||
- 第5-8节(下午):适合中等强度(专业课、阅读)
|
||||
- 第9-12节(晚间):适合 Memory 和 Review
|
||||
3. 学习效率曲线:避免连续超过 4 节高强度学习。
|
||||
4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。
|
||||
|
||||
## 可用工具
|
||||
|
||||
1. Swap — 交换两个 suggested 任务的时间位置
|
||||
1. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a(task_item_id),task_b(task_item_id)
|
||||
|
||||
2. Move — 将一个 suggested 任务移动到新的时间位置
|
||||
2. Move — 将一个 suggested 任务移动到新时间(仅限当天)
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
注意:目标位置必须空闲,且节次跨度必须与原任务一致
|
||||
|
||||
3. TimeAvailable — 检查目标时间段是否可用
|
||||
3. TimeAvailable — 检查时段是否可用
|
||||
参数:week, day_of_week, section_from, section_to
|
||||
|
||||
4. GetAvailableSlots — 获取可用时间段列表
|
||||
参数:week(可选,不传则返回所有周)
|
||||
4. GetAvailableSlots — 获取可用时段
|
||||
参数:week
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
|
||||
调用工具时:
|
||||
{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}},{"tool":"Move","params":{"task_item_id":10,"to_week":1,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
|
||||
{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明做了哪些优化及理由"}
|
||||
{"done":true,"summary":"简要说明优化理由"}
|
||||
|
||||
## 工作流程
|
||||
重要:只修改 suggested 任务,不要尝试移动 existing 条目。`
|
||||
|
||||
1. 仔细分析当前排程,识别不合理之处
|
||||
2. 如需了解可用时间,先调用 GetAvailableSlots
|
||||
3. 确定调整方案后,调用 Swap 或 Move 执行
|
||||
4. 你可以一次输出多个工具调用,后端会按顺序执行
|
||||
5. 当你认为排程已经足够合理,或者没有更好的调整空间,输出完成标记
|
||||
// SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。
|
||||
//
|
||||
// 设计重点:
|
||||
// 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done;
|
||||
// 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑;
|
||||
// 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突;
|
||||
// 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。
|
||||
SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。
|
||||
|
||||
重要:只修改 status="suggested" 的任务,不要尝试移动 existing 条目。`
|
||||
单日内的排程已优化完毕,你当前只负责“单周微调”。
|
||||
|
||||
## 数据可靠性前提(必须接受)
|
||||
1. 你收到的混合日程 JSON 已经过后端硬冲突检查。
|
||||
2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。
|
||||
3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。
|
||||
4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。
|
||||
5. 字段语义补充:
|
||||
- existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务;
|
||||
- suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。
|
||||
|
||||
## 预算语义(必须遵守,且必须严格区分)
|
||||
1. 总动作预算(剩余):{{action_total_remaining}}
|
||||
2. 总动作预算(固定):{{action_total_budget}}
|
||||
3. 总动作预算(已用):{{action_total_used}}
|
||||
4. 有效动作预算(剩余):{{action_effective_remaining}}
|
||||
5. 有效动作预算(固定):{{action_effective_budget}}
|
||||
6. 有效动作预算(已用):{{action_effective_used}}
|
||||
7. 规则:
|
||||
- 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”;
|
||||
- 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。
|
||||
8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。
|
||||
|
||||
## 约束
|
||||
1. 只允许在当前周内优化(禁止跨周移动)。
|
||||
2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。
|
||||
3. 严格遵守用户约束(如有)。
|
||||
4. 每个任务最多变动一次位置。
|
||||
|
||||
## 优化目标
|
||||
1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。
|
||||
2. 间隔重复:同一科目任务适当分散到不同天。
|
||||
3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。
|
||||
4. 总量均衡:各天 suggested 数量大致均匀。
|
||||
|
||||
## 执行节奏(降低无效思考)
|
||||
1. 想一步做一步:本轮只做“一个最有价值动作”。
|
||||
2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。
|
||||
3. 如果当前方案已经足够好,直接 done,不要空转。
|
||||
4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。
|
||||
|
||||
## 可用工具
|
||||
1. Move — 将一个 suggested 任务移动到当前周的另一天/时段
|
||||
参数:task_item_id, to_week, to_day, to_section_from, to_section_to
|
||||
注意:节次跨度必须与原任务一致
|
||||
2. Swap — 交换两个 suggested 任务的时间
|
||||
参数:task_a, task_b(task_item_id)
|
||||
|
||||
## 输出格式(严格 JSON,不要 markdown)
|
||||
调用工具时(注意:tool_calls 里只能有 1 个元素):
|
||||
{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
|
||||
|
||||
完成优化时:
|
||||
{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}`
|
||||
|
||||
// SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做读数据总结,不参与工具调用与状态修改;
|
||||
// 2. 输出面向用户的自然语言;
|
||||
// 3. 失败由上层兜底文案处理。
|
||||
SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。
|
||||
你的任务是为用户生成一段友好、自然的排程总结。
|
||||
|
||||
要求:
|
||||
1. 用 2-3 句话概括方案亮点。
|
||||
2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
|
||||
3. 若用户有约束,说明方案如何满足这些约束。
|
||||
4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
|
||||
5. 语气温暖自然。
|
||||
6. 只输出纯文本,不要输出 JSON。`
|
||||
)
|
||||
|
||||
@@ -4,29 +4,53 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/chat"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
einoModel "github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// reactRoundTimeout 是单轮 ReAct 的超时时间。
|
||||
// 深度思考模式下 reasoning 阶段可能耗时较长,需要给足时间。
|
||||
const reactRoundTimeout = 15 * time.Minute
|
||||
const (
|
||||
// weeklyReactRoundTimeout 是周级“单步动作”单轮超时时间。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前周级策略是“每轮只做一个动作”,单轮输入较短,超时可比旧版更保守;
|
||||
// 2. 过长超时会放大长尾等待,影响并发周优化的整体收口速度。
|
||||
weeklyReactRoundTimeout = 4 * time.Minute
|
||||
)
|
||||
|
||||
// runReactRefineNode 执行 ReAct 精排循环。
|
||||
// weeklyRefineWorkerResult 是“单周 worker”输出。
|
||||
//
|
||||
// 核心流程(最多 ReactMaxRound 轮):
|
||||
// 1. 构造 messages(system prompt + 混合日程 JSON + 上轮 tool 结果)
|
||||
// 2. 调用 chatModel.Stream() + ThinkingTypeEnabled
|
||||
// 3. reasoning_content 实时推送到 outChan(前端可见思考过程)
|
||||
// 4. content 累积后解析:done=true 则退出,tool_calls 则执行
|
||||
// 5. tool 结果拼入下一轮 messages
|
||||
func runReactRefineNode(
|
||||
// 职责边界:
|
||||
// 1. 记录该周优化后的 entries;
|
||||
// 2. 记录预算消耗(总动作/有效动作);
|
||||
// 3. 记录动作日志,供 final_check 生成“过程可解释”总结;
|
||||
// 4. 记录该周摘要,便于最终汇总。
|
||||
type weeklyRefineWorkerResult struct {
|
||||
Week int
|
||||
Entries []model.HybridScheduleEntry
|
||||
TotalUsed int
|
||||
EffectiveUsed int
|
||||
Summary string
|
||||
ActionLogs []string
|
||||
}
|
||||
|
||||
// runWeeklyRefineNode 执行“周级单步优化”。
|
||||
//
|
||||
// 新链路目标:
|
||||
// 1. 把全量周数据拆成“按周并发”执行,降低单次模型输入规模;
|
||||
// 2. 每轮只允许一个动作(Move/Swap)或 done,减少模型犹豫;
|
||||
// 3. 使用“双预算”约束迭代:
|
||||
// 3.1 总动作预算:成功/失败都扣减;
|
||||
// 3.2 有效动作预算:仅成功动作扣减;
|
||||
// 4. 不在该阶段输出 reasoning 文本,改为阶段状态 + 动作结果,避免刷屏。
|
||||
func runWeeklyRefineNode(
|
||||
ctx context.Context,
|
||||
st *SchedulePlanState,
|
||||
chatModel *ark.ChatModel,
|
||||
@@ -34,176 +58,790 @@ func runReactRefineNode(
|
||||
modelName string,
|
||||
emitStage func(stage, detail string),
|
||||
) (*SchedulePlanState, error) {
|
||||
_ = outChan
|
||||
if st == nil {
|
||||
return nil, fmt.Errorf("schedule plan graph: nil state in reactRefine node")
|
||||
return nil, fmt.Errorf("schedule plan weekly refine: nil state")
|
||||
}
|
||||
if chatModel == nil {
|
||||
return nil, fmt.Errorf("schedule plan graph: model is nil in reactRefine node")
|
||||
return nil, fmt.Errorf("schedule plan weekly refine: model is nil")
|
||||
}
|
||||
if len(st.HybridEntries) == 0 {
|
||||
st.ReactDone = true
|
||||
st.ReactSummary = "无可优化的排程条目。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 准备 SSE 流式输出的基础参数
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = "smartflow-worker"
|
||||
modelName = "worker"
|
||||
}
|
||||
|
||||
// 构造混合日程 JSON(只在首轮构造,后续轮次复用)
|
||||
hybridJSON, err := json.Marshal(st.HybridEntries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化混合日程失败: %w", err)
|
||||
// 1. 预算与并发兜底。
|
||||
// 1.1 有效预算(旧字段)<=0 时回退默认值;
|
||||
// 1.2 总预算 <=0 时回退默认值;
|
||||
// 1.3 为避免“有效预算 > 总预算”的反直觉状态,做一次归一化修正;
|
||||
// 1.4 周级并发度默认不高于周数,避免空并发浪费。
|
||||
if st.WeeklyAdjustBudget <= 0 {
|
||||
st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget
|
||||
}
|
||||
if st.WeeklyTotalBudget <= 0 {
|
||||
st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget
|
||||
}
|
||||
if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
|
||||
st.WeeklyAdjustBudget = st.WeeklyTotalBudget
|
||||
}
|
||||
if st.WeeklyRefineConcurrency <= 0 {
|
||||
st.WeeklyRefineConcurrency = schedulePlanDefaultWeeklyRefineConcurrency
|
||||
}
|
||||
|
||||
// 用户约束文本
|
||||
constraintsText := "无"
|
||||
if len(st.Constraints) > 0 {
|
||||
constraintsText = strings.Join(st.Constraints, "、")
|
||||
}
|
||||
|
||||
// 对话历史:跨轮次累积
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(SchedulePlanReactSystemPrompt),
|
||||
schema.UserMessage(fmt.Sprintf(
|
||||
"以下是当前混合日程(JSON):\n%s\n\n用户约束:%s\n\n请分析并优化 suggested 任务的时间安排。",
|
||||
string(hybridJSON), constraintsText,
|
||||
)),
|
||||
}
|
||||
|
||||
// ── ReAct 主循环 ──
|
||||
for st.ReactRound < st.ReactMaxRound {
|
||||
st.ReactRound++
|
||||
emitStage("schedule_plan.react.round", fmt.Sprintf("第 %d 轮优化思考...", st.ReactRound))
|
||||
|
||||
// 1. 带超时的 context
|
||||
roundCtx, cancel := context.WithTimeout(ctx, reactRoundTimeout)
|
||||
|
||||
// 2. 调用模型(流式 + 深度思考)
|
||||
content, streamErr := streamReactRound(roundCtx, chatModel, modelName, messages, outChan)
|
||||
cancel()
|
||||
|
||||
if streamErr != nil {
|
||||
emitStage("schedule_plan.react.error", fmt.Sprintf("第 %d 轮模型调用失败: %s", st.ReactRound, streamErr.Error()))
|
||||
// 明确标记为失败,不伪装成功
|
||||
st.ReactDone = true
|
||||
st.ReactSummary = fmt.Sprintf("排程优化未完成:第 %d 轮模型调用超时或失败,使用粗排结果。", st.ReactRound)
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 解析 LLM 输出
|
||||
parsed, parseErr := parseReactLLMOutput(content)
|
||||
if parseErr != nil {
|
||||
// 解析失败,把原始输出当作摘要,结束循环
|
||||
emitStage("schedule_plan.react.parse_error", "LLM 输出格式异常,结束优化。")
|
||||
st.ReactSummary = "排程优化已完成(LLM 输出格式异常,使用当前结果)。"
|
||||
st.ReactDone = true
|
||||
break
|
||||
}
|
||||
|
||||
// 4. 检查是否完成
|
||||
if parsed.Done {
|
||||
st.ReactSummary = parsed.Summary
|
||||
st.ReactDone = true
|
||||
emitStage("schedule_plan.react.done", "优化完成。")
|
||||
break
|
||||
}
|
||||
|
||||
// 5. 执行 tool calls
|
||||
if len(parsed.ToolCalls) == 0 {
|
||||
// 没有 tool 调用也没有 done,视为完成
|
||||
st.ReactSummary = "排程优化已完成。"
|
||||
st.ReactDone = true
|
||||
break
|
||||
}
|
||||
|
||||
results := make([]reactToolResult, 0, len(parsed.ToolCalls))
|
||||
for _, call := range parsed.ToolCalls {
|
||||
var result reactToolResult
|
||||
st.HybridEntries, result = dispatchReactTool(st.HybridEntries, call)
|
||||
results = append(results, result)
|
||||
statusMark := "OK"
|
||||
if !result.Success {
|
||||
statusMark = "FAIL"
|
||||
}
|
||||
emitStage("schedule_plan.react.tool_call",
|
||||
fmt.Sprintf("[%s] %s: %s", statusMark, result.Tool, result.Result))
|
||||
}
|
||||
|
||||
// 6. 将 tool 结果拼入下一轮 messages
|
||||
// 先追加 assistant 的输出
|
||||
messages = append(messages, schema.AssistantMessage(content, nil))
|
||||
// 再追加 tool 结果作为 user message
|
||||
resultsJSON, _ := json.Marshal(results)
|
||||
messages = append(messages, schema.UserMessage(
|
||||
fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化,或输出 {\"done\":true,\"summary\":\"...\"} 完成。", string(resultsJSON)),
|
||||
))
|
||||
}
|
||||
|
||||
// 循环结束兜底
|
||||
if !st.ReactDone {
|
||||
// 2. 按周拆分输入。
|
||||
weekOrder, weekEntries := splitHybridEntriesByWeek(st.HybridEntries)
|
||||
if len(weekOrder) == 0 {
|
||||
st.ReactDone = true
|
||||
if strings.TrimSpace(st.ReactSummary) == "" {
|
||||
st.ReactSummary = fmt.Sprintf("排程优化已达最大轮次(%d 轮),使用当前结果。", st.ReactRound)
|
||||
}
|
||||
emitStage("schedule_plan.react.max_round", "已达最大优化轮次,使用当前结果。")
|
||||
st.ReactSummary = "无可优化的排程条目。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
// 3. 只对“包含 suggested 的周”分配预算,其余周直接透传。
|
||||
activeWeeks := make([]int, 0, len(weekOrder))
|
||||
for _, week := range weekOrder {
|
||||
if countSuggested(weekEntries[week]) > 0 {
|
||||
activeWeeks = append(activeWeeks, week)
|
||||
}
|
||||
}
|
||||
if len(activeWeeks) == 0 {
|
||||
st.ReactDone = true
|
||||
st.ReactSummary = "当前方案中没有可调整的 suggested 任务,已跳过周级优化。"
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// streamReactRound 执行单轮 ReAct 模型调用:
|
||||
// - 流式推送 reasoning_content 到 outChan(前端可见思考过程)
|
||||
// - 累积 content 并返回(包含 tool_calls 或 done 信号)
|
||||
func streamReactRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
modelName string,
|
||||
messages []*schema.Message,
|
||||
outChan chan<- string,
|
||||
) (string, error) {
|
||||
// 开启深度思考
|
||||
reader, err := chatModel.Stream(ctx, messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
|
||||
// 3.1 强制“每个有效周至少 1 个总预算 + 1 个有效预算”。
|
||||
// 3.1.1 判断依据:任何有效周都必须有机会进入优化,避免出现 0 预算跳过。
|
||||
// 3.1.2 实现方式:当全局预算不足时,自动抬升到 activeWeeks 数量。
|
||||
// 3.1.3 失败/兜底:该步骤仅做内存字段修正,不依赖外部资源,不会新增失败点。
|
||||
minBudgetRequired := len(activeWeeks)
|
||||
if st.WeeklyTotalBudget < minBudgetRequired {
|
||||
st.WeeklyTotalBudget = minBudgetRequired
|
||||
}
|
||||
if st.WeeklyAdjustBudget < minBudgetRequired {
|
||||
st.WeeklyAdjustBudget = minBudgetRequired
|
||||
}
|
||||
if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
|
||||
st.WeeklyAdjustBudget = st.WeeklyTotalBudget
|
||||
}
|
||||
|
||||
totalBudgetByWeek, effectiveBudgetByWeek, weeklyLoads, coveredWeeks := splitWeeklyBudgetsByLoad(
|
||||
activeWeeks,
|
||||
weekEntries,
|
||||
st.WeeklyTotalBudget,
|
||||
st.WeeklyAdjustBudget,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("模型 Stream 调用失败: %w", err)
|
||||
budgetIndexByWeek := make(map[int]int, len(activeWeeks))
|
||||
for idx, week := range activeWeeks {
|
||||
budgetIndexByWeek[week] = idx
|
||||
}
|
||||
if coveredWeeks < len(activeWeeks) {
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.budget_fallback",
|
||||
fmt.Sprintf(
|
||||
"周级预算不足以覆盖全部有效周(有效周=%d,至少需预算=%d;当前总预算=%d,有效预算=%d)。已按周负载优先覆盖 %d 个周,其余周预算置 0 并透传原方案。",
|
||||
len(activeWeeks),
|
||||
len(activeWeeks),
|
||||
st.WeeklyTotalBudget,
|
||||
st.WeeklyAdjustBudget,
|
||||
coveredWeeks,
|
||||
),
|
||||
)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
requestID := "react-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
created := time.Now().Unix()
|
||||
var contentBuilder strings.Builder
|
||||
workerConcurrency := st.WeeklyRefineConcurrency
|
||||
if workerConcurrency > len(activeWeeks) {
|
||||
workerConcurrency = len(activeWeeks)
|
||||
}
|
||||
if workerConcurrency <= 0 {
|
||||
workerConcurrency = 1
|
||||
}
|
||||
|
||||
for {
|
||||
chunk, recvErr := reader.Recv()
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if recvErr != nil {
|
||||
return contentBuilder.String(), fmt.Errorf("流式接收失败: %w", recvErr)
|
||||
}
|
||||
if chunk == nil {
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.start",
|
||||
fmt.Sprintf(
|
||||
"周级单步优化开始:周数=%d(可优化=%d),并发度=%d,总动作预算=%d,有效动作预算=%d,覆盖周=%d/%d,周负载=%v。",
|
||||
len(weekOrder),
|
||||
len(activeWeeks),
|
||||
workerConcurrency,
|
||||
st.WeeklyTotalBudget,
|
||||
st.WeeklyAdjustBudget,
|
||||
coveredWeeks,
|
||||
len(activeWeeks),
|
||||
weeklyLoads,
|
||||
),
|
||||
)
|
||||
|
||||
// 4. 并发执行“单周 worker”。
|
||||
sem := make(chan struct{}, workerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
workerResults := make(map[int]weeklyRefineWorkerResult, len(weekOrder))
|
||||
var firstErr error
|
||||
completedWeeks := 0
|
||||
|
||||
for _, week := range weekOrder {
|
||||
week := week
|
||||
entries := deepCopyEntries(weekEntries[week])
|
||||
|
||||
// 4.1 没有 suggested 的周直接透传,不占模型调用预算。
|
||||
if countSuggested(entries) == 0 {
|
||||
workerResults[week] = weeklyRefineWorkerResult{
|
||||
Week: week,
|
||||
Entries: entries,
|
||||
Summary: fmt.Sprintf("W%d 无 suggested 任务,跳过周级优化。", week),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送 reasoning_content 到前端(实时思考过程)
|
||||
if chunk.ReasoningContent != "" && outChan != nil {
|
||||
payload, fmtErr := chat.ToOpenAIStream(
|
||||
&schema.Message{ReasoningContent: chunk.ReasoningContent},
|
||||
requestID, modelName, created, false,
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = ctx.Err()
|
||||
}
|
||||
completedWeeks++
|
||||
workerResults[week] = weeklyRefineWorkerResult{
|
||||
Week: week,
|
||||
Entries: entries,
|
||||
Summary: fmt.Sprintf("W%d 优化取消,已保留原方案。", week),
|
||||
}
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.week_done",
|
||||
fmt.Sprintf("W%d 已取消并回退原方案。(进度 %d/%d)", week, completedWeeks, len(activeWeeks)),
|
||||
)
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
idx := budgetIndexByWeek[week]
|
||||
weekTotalBudget := totalBudgetByWeek[idx]
|
||||
weekEffectiveBudget := effectiveBudgetByWeek[idx]
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.week_start",
|
||||
fmt.Sprintf(
|
||||
"W%d 开始周级单步优化:总预算=%d,有效预算=%d。",
|
||||
week,
|
||||
weekTotalBudget,
|
||||
weekEffectiveBudget,
|
||||
),
|
||||
)
|
||||
if fmtErr == nil && payload != "" {
|
||||
outChan <- payload
|
||||
|
||||
result, workerErr := runSingleWeekRefineWorker(
|
||||
ctx,
|
||||
chatModel,
|
||||
modelName,
|
||||
week,
|
||||
entries,
|
||||
st.Constraints,
|
||||
weeklyPlanningWindow{
|
||||
Enabled: st.HasPlanningWindow,
|
||||
StartWeek: st.PlanStartWeek,
|
||||
StartDay: st.PlanStartDay,
|
||||
EndWeek: st.PlanEndWeek,
|
||||
EndDay: st.PlanEndDay,
|
||||
},
|
||||
weekTotalBudget,
|
||||
weekEffectiveBudget,
|
||||
emitStage,
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if workerErr != nil && firstErr == nil {
|
||||
firstErr = workerErr
|
||||
}
|
||||
completedWeeks++
|
||||
workerResults[week] = result
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.week_done",
|
||||
fmt.Sprintf(
|
||||
"W%d 周级优化完成(总已用=%d/%d,有效已用=%d/%d)。(进度 %d/%d)",
|
||||
week,
|
||||
result.TotalUsed,
|
||||
weekTotalBudget,
|
||||
result.EffectiveUsed,
|
||||
weekEffectiveBudget,
|
||||
completedWeeks,
|
||||
len(activeWeeks),
|
||||
),
|
||||
)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 5. 汇总 worker 结果,重建全量 HybridEntries。
|
||||
mergedEntries := make([]model.HybridScheduleEntry, 0, len(st.HybridEntries))
|
||||
st.WeeklyTotalUsed = 0
|
||||
st.WeeklyAdjustUsed = 0
|
||||
st.WeeklyActionLogs = st.WeeklyActionLogs[:0]
|
||||
weekSummaries := make([]string, 0, len(weekOrder))
|
||||
|
||||
for _, week := range weekOrder {
|
||||
result, exists := workerResults[week]
|
||||
if !exists {
|
||||
// 理论上不会发生;兜底透传该周原始条目。
|
||||
result = weeklyRefineWorkerResult{
|
||||
Week: week,
|
||||
Entries: deepCopyEntries(weekEntries[week]),
|
||||
Summary: fmt.Sprintf("W%d 未拿到 worker 结果,已保留原方案。", week),
|
||||
}
|
||||
}
|
||||
|
||||
// 累积 content(tool_calls 或 done 信号)
|
||||
if chunk.Content != "" {
|
||||
contentBuilder.WriteString(chunk.Content)
|
||||
mergedEntries = append(mergedEntries, result.Entries...)
|
||||
st.WeeklyTotalUsed += result.TotalUsed
|
||||
st.WeeklyAdjustUsed += result.EffectiveUsed
|
||||
st.WeeklyActionLogs = append(st.WeeklyActionLogs, result.ActionLogs...)
|
||||
if strings.TrimSpace(result.Summary) != "" {
|
||||
weekSummaries = append(weekSummaries, result.Summary)
|
||||
}
|
||||
}
|
||||
sortHybridEntries(mergedEntries)
|
||||
st.HybridEntries = mergedEntries
|
||||
|
||||
return strings.TrimSpace(contentBuilder.String()), nil
|
||||
// 6. 生成阶段摘要并收口状态。
|
||||
st.ReactDone = true
|
||||
st.ReactRound = st.WeeklyTotalUsed
|
||||
if len(weekSummaries) == 0 {
|
||||
st.ReactSummary = fmt.Sprintf(
|
||||
"周级优化完成:总动作已用 %d/%d,有效动作已用 %d/%d。",
|
||||
st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
|
||||
)
|
||||
} else {
|
||||
st.ReactSummary = strings.Join(weekSummaries, ";")
|
||||
}
|
||||
if firstErr != nil {
|
||||
emitStage("schedule_plan.weekly_refine.partial_error", fmt.Sprintf("周级并发优化部分失败,已自动保留失败周原方案。原因:%s", firstErr.Error()))
|
||||
}
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.done",
|
||||
fmt.Sprintf(
|
||||
"周级单步优化结束:总动作已用 %d/%d,有效动作已用 %d/%d。",
|
||||
st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
|
||||
),
|
||||
)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// runSingleWeekRefineWorker 执行“单周 + 单步动作”循环。
|
||||
//
|
||||
// 流程说明:
|
||||
// 1. 每轮只允许 1 个工具调用或 done;
|
||||
// 2. 每次工具调用都扣“总预算”;
|
||||
// 3. 仅成功调用再扣“有效预算”;
|
||||
// 4. 工具结果会回灌到下一轮上下文,驱动“走一步看一步”。
|
||||
func runSingleWeekRefineWorker(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
modelName string,
|
||||
week int,
|
||||
entries []model.HybridScheduleEntry,
|
||||
constraints []string,
|
||||
window weeklyPlanningWindow,
|
||||
totalBudget int,
|
||||
effectiveBudget int,
|
||||
emitStage func(stage, detail string),
|
||||
) (weeklyRefineWorkerResult, error) {
|
||||
result := weeklyRefineWorkerResult{
|
||||
Week: week,
|
||||
Entries: deepCopyEntries(entries),
|
||||
}
|
||||
|
||||
if totalBudget <= 0 || effectiveBudget <= 0 {
|
||||
result.Summary = fmt.Sprintf("W%d 预算为 0,跳过周级优化。", week)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
hybridJSON, err := json.Marshal(result.Entries)
|
||||
if err != nil {
|
||||
result.Summary = fmt.Sprintf("W%d 序列化失败,已保留原方案。", week)
|
||||
return result, fmt.Errorf("周级 worker 序列化失败 week=%d: %w", week, err)
|
||||
}
|
||||
constraintsText := "无"
|
||||
if len(constraints) > 0 {
|
||||
constraintsText = strings.Join(constraints, "、")
|
||||
}
|
||||
|
||||
messages := []*schema.Message{
|
||||
schema.SystemMessage(
|
||||
renderWeeklyPromptWithBudget(
|
||||
effectiveBudget-result.EffectiveUsed,
|
||||
effectiveBudget,
|
||||
result.EffectiveUsed,
|
||||
totalBudget-result.TotalUsed,
|
||||
totalBudget,
|
||||
result.TotalUsed,
|
||||
),
|
||||
),
|
||||
schema.UserMessage(fmt.Sprintf(
|
||||
"当前处理周次:W%d\n以下是当前周混合日程(JSON):\n%s\n\n用户约束:%s\n\n注意:本 worker 仅允许优化 W%d 内的任务。",
|
||||
week,
|
||||
string(hybridJSON),
|
||||
constraintsText,
|
||||
week,
|
||||
)),
|
||||
}
|
||||
|
||||
for result.TotalUsed < totalBudget && result.EffectiveUsed < effectiveBudget {
|
||||
remainingTotal := totalBudget - result.TotalUsed
|
||||
remainingEffective := effectiveBudget - result.EffectiveUsed
|
||||
emitStage(
|
||||
"schedule_plan.weekly_refine.round",
|
||||
fmt.Sprintf(
|
||||
"W%d 新一轮决策:总预算剩余=%d/%d,有效预算剩余=%d/%d。",
|
||||
week,
|
||||
remainingTotal,
|
||||
totalBudget,
|
||||
remainingEffective,
|
||||
effectiveBudget,
|
||||
),
|
||||
)
|
||||
|
||||
// 1. 每轮更新系统提示中的预算占位符。
|
||||
messages[0] = schema.SystemMessage(
|
||||
renderWeeklyPromptWithBudget(
|
||||
remainingEffective,
|
||||
effectiveBudget,
|
||||
result.EffectiveUsed,
|
||||
remainingTotal,
|
||||
totalBudget,
|
||||
result.TotalUsed,
|
||||
),
|
||||
)
|
||||
|
||||
roundCtx, cancel := context.WithTimeout(ctx, weeklyReactRoundTimeout)
|
||||
content, genErr := generateWeeklyRefineRound(roundCtx, chatModel, messages)
|
||||
cancel()
|
||||
if genErr != nil {
|
||||
result.Summary = fmt.Sprintf("W%d 模型调用失败,已保留当前结果。", week)
|
||||
return result, fmt.Errorf("周级 worker 调用失败 week=%d: %w", week, genErr)
|
||||
}
|
||||
|
||||
parsed, parseErr := parseReactLLMOutput(content)
|
||||
if parseErr != nil {
|
||||
result.Summary = fmt.Sprintf("W%d 输出格式异常,已保留当前结果。", week)
|
||||
return result, fmt.Errorf("周级 worker 解析失败 week=%d: %w", week, parseErr)
|
||||
}
|
||||
|
||||
// 2. done=true 直接正常结束,不再消耗预算。
|
||||
if parsed.Done {
|
||||
summary := strings.TrimSpace(parsed.Summary)
|
||||
if summary == "" {
|
||||
summary = fmt.Sprintf(
|
||||
"W%d 优化结束(总动作已用 %d/%d,有效动作已用 %d/%d)。",
|
||||
week,
|
||||
result.TotalUsed, totalBudget,
|
||||
result.EffectiveUsed, effectiveBudget,
|
||||
)
|
||||
}
|
||||
result.Summary = summary
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 只取一个工具调用,强制单步。
|
||||
call, warn := pickSingleToolCall(parsed.ToolCalls)
|
||||
if call == nil {
|
||||
result.Summary = fmt.Sprintf(
|
||||
"W%d 无可执行动作,提前结束(总动作已用 %d/%d,有效动作已用 %d/%d)。",
|
||||
week,
|
||||
result.TotalUsed, totalBudget,
|
||||
result.EffectiveUsed, effectiveBudget,
|
||||
)
|
||||
break
|
||||
}
|
||||
if warn != "" {
|
||||
result.ActionLogs = append(result.ActionLogs, fmt.Sprintf("W%d 警告:%s", week, warn))
|
||||
}
|
||||
|
||||
// 4. 执行工具:总预算总是扣减;有效预算仅成功时扣减。
|
||||
result.TotalUsed++
|
||||
nextEntries, toolResult := dispatchWeeklySingleActionTool(result.Entries, *call, week, window)
|
||||
if toolResult.Success {
|
||||
result.EffectiveUsed++
|
||||
result.Entries = nextEntries
|
||||
}
|
||||
|
||||
logLine := fmt.Sprintf(
|
||||
"W%d 动作[%s] 结果=%t,总预算=%d/%d,有效预算=%d/%d,详情=%s",
|
||||
week,
|
||||
toolResult.Tool,
|
||||
toolResult.Success,
|
||||
result.TotalUsed,
|
||||
totalBudget,
|
||||
result.EffectiveUsed,
|
||||
effectiveBudget,
|
||||
toolResult.Result,
|
||||
)
|
||||
result.ActionLogs = append(result.ActionLogs, logLine)
|
||||
statusMark := "FAIL"
|
||||
if toolResult.Success {
|
||||
statusMark = "OK"
|
||||
}
|
||||
emitStage("schedule_plan.weekly_refine.tool_call", fmt.Sprintf("[%s] %s", statusMark, logLine))
|
||||
|
||||
// 5. 把“本轮输出 + 工具结果”拼回下一轮上下文,驱动增量推理。
|
||||
messages = append(messages, schema.AssistantMessage(content, nil))
|
||||
toolResultJSON, _ := json.Marshal([]reactToolResult{toolResult})
|
||||
messages = append(messages, schema.UserMessage(
|
||||
fmt.Sprintf(
|
||||
"上一轮工具结果:%s\n当前预算:总剩余=%d,有效剩余=%d\n请继续按“单步动作”规则决策(仅一个工具调用或 done)。",
|
||||
string(toolResultJSON),
|
||||
totalBudget-result.TotalUsed,
|
||||
effectiveBudget-result.EffectiveUsed,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(result.Summary) == "" {
|
||||
result.Summary = fmt.Sprintf(
|
||||
"W%d 预算耗尽停止(总动作已用 %d/%d,有效动作已用 %d/%d)。",
|
||||
week,
|
||||
result.TotalUsed, totalBudget,
|
||||
result.EffectiveUsed, effectiveBudget,
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateWeeklyRefineRound 调用模型生成“单周单步”决策输出。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 周级仍保留 thinking(提高复杂排程准确率);
|
||||
// 2. 但不把 reasoning 实时透传给前端,避免刷屏;
|
||||
// 3. 仅返回最终 content,交给 JSON 解析器处理。
|
||||
func generateWeeklyRefineRound(
|
||||
ctx context.Context,
|
||||
chatModel *ark.ChatModel,
|
||||
messages []*schema.Message,
|
||||
) (string, error) {
|
||||
resp, err := chatModel.Generate(
|
||||
ctx,
|
||||
messages,
|
||||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
|
||||
einoModel.WithTemperature(0.2),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("周级单步调用返回为空")
|
||||
}
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("周级单步调用返回内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// renderWeeklyPromptWithBudget 渲染周级单步优化的预算占位符。
|
||||
//
|
||||
// 1. 保留旧占位符 {{budget*}} 兼容历史模板;
|
||||
// 2. 新增 action_total/action_effective 占位符表达双预算语义;
|
||||
// 3. 所有负值都会在这里兜底归零,避免传给模型异常预算。
|
||||
func renderWeeklyPromptWithBudget(
|
||||
remainingEffective int,
|
||||
effectiveBudget int,
|
||||
usedEffective int,
|
||||
remainingTotal int,
|
||||
totalBudget int,
|
||||
usedTotal int,
|
||||
) string {
|
||||
if effectiveBudget <= 0 {
|
||||
effectiveBudget = schedulePlanDefaultWeeklyAdjustBudget
|
||||
}
|
||||
if totalBudget <= 0 {
|
||||
totalBudget = schedulePlanDefaultWeeklyTotalBudget
|
||||
}
|
||||
if remainingEffective < 0 {
|
||||
remainingEffective = 0
|
||||
}
|
||||
if remainingTotal < 0 {
|
||||
remainingTotal = 0
|
||||
}
|
||||
if usedEffective < 0 {
|
||||
usedEffective = 0
|
||||
}
|
||||
if usedTotal < 0 {
|
||||
usedTotal = 0
|
||||
}
|
||||
if usedEffective > effectiveBudget {
|
||||
usedEffective = effectiveBudget
|
||||
}
|
||||
if usedTotal > totalBudget {
|
||||
usedTotal = totalBudget
|
||||
}
|
||||
|
||||
prompt := SchedulePlanWeeklyReactPrompt
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_total_remaining}}", fmt.Sprintf("%d", remainingTotal))
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_total_budget}}", fmt.Sprintf("%d", totalBudget))
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_total_used}}", fmt.Sprintf("%d", usedTotal))
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_effective_remaining}}", fmt.Sprintf("%d", remainingEffective))
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_effective_budget}}", fmt.Sprintf("%d", effectiveBudget))
|
||||
prompt = strings.ReplaceAll(prompt, "{{action_effective_used}}", fmt.Sprintf("%d", usedEffective))
|
||||
|
||||
// 兼容旧模板占位符,避免历史 prompt 残留时出现未替换文本。
|
||||
prompt = strings.ReplaceAll(prompt, "{{budget_remaining}}", fmt.Sprintf("%d", remainingEffective))
|
||||
prompt = strings.ReplaceAll(prompt, "{{budget_total}}", fmt.Sprintf("%d", effectiveBudget))
|
||||
prompt = strings.ReplaceAll(prompt, "{{budget_used}}", fmt.Sprintf("%d", usedEffective))
|
||||
prompt = strings.ReplaceAll(prompt, "{{budget}}", fmt.Sprintf("%d(总额度 %d,已用 %d)", remainingEffective, effectiveBudget, usedEffective))
|
||||
return prompt
|
||||
}
|
||||
|
||||
// pickSingleToolCall 在“单步动作模式”下选择一个工具调用。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. call=nil:没有可执行工具;
|
||||
// 2. warn 非空:模型返回了多个工具,本轮仅执行第一个。
|
||||
func pickSingleToolCall(toolCalls []reactToolCall) (*reactToolCall, string) {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
call := toolCalls[0]
|
||||
if len(toolCalls) == 1 {
|
||||
return &call, ""
|
||||
}
|
||||
return &call, fmt.Sprintf("模型返回了 %d 个工具调用,单步模式仅执行第一个:%s", len(toolCalls), call.Tool)
|
||||
}
|
||||
|
||||
// splitHybridEntriesByWeek 按 week 对混合条目分组并返回稳定周序。
|
||||
func splitHybridEntriesByWeek(entries []model.HybridScheduleEntry) ([]int, map[int][]model.HybridScheduleEntry) {
|
||||
byWeek := make(map[int][]model.HybridScheduleEntry)
|
||||
for _, entry := range entries {
|
||||
byWeek[entry.Week] = append(byWeek[entry.Week], entry)
|
||||
}
|
||||
weeks := make([]int, 0, len(byWeek))
|
||||
for week := range byWeek {
|
||||
weeks = append(weeks, week)
|
||||
}
|
||||
sort.Ints(weeks)
|
||||
return weeks, byWeek
|
||||
}
|
||||
|
||||
type weightedBudgetRemainder struct {
|
||||
Index int
|
||||
Remainder int
|
||||
Load int
|
||||
}
|
||||
|
||||
// splitWeeklyBudgetsByLoad 根据“有效周保底 + 周负载加权”拆分预算。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责:返回与 activeWeeks 同索引对齐的总预算/有效预算;
|
||||
// 2. 负责:在预算不足时按负载优先覆盖高负载周;
|
||||
// 3. 不负责:执行周级动作与状态落盘(由 runSingleWeekRefineWorker / runWeeklyRefineNode 负责)。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. coveredWeeks 表示“同时拿到 >=1 总预算和 >=1 有效预算”的周数;
|
||||
// 2. 当任一全局预算 <=0 时,返回全 0;上游将据此跳过对应周优化;
|
||||
// 3. 返回的 weeklyLoads 仅用于可观测性,不参与后续状态持久化。
|
||||
func splitWeeklyBudgetsByLoad(
|
||||
activeWeeks []int,
|
||||
weekEntries map[int][]model.HybridScheduleEntry,
|
||||
totalBudget int,
|
||||
effectiveBudget int,
|
||||
) (totalByWeek []int, effectiveByWeek []int, weeklyLoads []int, coveredWeeks int) {
|
||||
weekCount := len(activeWeeks)
|
||||
if weekCount == 0 {
|
||||
return nil, nil, nil, 0
|
||||
}
|
||||
|
||||
if totalBudget < 0 {
|
||||
totalBudget = 0
|
||||
}
|
||||
if effectiveBudget < 0 {
|
||||
effectiveBudget = 0
|
||||
}
|
||||
|
||||
weeklyLoads = buildWeeklyLoadScores(activeWeeks, weekEntries)
|
||||
totalByWeek = make([]int, weekCount)
|
||||
effectiveByWeek = make([]int, weekCount)
|
||||
if totalBudget == 0 || effectiveBudget == 0 {
|
||||
return totalByWeek, effectiveByWeek, weeklyLoads, 0
|
||||
}
|
||||
|
||||
// 1. 先计算“可保底覆盖周数”。
|
||||
// 1.1 目标是每个有效周至少 1 个总预算 + 1 个有效预算;
|
||||
// 1.2 失败场景:当预算小于有效周数量时,不可能全覆盖;
|
||||
// 1.3 兜底策略:只覆盖高负载周,避免把预算分散到无法执行的周。
|
||||
coveredWeeks = weekCount
|
||||
if totalBudget < coveredWeeks {
|
||||
coveredWeeks = totalBudget
|
||||
}
|
||||
if effectiveBudget < coveredWeeks {
|
||||
coveredWeeks = effectiveBudget
|
||||
}
|
||||
if coveredWeeks <= 0 {
|
||||
return totalByWeek, effectiveByWeek, weeklyLoads, 0
|
||||
}
|
||||
|
||||
coveredIndexes := pickTopLoadWeekIndexes(weeklyLoads, coveredWeeks)
|
||||
for _, idx := range coveredIndexes {
|
||||
totalByWeek[idx]++
|
||||
effectiveByWeek[idx]++
|
||||
}
|
||||
|
||||
// 2. 再把剩余预算按周负载加权分配。
|
||||
// 2.1 判断依据:负载越高,给到的额外预算越多,优先解决高密度周;
|
||||
// 2.2 失败场景:负载异常(<=0)会导致权重失真;
|
||||
// 2.3 兜底策略:权重最小按 1 处理,保证分配可持续、不会 panic。
|
||||
addWeightedBudget(totalByWeek, weeklyLoads, coveredIndexes, totalBudget-coveredWeeks)
|
||||
addWeightedBudget(effectiveByWeek, weeklyLoads, coveredIndexes, effectiveBudget-coveredWeeks)
|
||||
return totalByWeek, effectiveByWeek, weeklyLoads, coveredWeeks
|
||||
}
|
||||
|
||||
// buildWeeklyLoadScores 计算每个有效周的负载评分。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责:以 suggested 任务的节次跨度作为周负载;
|
||||
// 2. 不负责:预算分配策略与排序决策(由 splitWeeklyBudgetsByLoad/pickTopLoadWeekIndexes 负责)。
|
||||
func buildWeeklyLoadScores(
|
||||
activeWeeks []int,
|
||||
weekEntries map[int][]model.HybridScheduleEntry,
|
||||
) []int {
|
||||
loads := make([]int, len(activeWeeks))
|
||||
for idx, week := range activeWeeks {
|
||||
load := 0
|
||||
for _, entry := range weekEntries[week] {
|
||||
if entry.Status != "suggested" {
|
||||
continue
|
||||
}
|
||||
span := entry.SectionTo - entry.SectionFrom + 1
|
||||
if span <= 0 {
|
||||
span = 1
|
||||
}
|
||||
load += span
|
||||
}
|
||||
if load <= 0 {
|
||||
// 兜底:脏数据或异常节次下仍给该周最小权重,避免被完全饿死。
|
||||
load = 1
|
||||
}
|
||||
loads[idx] = load
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
// pickTopLoadWeekIndexes 选择负载最高的 topN 个周索引。
|
||||
func pickTopLoadWeekIndexes(loads []int, topN int) []int {
|
||||
if topN <= 0 || len(loads) == 0 {
|
||||
return nil
|
||||
}
|
||||
indexes := make([]int, len(loads))
|
||||
for i := range loads {
|
||||
indexes[i] = i
|
||||
}
|
||||
sort.SliceStable(indexes, func(i, j int) bool {
|
||||
left := loads[indexes[i]]
|
||||
right := loads[indexes[j]]
|
||||
if left != right {
|
||||
return left > right
|
||||
}
|
||||
return indexes[i] < indexes[j]
|
||||
})
|
||||
if topN > len(indexes) {
|
||||
topN = len(indexes)
|
||||
}
|
||||
selected := append([]int(nil), indexes[:topN]...)
|
||||
sort.Ints(selected)
|
||||
return selected
|
||||
}
|
||||
|
||||
// addWeightedBudget 把剩余预算按权重分配到目标周。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 先按整数份额分配;
|
||||
// 2. 再按“最大余数法”分发尾差,保证总和严格守恒;
|
||||
// 3. 余数相同时优先高负载周,再按索引稳定排序,避免结果抖动。
|
||||
func addWeightedBudget(
|
||||
budgets []int,
|
||||
loads []int,
|
||||
targetIndexes []int,
|
||||
remainingBudget int,
|
||||
) {
|
||||
if remainingBudget <= 0 || len(targetIndexes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
totalLoad := 0
|
||||
normalizedLoadByIndex := make(map[int]int, len(targetIndexes))
|
||||
for _, idx := range targetIndexes {
|
||||
load := 1
|
||||
if idx >= 0 && idx < len(loads) && loads[idx] > 0 {
|
||||
load = loads[idx]
|
||||
}
|
||||
normalizedLoadByIndex[idx] = load
|
||||
totalLoad += load
|
||||
}
|
||||
if totalLoad <= 0 {
|
||||
// 理论上不会出现;兜底均匀轮询分配,保证不会丢预算。
|
||||
for i := 0; i < remainingBudget; i++ {
|
||||
budgets[targetIndexes[i%len(targetIndexes)]]++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
allocated := 0
|
||||
remainders := make([]weightedBudgetRemainder, 0, len(targetIndexes))
|
||||
for _, idx := range targetIndexes {
|
||||
load := normalizedLoadByIndex[idx]
|
||||
shareProduct := remainingBudget * load
|
||||
share := shareProduct / totalLoad
|
||||
budgets[idx] += share
|
||||
allocated += share
|
||||
remainders = append(remainders, weightedBudgetRemainder{
|
||||
Index: idx,
|
||||
Remainder: shareProduct % totalLoad,
|
||||
Load: load,
|
||||
})
|
||||
}
|
||||
|
||||
left := remainingBudget - allocated
|
||||
if left <= 0 {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(remainders, func(i, j int) bool {
|
||||
if remainders[i].Remainder != remainders[j].Remainder {
|
||||
return remainders[i].Remainder > remainders[j].Remainder
|
||||
}
|
||||
if remainders[i].Load != remainders[j].Load {
|
||||
return remainders[i].Load > remainders[j].Load
|
||||
}
|
||||
return remainders[i].Index < remainders[j].Index
|
||||
})
|
||||
for i := 0; i < left; i++ {
|
||||
budgets[remainders[i%len(remainders)].Index]++
|
||||
}
|
||||
}
|
||||
|
||||
// sortHybridEntries 对条目做稳定排序,确保后续预览输出稳定。
|
||||
func sortHybridEntries(entries []model.HybridScheduleEntry) {
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
left := entries[i]
|
||||
right := entries[j]
|
||||
if left.Week != right.Week {
|
||||
return left.Week < right.Week
|
||||
}
|
||||
if left.DayOfWeek != right.DayOfWeek {
|
||||
return left.DayOfWeek < right.DayOfWeek
|
||||
}
|
||||
if left.SectionFrom != right.SectionFrom {
|
||||
return left.SectionFrom < right.SectionFrom
|
||||
}
|
||||
if left.SectionTo != right.SectionTo {
|
||||
return left.SectionTo < right.SectionTo
|
||||
}
|
||||
if left.Status != right.Status {
|
||||
// existing 放前,suggested 放后,便于观察课表底板与建议层。
|
||||
return left.Status < right.Status
|
||||
}
|
||||
return left.Name < right.Name
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// schedulePlanRunner 是"单次图运行"的请求级依赖容器。
|
||||
// schedulePlanRunner 是“单次图执行”的请求级依赖容器。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1) 把节点运行所需依赖(model/deps/emit/extra/history)就近收口;
|
||||
// 2) 让 graph.go 只保留"节点连线"和"方法引用",提升可读性;
|
||||
// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。
|
||||
// 1. 把节点运行所需依赖(model/deps/emit/extra/history)就近收口;
|
||||
// 2. 让 graph.go 只保留“节点连线与分支决策”,提升可读性;
|
||||
// 3. 避免在 graph.go 里重复出现大量闭包和参数透传。
|
||||
type schedulePlanRunner struct {
|
||||
chatModel *ark.ChatModel
|
||||
deps SchedulePlanToolDeps
|
||||
@@ -20,13 +20,15 @@ type schedulePlanRunner struct {
|
||||
userMessage string
|
||||
extra map[string]any
|
||||
chatHistory []*schema.Message
|
||||
// ── ReAct 精排所需 ──
|
||||
outChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content
|
||||
modelName string // 模型名称,用于构造 OpenAI 兼容 chunk
|
||||
|
||||
// weekly refine 需要的上下文
|
||||
outChan chan<- string
|
||||
modelName string
|
||||
|
||||
// daily refine 并发度
|
||||
dailyRefineConcurrency int
|
||||
}
|
||||
|
||||
// newSchedulePlanRunner 构造请求级 runner。
|
||||
// 生命周期仅限一次 graph invoke,不做跨请求复用。
|
||||
func newSchedulePlanRunner(
|
||||
chatModel *ark.ChatModel,
|
||||
deps SchedulePlanToolDeps,
|
||||
@@ -36,37 +38,49 @@ func newSchedulePlanRunner(
|
||||
chatHistory []*schema.Message,
|
||||
outChan chan<- string,
|
||||
modelName string,
|
||||
dailyRefineConcurrency int,
|
||||
) *schedulePlanRunner {
|
||||
return &schedulePlanRunner{
|
||||
chatModel: chatModel,
|
||||
deps: deps,
|
||||
emitStage: emitStage,
|
||||
userMessage: userMessage,
|
||||
extra: extra,
|
||||
chatHistory: chatHistory,
|
||||
outChan: outChan,
|
||||
modelName: modelName,
|
||||
chatModel: chatModel,
|
||||
deps: deps,
|
||||
emitStage: emitStage,
|
||||
userMessage: userMessage,
|
||||
extra: extra,
|
||||
chatHistory: chatHistory,
|
||||
outChan: outChan,
|
||||
modelName: modelName,
|
||||
dailyRefineConcurrency: dailyRefineConcurrency,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 节点方法引用适配层 ──
|
||||
// 节点方法适配层
|
||||
|
||||
func (r *schedulePlanRunner) planNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runPlanNode(ctx, st, r.chatModel, r.userMessage, r.extra, r.chatHistory, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) previewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runPreviewNode(ctx, st, r.deps, r.emitStage)
|
||||
func (r *schedulePlanRunner) roughBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runRoughBuildNode(ctx, st, r.deps, r.emitStage)
|
||||
}
|
||||
|
||||
// ── ReAct 精排节点适配层 ──
|
||||
|
||||
func (r *schedulePlanRunner) hybridBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runHybridBuildNode(ctx, st, r.deps, r.emitStage)
|
||||
func (r *schedulePlanRunner) dailySplitNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runDailySplitNode(ctx, st, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) reactRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runReactRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage)
|
||||
func (r *schedulePlanRunner) dailyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runDailyRefineNode(ctx, st, r.chatModel, r.dailyRefineConcurrency, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) mergeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runMergeNode(ctx, st, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) weeklyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runWeeklyRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) finalCheckNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
return runFinalCheckNode(ctx, st, r.chatModel, r.emitStage)
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
@@ -74,32 +88,27 @@ func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *Schedule
|
||||
}
|
||||
|
||||
func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) {
|
||||
// exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ── 分支决策适配层 ──
|
||||
// 分支决策适配层
|
||||
|
||||
func (r *schedulePlanRunner) nextAfterPlan(_ context.Context, st *SchedulePlanState) (string, error) {
|
||||
return selectNextAfterPlan(st), nil
|
||||
}
|
||||
|
||||
// nextAfterPreview 根据 preview 结果决定下一步。
|
||||
// nextAfterRoughBuild 根据粗排构建结果决定后续路径。
|
||||
//
|
||||
// 分支规则:
|
||||
// 1) preview 失败(无候选方案)-> exit
|
||||
// 2) 否则 -> hybridBuild(进入 ReAct 精排路径)
|
||||
func (r *schedulePlanRunner) nextAfterPreview(_ context.Context, st *SchedulePlanState) (string, error) {
|
||||
if st == nil || len(st.CandidatePlans) == 0 {
|
||||
return schedulePlanGraphNodeExit, nil
|
||||
}
|
||||
return schedulePlanGraphNodeHybridBuild, nil
|
||||
}
|
||||
|
||||
// nextAfterHybridBuild 根据 hybridBuild 结果决定下一步。
|
||||
func (r *schedulePlanRunner) nextAfterHybridBuild(_ context.Context, st *SchedulePlanState) (string, error) {
|
||||
// 规则:
|
||||
// 1. 没有可优化条目 -> exit;
|
||||
// 2. task_class_ids >= 2 -> dailySplit(多任务类混排,先做日内并发);
|
||||
// 3. task_class_ids == 1 -> weeklyRefine(单任务类直接周级配平)。
|
||||
func (r *schedulePlanRunner) nextAfterRoughBuild(_ context.Context, st *SchedulePlanState) (string, error) {
|
||||
if st == nil || len(st.HybridEntries) == 0 {
|
||||
return schedulePlanGraphNodeExit, nil
|
||||
}
|
||||
return schedulePlanGraphNodeReactRefine, nil
|
||||
if len(st.TaskClassIDs) >= 2 {
|
||||
return schedulePlanGraphNodeDailySplit, nil
|
||||
}
|
||||
return schedulePlanGraphNodeWeeklyRefine, nil
|
||||
}
|
||||
|
||||
@@ -13,13 +13,51 @@ const (
|
||||
|
||||
// schedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
|
||||
schedulePlanDatetimeLayout = "2006-01-02 15:04"
|
||||
|
||||
// schedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。
|
||||
// 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。
|
||||
schedulePlanDefaultDailyRefineConcurrency = 3
|
||||
|
||||
// schedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。
|
||||
// 额度存在的目的:
|
||||
// 1. 防止周级 ReAct 过度调整导致震荡;
|
||||
// 2. 控制 token 与时延成本;
|
||||
// 3. 让方案改动更可解释。
|
||||
schedulePlanDefaultWeeklyAdjustBudget = 5
|
||||
|
||||
// schedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。
|
||||
//
|
||||
// 设计意图:
|
||||
// 1. 总预算统计“动作尝试次数”(成功/失败都记一次);
|
||||
// 2. 有效预算统计“成功动作次数”(仅成功时记一次);
|
||||
// 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。
|
||||
schedulePlanDefaultWeeklyTotalBudget = 8
|
||||
|
||||
// schedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。
|
||||
// 说明:
|
||||
// 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流;
|
||||
// 2. 可在运行时按请求状态覆盖。
|
||||
schedulePlanDefaultWeeklyRefineConcurrency = 2
|
||||
)
|
||||
|
||||
// SchedulePlanState 是"智能排程"链路在 graph 节点间传递的统一状态容器。
|
||||
// DayGroup 是“按天拆分后”的最小优化单元。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模;
|
||||
// 2. 支持并发优化不同天的数据,缩短整体等待;
|
||||
// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。
|
||||
type DayGroup struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Entries []model.HybridScheduleEntry
|
||||
SkipRefine bool
|
||||
}
|
||||
|
||||
// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散<EFBFBD><EFBFBD><EFBFBD>;
|
||||
// 2) 支持"粗排 -> 校验 -> 修补重试 -> 落库"的完整链路追踪;
|
||||
// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落;
|
||||
// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪;
|
||||
// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
|
||||
type SchedulePlanState struct {
|
||||
// ── 基础上下文 ──
|
||||
@@ -35,31 +73,93 @@ type SchedulePlanState struct {
|
||||
UserIntent string
|
||||
// Constraints 是用户提出的硬约束列表(如 ["早八不排", "周末休息"])。
|
||||
Constraints []string
|
||||
// TaskClassID 是目标任务类 ID,由 Extra 字段或模型抽取获得。
|
||||
TaskClassID int
|
||||
// TaskClassIDs 是本次请求携带的任务类集合(统一主语义)。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这里明确不再维护单值 task_class_id,避免“单值和切片同时存在”导致语义漂移;
|
||||
// 2. 分流依据统一为 len(TaskClassIDs):
|
||||
// 2.1 len==1:跳过 daily 并发,直接进入 weekly refine;
|
||||
// 2.2 len>=2:进入 daily 并发后再 weekly refine;
|
||||
// 3. 输入清洗(去重、过滤非法值)由 plan 节点完成,这里只承载最终状态。
|
||||
TaskClassIDs []int
|
||||
// Strategy 是排程策略(steady/rapid),默认 steady。
|
||||
Strategy string
|
||||
// TaskTags 是“任务项 ID -> 认知类型标签”的映射。
|
||||
// 使用 ID 而不是名称,目的是规避“同名任务”带来的映射冲突。
|
||||
TaskTags map[int]string
|
||||
// TaskTagHintsByName 是“任务名称 -> 认知类型标签”的临时映射。
|
||||
// 该字段只作为 plan 输出兼容层:
|
||||
// 1. 若模型暂时给不出 task_item_id,只给名称;
|
||||
// 2. 后续在 hybridBuild/dailySplit 阶段再转换为 TaskTags(ID 维度)。
|
||||
TaskTagHintsByName map[string]string
|
||||
|
||||
// ── preview 节点输出 ──
|
||||
|
||||
// CandidatePlans 是粗排算法生成的候选方案(展示型结构,供 SSE 推送给前端预览)。
|
||||
// CandidatePlans 是粗排算法生成的候选方案(展示型结构,供后续节点做预览与总结)。
|
||||
CandidatePlans []model.UserWeekSchedule
|
||||
// AllocatedItems 是粗排算法已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。
|
||||
AllocatedItems []model.TaskClassItem
|
||||
// HasPlanningWindow 标记是否成功解析出“任务类时间窗”的相对周/天边界。
|
||||
//
|
||||
// 语义:
|
||||
// 1. true:PlanStart*/PlanEnd* 字段可用于 Move 工具的硬边界校验;
|
||||
// 2. false:表示当前运行未拿到窗口信息(例如依赖未注入),工具层将仅做基础校验。
|
||||
HasPlanningWindow bool
|
||||
// PlanStartWeek / PlanStartDay 表示全局排程窗口起点(相对周/天)。
|
||||
PlanStartWeek int
|
||||
PlanStartDay int
|
||||
// PlanEndWeek / PlanEndDay 表示全局排程窗口终点(相对周/天)。
|
||||
PlanEndWeek int
|
||||
PlanEndDay int
|
||||
|
||||
// ── ReAct 精排阶段 ──
|
||||
// ── 日内并发优化阶段 ──
|
||||
|
||||
// DailyGroups 是按 (week, day) 拆分后的单日优化输入。
|
||||
// 结构:week -> day -> DayGroup。
|
||||
DailyGroups map[int]map[int]*DayGroup
|
||||
// DailyResults 是单日优化输出。
|
||||
// 结构:week -> day -> []HybridScheduleEntry。
|
||||
DailyResults map[int]map[int][]model.HybridScheduleEntry
|
||||
// DailyRefineConcurrency 是日内并发优化的并发度。
|
||||
// 说明:该值由配置注入,可按环境调节。
|
||||
DailyRefineConcurrency int
|
||||
|
||||
// ── 周级 ReAct 精排阶段 ──
|
||||
|
||||
// HybridEntries 是混合日程条目列表,包含既有日程(existing)和粗排建议(suggested)。
|
||||
// ReAct 工具直接在此切片上操作(内存修改,不涉及 DB)。
|
||||
// 周级 ReAct 工具直接在此切片上操作(内存修改,不涉及 DB)。
|
||||
HybridEntries []model.HybridScheduleEntry
|
||||
// ReactRound 当前 ReAct 循环轮次。
|
||||
// MergeSnapshot 是 merge 后快照。
|
||||
// 终审失败时回退到该快照,确保至少保留“日内优化成果”。
|
||||
MergeSnapshot []model.HybridScheduleEntry
|
||||
// ReactRound 当前周级 ReAct 循环轮次。
|
||||
ReactRound int
|
||||
// ReactMaxRound 最大循环轮次(建议 3)。
|
||||
// ReactMaxRound 周级 ReAct 最大循环轮次。
|
||||
ReactMaxRound int
|
||||
// ReactSummary LLM 输出的优化摘要。
|
||||
// ReactSummary 周级 ReAct 输出的优化摘要。
|
||||
ReactSummary string
|
||||
// ReactDone 标记 ReAct 是否已完成。
|
||||
// ReactDone 标记周级 ReAct 是否已完成。
|
||||
ReactDone bool
|
||||
// WeeklyAdjustBudget 是周级跨天调整额度上限。
|
||||
// 语义:有效动作预算(仅工具调用成功时扣减)。
|
||||
WeeklyAdjustBudget int
|
||||
// WeeklyAdjustUsed 是周级跨天调整已使用额度。
|
||||
// 语义:有效动作已使用次数(仅成功调用时递增)。
|
||||
WeeklyAdjustUsed int
|
||||
// WeeklyTotalBudget 是周级总动作预算。
|
||||
// 语义:总尝试次数预算(成功/失败都扣减)。
|
||||
WeeklyTotalBudget int
|
||||
// WeeklyTotalUsed 是周级总动作已使用次数。
|
||||
// 语义:成功/失败每执行一次工具调用都递增。
|
||||
WeeklyTotalUsed int
|
||||
// WeeklyRefineConcurrency 是周级“按周并发”并发度。
|
||||
WeeklyRefineConcurrency int
|
||||
// WeeklyActionLogs 记录周级优化阶段的关键动作流水。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 供 final_check 的总结模型理解“优化过程”,而非只看最终静态结果;
|
||||
// 2. 供调试排查时快速回放“每轮做了什么动作、是否成功、为何失败”。
|
||||
WeeklyActionLogs []string
|
||||
|
||||
// ── 连续对话微调 ──
|
||||
|
||||
@@ -68,6 +168,30 @@ type SchedulePlanState struct {
|
||||
PreviousPlanJSON string
|
||||
// IsAdjustment 标记本次是否为微调请求(而非全新排程)。
|
||||
IsAdjustment bool
|
||||
// HasPreviousPreview 标记是否命中“同会话上一次排程预览快照”。
|
||||
//
|
||||
// 语义:
|
||||
// 1. true:可以尝试复用上次 HybridEntries 作为本轮优化起点;
|
||||
// 2. false:按全新排程路径构建粗排底板。
|
||||
HasPreviousPreview bool
|
||||
// PreviousTaskClassIDs 是上一次预览对应的任务类集合。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 本轮未显式传 task_class_ids 时作为兜底;
|
||||
// 2. 仅会话内承接,不改动数据库。
|
||||
PreviousTaskClassIDs []int
|
||||
// PreviousHybridEntries 是上一次预览保存的混合日程条目。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 连续对话微调时直接复用,避免重新粗排;
|
||||
// 2. 若为空则回退到粗排构建路径。
|
||||
PreviousHybridEntries []model.HybridScheduleEntry
|
||||
// PreviousAllocatedItems 是上一次预览保存的任务块分配结果。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 保持 final_check 的数量核对口径稳定;
|
||||
// 2. return_preview 阶段可继续回填 embedded_time。
|
||||
PreviousAllocatedItems []model.TaskClassItem
|
||||
|
||||
// ── 最终输出 ──
|
||||
|
||||
@@ -81,13 +205,19 @@ type SchedulePlanState struct {
|
||||
func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState {
|
||||
now := schedulePlanNowToMinute()
|
||||
return &SchedulePlanState{
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
RequestNow: now,
|
||||
RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout),
|
||||
Strategy: "steady",
|
||||
ReactMaxRound: 3,
|
||||
TraceID: traceID,
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
RequestNow: now,
|
||||
RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout),
|
||||
Strategy: "steady",
|
||||
TaskTags: make(map[int]string),
|
||||
TaskTagHintsByName: make(map[string]string),
|
||||
DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency,
|
||||
WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency,
|
||||
ReactMaxRound: 2,
|
||||
WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget,
|
||||
WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,50 @@ package scheduleplan
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// SchedulePlanToolDeps 描述"智能排程工具包"需要的外部依赖。
|
||||
// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。
|
||||
//
|
||||
// 设计目标:
|
||||
// 1) 通过函数注入把 agent 包与 service/dao 解耦,避免循环依赖;
|
||||
// 2) 每个函数对应一个可独立 mock 的业务能力;
|
||||
// 3) 后续可按需扩展(如局部修补、任务类自动生成等)。
|
||||
// 职责边界:
|
||||
// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。
|
||||
// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。
|
||||
// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。
|
||||
type SchedulePlanToolDeps struct {
|
||||
// SmartPlanningRaw 调用粗排算法,同时返回展示结构和已分配的任务项。
|
||||
// 返回值:
|
||||
// - []UserWeekSchedule:展示型结构,供 SSE 阶段推送给前端预览;
|
||||
// - []TaskClassItem:已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。
|
||||
SmartPlanningRaw func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
|
||||
// SmartPlanningMultiRaw 是可选依赖:
|
||||
// 1) 用于需要单独输出“粗排预览”时复用;
|
||||
// 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。
|
||||
SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
|
||||
|
||||
// HybridScheduleWithPlan 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。
|
||||
HybridScheduleWithPlan func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
|
||||
// HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片,
|
||||
// 供 daily/weekly ReAct 节点在内存中继续优化。
|
||||
HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
|
||||
|
||||
// ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. startWeek/startDay:窗口起点(含);
|
||||
// 2. endWeek/endDay:窗口终点(含);
|
||||
// 3. error:解析失败(如任务类不存在、日期非法)。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数;
|
||||
// 2. 解决“首尾不足一周”场景下的周内越界问题。
|
||||
ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
|
||||
}
|
||||
|
||||
// validate 校验依赖完整性,缺失任意一个都无法完成排程链路。
|
||||
// validate 校验依赖完整性。
|
||||
//
|
||||
// 失败处理:
|
||||
// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。
|
||||
// 2. 调用方(runSchedulePlanFlow)收到错误后会走回退链路,不影响普通聊天可用性。
|
||||
func (d SchedulePlanToolDeps) validate() error {
|
||||
if d.SmartPlanningRaw == nil {
|
||||
return errors.New("schedule plan tool deps: SmartPlanningRaw is nil")
|
||||
}
|
||||
if d.HybridScheduleWithPlan == nil {
|
||||
return errors.New("schedule plan tool deps: HybridScheduleWithPlan is nil")
|
||||
if d.HybridScheduleWithPlanMulti == nil {
|
||||
return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -59,3 +74,74 @@ func ExtraInt(extra map[string]any, key string) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// ExtraIntSlice 从 extra map 中安全提取整数切片。
|
||||
//
|
||||
// 兼容输入:
|
||||
// 1) []any(JSON 数组反序列化后的常见类型);
|
||||
// 2) []int;
|
||||
// 3) []float64;
|
||||
// 4) 逗号分隔字符串(例如 "1,2,3")。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1) ok=true:至少成功解析出一个整数;
|
||||
// 2) ok=false:字段不存在或全部解析失败。
|
||||
func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) {
|
||||
v, exists := extra[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
parseOne := func(raw any) (int, error) {
|
||||
switch n := raw.(type) {
|
||||
case int:
|
||||
return n, nil
|
||||
case float64:
|
||||
return int(n), nil
|
||||
case string:
|
||||
i, err := strconv.Atoi(n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return i, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported type: %T", raw)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]int, 0)
|
||||
switch arr := v.(type) {
|
||||
case []int:
|
||||
for _, item := range arr {
|
||||
out = append(out, item)
|
||||
}
|
||||
case []float64:
|
||||
for _, item := range arr {
|
||||
out = append(out, int(item))
|
||||
}
|
||||
case []any:
|
||||
for _, item := range arr {
|
||||
if parsed, err := parseOne(item); err == nil {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
parts := strings.Split(arr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ type reactLLMOutput struct {
|
||||
ToolCalls []reactToolCall `json:"tool_calls"`
|
||||
}
|
||||
|
||||
// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。
|
||||
//
|
||||
// 语义:
|
||||
// 1. Enabled=false:不启用窗口硬边界,仅做基础合法性校验;
|
||||
// 2. Enabled=true:Move 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内;
|
||||
// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。
|
||||
type weeklyPlanningWindow struct {
|
||||
Enabled bool
|
||||
StartWeek int
|
||||
StartDay int
|
||||
EndWeek int
|
||||
EndDay int
|
||||
}
|
||||
|
||||
// ── 工具分发器 ──
|
||||
|
||||
// dispatchReactTool 根据工具名分发调用,返回(可能修改后的)entries 和执行结果。
|
||||
@@ -49,6 +63,88 @@ func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots;
|
||||
// 2. 强制 Move 的目标周必须等于 currentWeek,避免并发周优化时发生跨周写穿;
|
||||
// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。
|
||||
func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
|
||||
tool := strings.TrimSpace(call.Tool)
|
||||
switch tool {
|
||||
case "Swap":
|
||||
return reactToolSwap(entries, call.Params)
|
||||
case "Move":
|
||||
// 1. 周级并发模式下,每个 worker 只负责单周数据。
|
||||
// 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。
|
||||
// 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。
|
||||
toWeek, ok := paramInt(call.Params, "to_week")
|
||||
if !ok {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"}
|
||||
}
|
||||
if toWeek != currentWeek {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("当前仅允许优化本周:worker_week=%d,目标周=%d", currentWeek, toWeek),
|
||||
}
|
||||
}
|
||||
// 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。
|
||||
// 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。
|
||||
// 4.2 窗口未启用时不阻断,保持兼容旧链路。
|
||||
if window.Enabled {
|
||||
toDay, ok := paramInt(call.Params, "to_day")
|
||||
if !ok {
|
||||
return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"}
|
||||
}
|
||||
allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay)
|
||||
if !allowed {
|
||||
return entries, reactToolResult{
|
||||
Tool: "Move",
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("目标日期超出排程窗口:W%d 仅允许 D%d-D%d,当前目标为 D%d", toWeek, dayFrom, dayTo, toDay),
|
||||
}
|
||||
}
|
||||
}
|
||||
return reactToolMove(entries, call.Params)
|
||||
default:
|
||||
return entries, reactToolResult{
|
||||
Tool: tool,
|
||||
Success: false,
|
||||
Result: fmt.Sprintf("周级单步模式不支持工具: %s,仅允许 Move/Swap", tool),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。
|
||||
//
|
||||
// 返回值:
|
||||
// 1. allowed:是否允许;
|
||||
// 2. dayFrom/dayTo:该周允许的 day 区间(用于错误提示)。
|
||||
func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) {
|
||||
// 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。
|
||||
if !window.Enabled {
|
||||
return true, 1, 7
|
||||
}
|
||||
// 2. 先做周范围校验。
|
||||
if week < window.StartWeek || week > window.EndWeek {
|
||||
return false, 1, 7
|
||||
}
|
||||
// 3. 计算当前周允许的 day 边界。
|
||||
from := 1
|
||||
to := 7
|
||||
if week == window.StartWeek {
|
||||
from = window.StartDay
|
||||
}
|
||||
if week == window.EndWeek {
|
||||
to = window.EndDay
|
||||
}
|
||||
if day < from || day > to {
|
||||
return false, from, to
|
||||
}
|
||||
return true, from, to
|
||||
}
|
||||
|
||||
// ── 参数提取辅助 ──
|
||||
|
||||
func paramInt(params map[string]any, key string) (int, bool) {
|
||||
@@ -81,12 +177,35 @@ func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
|
||||
return aFrom <= bTo && bFrom <= aTo
|
||||
}
|
||||
|
||||
// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。
|
||||
//
|
||||
// 规则:
|
||||
// 1. suggested 任务永远阻塞(任务之间不能重叠);
|
||||
// 2. existing 条目按 BlockForSuggested 字段决定;
|
||||
// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。
|
||||
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
|
||||
if entry.Status == "suggested" {
|
||||
return true
|
||||
}
|
||||
// existing 走显式字段语义。
|
||||
if entry.Status == "existing" {
|
||||
return entry.BlockForSuggested
|
||||
}
|
||||
// 未知状态兜底:按阻塞处理。
|
||||
return true
|
||||
}
|
||||
|
||||
// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx)。
|
||||
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) {
|
||||
for i, e := range entries {
|
||||
if i == excludeIdx {
|
||||
continue
|
||||
}
|
||||
// 1. 可嵌入且未占用的课程槽(BlockForSuggested=false)不参与冲突判断。
|
||||
// 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。
|
||||
if !entryBlocksSuggested(e) {
|
||||
continue
|
||||
}
|
||||
if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) {
|
||||
return true, fmt.Sprintf("%s(%s)", e.Name, e.Type)
|
||||
}
|
||||
@@ -231,6 +350,9 @@ func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[
|
||||
type slotKey struct{ W, D, S int }
|
||||
occupied := make(map[slotKey]bool)
|
||||
for _, e := range entries {
|
||||
if !entryBlocksSuggested(e) {
|
||||
continue
|
||||
}
|
||||
for s := e.SectionFrom; s <= e.SectionTo; s++ {
|
||||
occupied[slotKey{e.Week, e.DayOfWeek, s}] = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user