♻️ refactor(schedule-refine): [WIP] 重构 Plan-and-Execute ReAct 链路,并增强 JSON 解析兜底能力 - 🧩 重构 `schedulerefine` 主流程,引入 `Planner` / `Replan` 机制,以及执行预算与轮次状态管理 - 🧠 扩展状态与观察上下文,补充工具结果、失败签名、连续失败计数与后置反思策略等信息 - 🔧 增强工具层能力与参数兼容性,补齐 `Query` / `Move` / `Swap` / `BatchMove` / `Verify` 等行为及约束校验 - 🛡️ 提升解析鲁棒性,支持从代码块或混杂文本中提取首个 JSON 对象,并增加单次解析重试机制 - 👀 增强可观测性,补充 `debug raw` 阶段输出与分片透传能力 - ✍️ 优化提示词近端约束,将严格 JSON 输出协议追加到各节点 `userPrompt` 末尾 - 🚧 备注:当前链路仍处于持续调优阶段,稳定性与可用性仍需进一步验证
309 lines
10 KiB
Go
309 lines
10 KiB
Go
package schedulerefine
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
)
|
||
|
||
const (
|
||
// timezoneName 固定排程链路使用的业务时区,避免容器默认时区导致“明天/今晚”偏移。
|
||
timezoneName = "Asia/Shanghai"
|
||
// datetimeLayout 统一使用分钟级时间文本,方便模型理解与日志比对。
|
||
datetimeLayout = "2006-01-02 15:04"
|
||
// defaultPlanMax 是 Planner 最大调用次数(包含首次规划 + 重规划)。
|
||
defaultPlanMax = 2
|
||
// defaultExecuteMax 是执行阶段最大工具动作轮次。
|
||
defaultExecuteMax = 16
|
||
// defaultReplanMax 是执行阶段允许触发的重规划次数上限。
|
||
defaultReplanMax = 2
|
||
// defaultRepairReserve 表示为“终审修复”保留的最小动作预算。
|
||
defaultRepairReserve = 1
|
||
)
|
||
|
||
// RefineContract 表示“微调意图契约”。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责承载“本轮微调到底要满足什么”的结构化目标;
|
||
// 2. 负责给后续 ReAct 动作与终审硬校验提供统一语义;
|
||
// 3. 不负责实际排程修改动作执行(动作由工具层负责)。
|
||
type RefineContract struct {
|
||
Intent string `json:"intent"`
|
||
Strategy string `json:"strategy"`
|
||
HardRequirements []string `json:"hard_requirements"`
|
||
KeepRelativeOrder bool `json:"keep_relative_order"`
|
||
OrderScope string `json:"order_scope"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// HardCheckReport 表示“终审硬校验报告”。
|
||
//
|
||
// 职责边界:
|
||
// 1. 记录规则层(物理冲突)是否通过;
|
||
// 2. 记录语义层(是否满足用户要求)是否通过;
|
||
// 3. 记录顺序层(是否保持相对顺序)是否通过;
|
||
// 4. 记录失败原因与修复尝试信息,便于后续持续优化 prompt;
|
||
// 5. 不负责直接决定是否落库(落库决策仍由服务层控制)。
|
||
type HardCheckReport struct {
|
||
PhysicsPassed bool `json:"physics_passed"`
|
||
PhysicsIssues []string `json:"physics_issues,omitempty"`
|
||
|
||
IntentPassed bool `json:"intent_passed"`
|
||
IntentReason string `json:"intent_reason,omitempty"`
|
||
IntentUnmet []string `json:"intent_unmet,omitempty"`
|
||
|
||
OrderPassed bool `json:"order_passed"`
|
||
OrderIssues []string `json:"order_issues,omitempty"`
|
||
|
||
RepairTried bool `json:"repair_tried"`
|
||
}
|
||
|
||
// ReactRoundObservation 用于沉淀“每轮 ReAct 的可见观测信息”。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责记录每轮“计划 -> 动作 -> 观察 -> 反思”的关键信息;
|
||
// 2. 既用于 SSE 透传,也用于下一轮 prompt 的上下文回灌;
|
||
// 3. 不承担排程真实数据存储职责(真实排程仍在 HybridEntries)。
|
||
type ReactRoundObservation struct {
|
||
Round int `json:"round"`
|
||
GoalCheck string `json:"goal_check,omitempty"`
|
||
Decision string `json:"decision,omitempty"`
|
||
MissingInfo []string `json:"missing_info,omitempty"`
|
||
ToolName string `json:"tool_name,omitempty"`
|
||
ToolParams map[string]any `json:"tool_params,omitempty"`
|
||
ToolSuccess bool `json:"tool_success"`
|
||
ToolErrorCode string `json:"tool_error_code,omitempty"`
|
||
ToolResult string `json:"tool_result,omitempty"`
|
||
Reflect string `json:"reflect,omitempty"`
|
||
}
|
||
|
||
// PlannerPlan 表示“本轮执行前的结构化计划”。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责记录模型当前建议的执行路径(先查什么、再做什么);
|
||
// 2. 负责在失败重规划后替换为新版本,供执行器下一轮参考;
|
||
// 3. 不直接约束工具执行结果(执行合法性仍由工具层硬校验负责)。
|
||
type PlannerPlan struct {
|
||
Summary string `json:"summary"`
|
||
Steps []string `json:"steps,omitempty"`
|
||
SuccessSignals []string `json:"success_signals,omitempty"`
|
||
Fallback string `json:"fallback,omitempty"`
|
||
}
|
||
|
||
// ScheduleRefineState 是“连续微调图”的统一状态容器。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责在图节点间传递“上一版排程快照 + 本轮用户微调请求 + 动作日志 + 终审报告”;
|
||
// 2. 负责承载最终对用户可见的 summary 与结构化 candidate_plans;
|
||
// 3. 不负责 Redis/MySQL 读写(持久化由 service 层负责)。
|
||
type ScheduleRefineState struct {
|
||
// 1. 基础请求上下文。
|
||
TraceID string
|
||
UserID int
|
||
ConversationID string
|
||
UserMessage string
|
||
RequestNow time.Time
|
||
RequestNowText string
|
||
|
||
// 2. 继承自上一版预览快照的可调度数据。
|
||
TaskClassIDs []int
|
||
Constraints []string
|
||
HybridEntries []model.HybridScheduleEntry
|
||
AllocatedItems []model.TaskClassItem
|
||
CandidatePlans []model.UserWeekSchedule
|
||
|
||
// 3. 本轮微调过程状态。
|
||
UserIntent string
|
||
Contract RefineContract
|
||
|
||
PlanMax int
|
||
ExecuteMax int
|
||
ReplanMax int
|
||
|
||
PlanUsed int
|
||
ReplanUsed int
|
||
|
||
// MaxRounds 保留“总预算”语义,供终审修复节点继续复用:
|
||
// MaxRounds = ExecuteMax + RepairReserve
|
||
MaxRounds int
|
||
RepairReserve int
|
||
RoundUsed int
|
||
ActionLogs []string
|
||
|
||
// ConsecutiveFailures 记录执行阶段连续失败次数,用于触发“失败兜底 thinking”。
|
||
ConsecutiveFailures int
|
||
// ThinkingBoostArmed 表示“当前失败串已触发过一次 thinking 兜底”。
|
||
ThinkingBoostArmed bool
|
||
|
||
LastToolResult string
|
||
ObservationHistory []ReactRoundObservation
|
||
CurrentPlan PlannerPlan
|
||
LastPostStrategy string
|
||
// LastFailedCallSignature 记录“上一轮失败动作签名(tool+params)”。用于后端硬拦截重复失败动作。
|
||
LastFailedCallSignature string
|
||
OriginOrderMap map[int]int
|
||
|
||
// 4. 终审校验状态。
|
||
HardCheck HardCheckReport
|
||
|
||
// 5. 最终输出。
|
||
FinalSummary string
|
||
Completed bool
|
||
}
|
||
|
||
// NewScheduleRefineState 基于“上一版排程预览快照”初始化连续微调状态。
|
||
//
|
||
// 步骤化说明:
|
||
// 1. 先初始化请求基础字段与默认预算,保证图内每个节点都能读取到稳定上下文。
|
||
// 2. 再把 preview 的核心排程数据做深拷贝注入,避免跨请求引用污染。
|
||
// 3. 最后构建 origin_order_map,作为“保持相对顺序”硬约束的判定基线。
|
||
// 4. 若 preview 为空,仍返回可用 state,由上层决定是报错还是降级。
|
||
func NewScheduleRefineState(traceID string, userID int, conversationID string, userMessage string, preview *model.SchedulePlanPreviewCache) *ScheduleRefineState {
|
||
now := nowToMinute()
|
||
st := &ScheduleRefineState{
|
||
TraceID: strings.TrimSpace(traceID),
|
||
UserID: userID,
|
||
ConversationID: strings.TrimSpace(conversationID),
|
||
UserMessage: strings.TrimSpace(userMessage),
|
||
RequestNow: now,
|
||
RequestNowText: now.In(loadLocation()).Format(datetimeLayout),
|
||
PlanMax: defaultPlanMax,
|
||
ExecuteMax: defaultExecuteMax,
|
||
ReplanMax: defaultReplanMax,
|
||
RepairReserve: defaultRepairReserve,
|
||
MaxRounds: defaultExecuteMax + defaultRepairReserve,
|
||
ActionLogs: make([]string, 0, 24),
|
||
ObservationHistory: make([]ReactRoundObservation, 0, 16),
|
||
OriginOrderMap: make(map[int]int),
|
||
CurrentPlan: PlannerPlan{
|
||
Summary: "初始化完成,等待 Planner 生成执行计划。",
|
||
},
|
||
}
|
||
if preview == nil {
|
||
return st
|
||
}
|
||
|
||
st.TaskClassIDs = append([]int(nil), preview.TaskClassIDs...)
|
||
st.HybridEntries = cloneHybridEntries(preview.HybridEntries)
|
||
st.AllocatedItems = cloneTaskClassItems(preview.AllocatedItems)
|
||
st.CandidatePlans = cloneWeekSchedules(preview.CandidatePlans)
|
||
st.OriginOrderMap = buildOriginOrderMap(st.HybridEntries)
|
||
return st
|
||
}
|
||
|
||
// loadLocation 返回排程链路使用的业务时区。
|
||
func loadLocation() *time.Location {
|
||
loc, err := time.LoadLocation(timezoneName)
|
||
if err != nil {
|
||
return time.Local
|
||
}
|
||
return loc
|
||
}
|
||
|
||
// nowToMinute 返回当前时刻并截断到分钟级,降低 prompt 中秒级噪声。
|
||
func nowToMinute() time.Time {
|
||
return time.Now().In(loadLocation()).Truncate(time.Minute)
|
||
}
|
||
|
||
// cloneHybridEntries 深拷贝混合日程切片。
|
||
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]model.HybridScheduleEntry, len(src))
|
||
copy(dst, src)
|
||
return dst
|
||
}
|
||
|
||
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段)。
|
||
func cloneTaskClassItems(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
|
||
}
|
||
|
||
// cloneWeekSchedules 深拷贝周视图切片。
|
||
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]model.UserWeekSchedule, 0, len(src))
|
||
for _, week := range src {
|
||
eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
|
||
copy(eventsCopy, week.Events)
|
||
dst = append(dst, model.UserWeekSchedule{
|
||
Week: week.Week,
|
||
Events: eventsCopy,
|
||
})
|
||
}
|
||
return dst
|
||
}
|
||
|
||
// buildOriginOrderMap 从当前 suggested 排程位置构建“初始相对顺序映射”。
|
||
//
|
||
// 步骤化说明:
|
||
// 1. 先筛出所有可调的 suggested 任务;
|
||
// 2. 按 week/day/section/task_item_id 稳定排序,得到“时间先后基线”;
|
||
// 3. 把 task_item_id -> rank 写入 map,后续 Move/Swap 都基于该 rank 做顺序硬校验。
|
||
func buildOriginOrderMap(entries []model.HybridScheduleEntry) map[int]int {
|
||
orderMap := make(map[int]int)
|
||
if len(entries) == 0 {
|
||
return orderMap
|
||
}
|
||
suggested := make([]model.HybridScheduleEntry, 0, len(entries))
|
||
for _, entry := range entries {
|
||
if entry.Status == "suggested" && entry.TaskItemID > 0 {
|
||
suggested = append(suggested, entry)
|
||
}
|
||
}
|
||
sort.SliceStable(suggested, func(i, j int) bool {
|
||
left := suggested[i]
|
||
right := suggested[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
|
||
}
|
||
return left.TaskItemID < right.TaskItemID
|
||
})
|
||
for idx, entry := range suggested {
|
||
orderMap[entry.TaskItemID] = idx + 1
|
||
}
|
||
return orderMap
|
||
}
|