Version: 0.9.11.dev.260409
后端: 1. conv 并行迁移与切流接线(旧目录下沉到 newAgent/conv) - 新建 newAgent/conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go,保持原有排程转换/预览/持久化能力; - 删除旧目录 conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go; - 更新 cmd/start.go 与 service/agentsvc/agent_newagent.go,ScheduleProvider/SchedulePersistor 与 preview 转换统一切到 newAgent/conv; - 删除旧 conv/schedule_state_test.go(迁移期测试文件清理)。 2. execute 循环上下文收口增强(历史归档 + 当前轮清晰化) - 更新 node/chat.go:仅在 completed 收口时写 execute_loop_closed marker,供后续 prompt 分层归档; - 更新 prompt/execute_context.go:msg1/msg2 升级为 V3,按收口标记拆分“历史归档 loop / 当前活跃 loop”,并增加 msg1 长度预算裁剪; - 更新 node/execute.go:新增 execute 置顶上下文同步(execution_context/current_step),在轮次开始与 next_plan 后即时刷新; - 更新 prompt/execute.go + execute_context.go:补齐“当前计划步骤 + done_when”强约束,禁止未达成判定时提前 next_plan。 3. 图路由与执行策略微调 - 更新 graph/common_graph.go:Plan/Confirm 分支允许直接进入 Deliver 收口; - 更新 node/plan.go:always_execute 链路下补发计划摘要并写入历史,保证自动执行与手动确认文案一致; - 更新 model/common_state.go:DefaultMaxRounds 从 30 提升到 60。 4. 复合工具规划器重构(去重实现,复用 logic 公共能力) - 更新 tools/compound_tools.go:min_context_switch / spread_even 改为调用 backend/logic 规划器(PlanMinContextSwitchMoves / PlanEvenSpreadMoves); - 新增 state_id↔logic_id 映射层,统一入参与回填,避免工具层与规划层 ID 语义耦合; - 删除 compound_tools 内部重复的规划/归一化/分组/打分实现,减少第三份复制逻辑。 5. 同步调试与文档 - 更新 newAgent/Log.txt 调试日志; - 新增 memory/记忆模块实施计划.md(面试优先版到产品可用版的落地路线)。 前端:无 仓库:无
This commit is contained in:
12226
backend/newAgent/Log.txt
12226
backend/newAgent/Log.txt
File diff suppressed because it is too large
Load Diff
280
backend/newAgent/conv/schedule_persist.go
Normal file
280
backend/newAgent/conv/schedule_persist.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package newagentconv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
baseconv "github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// SchedulePersistorAdapter 实现 model.SchedulePersistor 接口。
|
||||
// 组合 RepoManager,调用 PersistScheduleChanges 持久化变更。
|
||||
type SchedulePersistorAdapter struct {
|
||||
manager *dao.RepoManager
|
||||
}
|
||||
|
||||
// NewSchedulePersistorAdapter 创建持久化适配器。
|
||||
func NewSchedulePersistorAdapter(manager *dao.RepoManager) *SchedulePersistorAdapter {
|
||||
return &SchedulePersistorAdapter{manager: manager}
|
||||
}
|
||||
|
||||
// PersistScheduleChanges 实现 model.SchedulePersistor 接口。
|
||||
func (a *SchedulePersistorAdapter) PersistScheduleChanges(ctx context.Context, original, modified *newagenttools.ScheduleState, userID int) error {
|
||||
return PersistScheduleChanges(ctx, a.manager, original, modified, userID)
|
||||
}
|
||||
|
||||
// PersistScheduleChanges 将内存中的 ScheduleState 变更持久化到数据库。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 调用 DiffScheduleState 计算变更;
|
||||
// 2. 在事务中逐个应用变更到数据库;
|
||||
// 3. 全部成功或全部回滚,保证原子性。
|
||||
func PersistScheduleChanges(
|
||||
ctx context.Context,
|
||||
manager *dao.RepoManager,
|
||||
original *newagenttools.ScheduleState,
|
||||
modified *newagenttools.ScheduleState,
|
||||
userID int,
|
||||
) error {
|
||||
changes := DiffScheduleState(original, modified)
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return manager.Transaction(ctx, func(txM *dao.RepoManager) error {
|
||||
for _, change := range changes {
|
||||
if err := applyScheduleChange(ctx, txM, change, userID); err != nil {
|
||||
return fmt.Errorf("应用变更失败 [%s %s]: %w", change.Type, change.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// applyScheduleChange 应用单个变更到数据库。
|
||||
func applyScheduleChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
switch change.Type {
|
||||
case ChangePlace:
|
||||
return applyPlaceChange(ctx, manager, change, userID)
|
||||
case ChangeMove:
|
||||
return applyMoveChange(ctx, manager, change, userID)
|
||||
case ChangeUnplace:
|
||||
return applyUnplaceChange(ctx, manager, change, userID)
|
||||
default:
|
||||
return fmt.Errorf("未知变更类型: %s", change.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPlaceChange 应用放置变更。
|
||||
func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
if len(change.NewCoords) == 0 {
|
||||
return fmt.Errorf("place 变更缺少目标位置")
|
||||
}
|
||||
switch change.Source {
|
||||
case "event":
|
||||
return applyPlaceEventSource(ctx, manager, change, userID)
|
||||
case "task_item":
|
||||
return applyPlaceTaskItem(ctx, manager, change, userID)
|
||||
default:
|
||||
return fmt.Errorf("place 变更不支持的 source: %s", change.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPlaceEventSource 处理 source=event 的放置(为已有 Event 创建 Schedule 记录)。
|
||||
func applyPlaceEventSource(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("place event 变更需要有效的 source_id")
|
||||
}
|
||||
groups := groupCoordsByWeekDay(change.NewCoords)
|
||||
for week, dayGroups := range groups {
|
||||
for dayOfWeek, coords := range dayGroups {
|
||||
startSection, endSection := minMaxSection(coords)
|
||||
schedules := make([]model.Schedule, endSection-startSection+1)
|
||||
for sec := startSection; sec <= endSection; sec++ {
|
||||
schedules[sec-startSection] = model.Schedule{
|
||||
UserID: userID,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
Section: sec,
|
||||
EventID: change.SourceID,
|
||||
}
|
||||
}
|
||||
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
|
||||
return fmt.Errorf("创建 schedule 失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPlaceTaskItem 处理 source=task_item 的放置。
|
||||
//
|
||||
// 两条路径:
|
||||
// 1. 嵌入水课(HostEventID != 0):在宿主 Schedule 记录上设置 embedded_task_id。
|
||||
// 2. 普通放置(HostEventID == 0):新建 ScheduleEvent(type=task) + Schedule 记录。
|
||||
// 两条路径最终都更新 task_items.embedded_time。
|
||||
func applyPlaceTaskItem(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("place task_item 变更需要有效的 source_id")
|
||||
}
|
||||
|
||||
// task_item 只占一段连续时段,取第一个 coord 的 week/dayOfWeek
|
||||
first := change.NewCoords[0]
|
||||
week, dayOfWeek := first.Week, first.DayOfWeek
|
||||
startSection, endSection := minMaxSection(change.NewCoords)
|
||||
|
||||
targetTime := &model.TargetTime{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: startSection,
|
||||
SectionTo: endSection,
|
||||
}
|
||||
|
||||
if change.HostEventID != 0 {
|
||||
// 嵌入路径:更新宿主 Schedule 记录的 embedded_task_id
|
||||
if err := manager.Schedule.EmbedTaskIntoSchedule(
|
||||
startSection, endSection, dayOfWeek, week, userID, change.SourceID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("嵌入水课失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 普通路径:新建 ScheduleEvent + Schedule 记录
|
||||
startTime, endTime, err := baseconv.RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("时间转换失败: %w", err)
|
||||
}
|
||||
relID := change.SourceID
|
||||
event := model.ScheduleEvent{
|
||||
UserID: userID,
|
||||
Name: change.Name,
|
||||
Type: "task",
|
||||
RelID: &relID,
|
||||
CanBeEmbedded: false,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
eventID, err := manager.Schedule.AddScheduleEvent(&event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 schedule_event 失败: %w", err)
|
||||
}
|
||||
schedules := make([]model.Schedule, endSection-startSection+1)
|
||||
for i, sec := 0, startSection; sec <= endSection; i, sec = i+1, sec+1 {
|
||||
schedules[i] = model.Schedule{
|
||||
UserID: userID,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
Section: sec,
|
||||
EventID: eventID,
|
||||
Status: "normal",
|
||||
}
|
||||
}
|
||||
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
|
||||
return fmt.Errorf("创建 schedule 记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := manager.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, change.SourceID, targetTime); err != nil {
|
||||
return fmt.Errorf("更新 task_item embedded_time 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMoveChange 应用移动变更。
|
||||
func applyMoveChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
switch change.Source {
|
||||
case "event":
|
||||
if change.SourceID != 0 {
|
||||
if err := manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID); err != nil {
|
||||
return fmt.Errorf("删除旧位置失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "task_item":
|
||||
// 清理旧位置
|
||||
if change.OldHostEventID != 0 {
|
||||
// 旧位置是嵌入:清空宿主的 embedded_task_id
|
||||
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.OldHostEventID); err != nil {
|
||||
return fmt.Errorf("清除旧嵌入关系失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 旧位置是普通 task event:按 task_item_id 删除
|
||||
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("删除旧 task_item 日程失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyPlaceChange(ctx, manager, change, userID)
|
||||
}
|
||||
|
||||
// applyUnplaceChange 应用移除变更。
|
||||
func applyUnplaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
|
||||
switch change.Source {
|
||||
case "event":
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("unplace event 变更需要有效的 source_id")
|
||||
}
|
||||
return manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID)
|
||||
case "task_item":
|
||||
if change.SourceID == 0 {
|
||||
return fmt.Errorf("unplace task_item 变更需要有效的 source_id")
|
||||
}
|
||||
if change.HostEventID != 0 {
|
||||
// 是嵌入:清空宿主 Schedule 的 embedded_task_id
|
||||
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.HostEventID); err != nil {
|
||||
return fmt.Errorf("清除嵌入关系失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 普通 task event:按 task_item_id 删除
|
||||
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("删除 task_item 日程失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := manager.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, change.SourceID); err != nil {
|
||||
return fmt.Errorf("清除 task_item embedded_time 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unplace 变更不支持的 source: %s", change.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// intPtr 返回 int 指针,零值返回 nil。
|
||||
func intPtr(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// groupCoordsByWeekDay 按周天分组坐标。
|
||||
func groupCoordsByWeekDay(coords []SlotCoord) map[int]map[int][]SlotCoord {
|
||||
result := make(map[int]map[int][]SlotCoord)
|
||||
for _, coord := range coords {
|
||||
if result[coord.Week] == nil {
|
||||
result[coord.Week] = make(map[int][]SlotCoord)
|
||||
}
|
||||
result[coord.Week][coord.DayOfWeek] = append(result[coord.Week][coord.DayOfWeek], coord)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// minMaxSection 返回坐标列表中的最小和最大节次。
|
||||
func minMaxSection(coords []SlotCoord) (min, max int) {
|
||||
if len(coords) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
min, max = coords[0].Section, coords[0].Section
|
||||
for _, c := range coords[1:] {
|
||||
if c.Section < min {
|
||||
min = c.Section
|
||||
}
|
||||
if c.Section > max {
|
||||
max = c.Section
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
121
backend/newAgent/conv/schedule_preview.go
Normal file
121
backend/newAgent/conv/schedule_preview.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package newagentconv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// ScheduleStateToPreview 将 newAgent 的 ScheduleState 转换为前端预览缓存格式。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做数据格式转换,不做业务逻辑;
|
||||
// 2. 将每个 ScheduleTask 的每个 TaskSlot 转为一条 HybridScheduleEntry;
|
||||
// 3. Day → (Week, DayOfWeek) 通过 ScheduleState.DayToWeekDay 转换;
|
||||
// 4. 转换失败的 slot(day_index 无效)静默跳过。
|
||||
func ScheduleStateToPreview(
|
||||
state *newagenttools.ScheduleState,
|
||||
userID int,
|
||||
conversationID string,
|
||||
taskClassIDs []int,
|
||||
summary string,
|
||||
) *model.SchedulePlanPreviewCache {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]model.HybridScheduleEntry, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 待安排且无位置的任务不生成 entry。
|
||||
if newagenttools.IsPendingTask(*t) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, slot := range t.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := model.HybridScheduleEntry{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: t.Name,
|
||||
}
|
||||
|
||||
// Type 映射。
|
||||
if t.Source == "event" {
|
||||
if t.EventType != "" {
|
||||
entry.Type = t.EventType
|
||||
} else {
|
||||
entry.Type = "course"
|
||||
}
|
||||
} else {
|
||||
entry.Type = "task"
|
||||
}
|
||||
|
||||
// Status 映射:existing 不变,suggested / 兼容建议态统一输出为 suggested。
|
||||
if shouldMarkSuggestedInPreview(*t) {
|
||||
entry.Status = "suggested"
|
||||
} else {
|
||||
entry.Status = "existing"
|
||||
}
|
||||
|
||||
// ID 映射。
|
||||
if t.Source == "event" {
|
||||
entry.EventID = t.SourceID
|
||||
} else {
|
||||
entry.TaskItemID = t.SourceID
|
||||
}
|
||||
|
||||
// 嵌入与阻塞语义。
|
||||
entry.CanBeEmbedded = t.CanEmbed
|
||||
if t.Source == "event" && t.CanEmbed && t.EmbeddedBy == nil {
|
||||
// 可嵌入且当前无嵌入任务 → 不阻塞 suggested 占位。
|
||||
entry.BlockForSuggested = false
|
||||
} else {
|
||||
entry.BlockForSuggested = true
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成摘要(若调用方未提供)。
|
||||
if summary == "" {
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "existing" {
|
||||
existingCount++
|
||||
} else {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
summary = fmt.Sprintf("共 %d 个日程条目,其中已确定 %d 个,新安排 %d 个。", len(entries), existingCount, suggestedCount)
|
||||
}
|
||||
|
||||
return &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Summary: summary,
|
||||
HybridEntries: entries,
|
||||
TaskClassIDs: taskClassIDs,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。
|
||||
//
|
||||
// 规则说明:
|
||||
// 1. 新语义下,显式 suggested 直接输出为建议态;
|
||||
// 2. 兼容旧快照:pending+Slots、existing+Duration>0 的 task_item 也继续按 suggested 输出;
|
||||
// 3. 这样前端预览口径可以在迁移期保持稳定,不会因为状态枚举切换而抖动。
|
||||
func shouldMarkSuggestedInPreview(t newagenttools.ScheduleTask) bool {
|
||||
return newagenttools.IsSuggestedTask(t)
|
||||
}
|
||||
304
backend/newAgent/conv/schedule_provider.go
Normal file
304
backend/newAgent/conv/schedule_provider.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package newagentconv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
baseconv "github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// ScheduleProvider 实现 model.ScheduleStateProvider 接口。
|
||||
// 通过 DAO 层加载用户的日程和任务数据,调用 LoadScheduleState 构建内存状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"从 DB 查数据 + 调 LoadScheduleState 转换",不含业务逻辑;
|
||||
// 2. 不负责缓存(由上层 Service 决定是否缓存);
|
||||
// 3. 不负责 Diff 和持久化(由 Confirm 流程负责)。
|
||||
type ScheduleProvider struct {
|
||||
scheduleDAO *dao.ScheduleDAO
|
||||
taskClassDAO *dao.TaskClassDAO
|
||||
}
|
||||
|
||||
// NewScheduleProvider 创建 ScheduleProvider。
|
||||
func NewScheduleProvider(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskClassDAO) *ScheduleProvider {
|
||||
return &ScheduleProvider{
|
||||
scheduleDAO: scheduleDAO,
|
||||
taskClassDAO: taskClassDAO,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadScheduleState 实现 model.ScheduleStateProvider 接口。
|
||||
//
|
||||
// 窗口策略:
|
||||
// 1. 优先从 task class 的 StartDate/EndDate 推算规划窗口,覆盖粗排所需的完整日期范围;
|
||||
// 2. task class 无日期信息时,降级到当前周 7 天(兼容普通查询场景)。
|
||||
//
|
||||
// 日程加载策略:对窗口内每周分别调用 GetUserWeeklySchedule 并合并结果。
|
||||
func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*newagenttools.ScheduleState, error) {
|
||||
// 1. 加载用户所有任务类(含 Items 预加载)。
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
|
||||
}
|
||||
|
||||
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 负责:让粗排 / Execute 首次读取的 DayMapping 与本轮 task_class_ids 保持同一时间窗口;
|
||||
// 2. 不负责:裁掉窗口内已有的 existing/suggested 阻塞物,这部分仍由日程加载主流程统一保留;
|
||||
// 3. 失败策略:若 task_class_ids 为空,则退回全量加载,避免调用方额外分支。
|
||||
func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) (*newagenttools.ScheduleState, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return p.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
|
||||
taskClasses, err := p.loadCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses)
|
||||
}
|
||||
|
||||
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先根据传入 taskClasses 计算 DayMapping 窗口,保证粗排坐标能映射回 day_index;
|
||||
// 2. 若窗口无法从任务类日期推导,则退回当前周 7 天,兼容普通查询场景;
|
||||
// 3. 再按窗口覆盖的周批量拉取 existing schedules,与 taskClasses 一起交给 LoadScheduleState 统一建模。
|
||||
func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClasses []model.TaskClass,
|
||||
) (*newagenttools.ScheduleState, error) {
|
||||
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
|
||||
windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
|
||||
if len(windowDays) == 0 {
|
||||
var err error
|
||||
windowDays, weeks, err = buildCurrentWeekWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按周加载日程(含 Event + EmbeddedTask 预加载)。
|
||||
var allSchedules []model.Schedule
|
||||
for _, w := range weeks {
|
||||
weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户周日程失败 week=%d: %w", w, err)
|
||||
}
|
||||
allSchedules = append(allSchedules, weekSchedules...)
|
||||
}
|
||||
|
||||
// 3. 构建额外 item category 映射。
|
||||
extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses)
|
||||
|
||||
// 4. 调用已有的 LoadScheduleState 构建内存状态。
|
||||
return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
|
||||
}
|
||||
|
||||
// buildWindowFromTaskClasses 从 task class 的 StartDate/EndDate 推算规划窗口。
|
||||
//
|
||||
// 返回值:
|
||||
// - windowDays:窗口内每天的 (week, dayOfWeek) 有序列表;
|
||||
// - weeks:窗口覆盖的周号(去重、升序),供按周加载日程使用;
|
||||
// - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。
|
||||
func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
|
||||
minWeek, minDay := 0, 0
|
||||
maxWeek, maxDay := 0, 0
|
||||
hasWindow := false
|
||||
|
||||
for _, tc := range taskClasses {
|
||||
// 1. 先要求任务类具备完整且合法的起止日期,避免坏数据把整轮窗口拖坏。
|
||||
// 2. 再逐条做绝对日期 -> 相对周/天转换;转换失败的任务类直接忽略,不影响其余合法任务类。
|
||||
// 3. 只有至少一条任务类成功进入窗口后,才返回有效 DayMapping。
|
||||
if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) {
|
||||
continue
|
||||
}
|
||||
startWeek, startDay, err := baseconv.RealDateToRelativeDate(tc.StartDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
endWeek, endDay, err := baseconv.RealDateToRelativeDate(tc.EndDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(startWeek, startDay, minWeek, minDay) {
|
||||
minWeek, minDay = startWeek, startDay
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(maxWeek, maxDay, endWeek, endDay) {
|
||||
maxWeek, maxDay = endWeek, endDay
|
||||
}
|
||||
hasWindow = true
|
||||
}
|
||||
if !hasWindow {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
weeksSet := make(map[int]bool)
|
||||
w, d := minWeek, minDay
|
||||
for {
|
||||
windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d})
|
||||
weeksSet[w] = true
|
||||
if w == maxWeek && d == maxDay {
|
||||
break
|
||||
}
|
||||
d++
|
||||
if d > 7 {
|
||||
d = 1
|
||||
w++
|
||||
}
|
||||
if w > maxWeek+1 { // 防止因日期转换异常导致无限循环
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
weeks = make([]int, 0, len(weeksSet))
|
||||
for wk := range weeksSet {
|
||||
weeks = append(weeks, wk)
|
||||
}
|
||||
sort.Ints(weeks)
|
||||
return windowDays, weeks
|
||||
}
|
||||
|
||||
// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。
|
||||
func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
|
||||
now := time.Now()
|
||||
currentWeek, _, err := baseconv.RealDateToRelativeDate(now.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析当前日期失败: %w", err)
|
||||
}
|
||||
windowDays = make([]WindowDay, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1}
|
||||
}
|
||||
return windowDays, []int{currentWeek}, nil
|
||||
}
|
||||
|
||||
// isRelativeDateBefore 比较两个“相对周/天”坐标的先后关系。
|
||||
func isRelativeDateBefore(leftWeek, leftDay, rightWeek, rightDay int) bool {
|
||||
if leftWeek != rightWeek {
|
||||
return leftWeek < rightWeek
|
||||
}
|
||||
return leftDay < rightDay
|
||||
}
|
||||
|
||||
// loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID int) ([]model.TaskClass, error) {
|
||||
basicClasses, err := p.taskClassDAO.GetUserTaskClasses(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户任务类失败: %w", err)
|
||||
}
|
||||
if len(basicClasses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ids := make([]int, len(basicClasses))
|
||||
for i, tc := range basicClasses {
|
||||
ids[i] = tc.ID
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载完整任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// loadCompleteTaskClassesByIDs 批量加载指定任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClassesByIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) ([]model.TaskClass, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载指定任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// LoadTaskClassMetas 加载指定任务类的约束元数据(不含 Items、不含日程),供 Plan 阶段提前消费。
|
||||
func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]newagenttools.TaskClassMeta, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载任务类元数据失败: %w", err)
|
||||
}
|
||||
metas := make([]newagenttools.TaskClassMeta, 0, len(complete))
|
||||
for _, tc := range complete {
|
||||
meta := newagenttools.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: derefString(tc.Name),
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// buildExtraItemCategories 从已有日程中提取不属于给定 taskClasses 的 task event 的 category 映射。
|
||||
// 当加载全部 taskClass 时,通常返回空 map。
|
||||
func buildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
knownItemIDs := make(map[int]bool)
|
||||
for _, tc := range taskClasses {
|
||||
for _, item := range tc.Items {
|
||||
knownItemIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
categories := make(map[int]string)
|
||||
for _, s := range schedules {
|
||||
if s.Event == nil || s.Event.Type != "task" || s.Event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemID := *s.Event.RelID
|
||||
if !knownItemIDs[itemID] {
|
||||
categories[itemID] = "任务"
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
594
backend/newAgent/conv/schedule_state.go
Normal file
594
backend/newAgent/conv/schedule_state.go
Normal file
@@ -0,0 +1,594 @@
|
||||
package newagentconv
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
)
|
||||
|
||||
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
|
||||
type WindowDay struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
}
|
||||
|
||||
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 newAgent 工具层可直接操作的 ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责数据映射与状态归一,不做数据库读写;
|
||||
// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time;
|
||||
// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。
|
||||
func LoadScheduleState(
|
||||
schedules []model.Schedule,
|
||||
taskClasses []model.TaskClass,
|
||||
extraItemCategories map[int]string,
|
||||
windowDays []WindowDay,
|
||||
) *newagenttools.ScheduleState {
|
||||
state := &newagenttools.ScheduleState{
|
||||
Window: newagenttools.ScheduleWindow{
|
||||
TotalDays: len(windowDays),
|
||||
DayMapping: make([]newagenttools.DayMapping, len(windowDays)),
|
||||
},
|
||||
Tasks: make([]newagenttools.ScheduleTask, 0),
|
||||
}
|
||||
|
||||
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
|
||||
dayLookup := make(map[[2]int]int, len(windowDays))
|
||||
for i, wd := range windowDays {
|
||||
dayIndex := i + 1
|
||||
state.Window.DayMapping[i] = newagenttools.DayMapping{
|
||||
DayIndex: dayIndex,
|
||||
Week: wd.Week,
|
||||
DayOfWeek: wd.DayOfWeek,
|
||||
}
|
||||
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
|
||||
}
|
||||
|
||||
// 2. 构建 task_item -> 分类名映射。
|
||||
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||||
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||||
itemCategoryLookup := make(map[int]string)
|
||||
for id, name := range extraItemCategories {
|
||||
itemCategoryLookup[id] = name
|
||||
}
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
for _, item := range tc.Items {
|
||||
itemCategoryLookup[item.ID] = catName
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 先把 schedules 聚合成 event 任务(existing)。
|
||||
type slotGroup struct {
|
||||
week int
|
||||
dayOfWeek int
|
||||
sections []int
|
||||
}
|
||||
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
|
||||
eventInfo := make(map[int]*model.ScheduleEvent)
|
||||
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.Event == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := eventInfo[s.EventID]; !exists {
|
||||
eventInfo[s.EventID] = s.Event
|
||||
}
|
||||
|
||||
groups := eventSlotMap[s.EventID]
|
||||
found := false
|
||||
for gi := range groups {
|
||||
if groups[gi].week == s.Week && groups[gi].dayOfWeek == s.DayOfWeek {
|
||||
groups[gi].sections = append(groups[gi].sections, s.Section)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, slotGroup{
|
||||
week: s.Week,
|
||||
dayOfWeek: s.DayOfWeek,
|
||||
sections: []int{s.Section},
|
||||
})
|
||||
}
|
||||
eventSlotMap[s.EventID] = groups
|
||||
}
|
||||
|
||||
nextStateID := 1
|
||||
eventStateIDs := make(map[int]int) // eventID -> stateID
|
||||
for eventID, groups := range eventSlotMap {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
category := "课程"
|
||||
if event.Type == "task" {
|
||||
category = "任务"
|
||||
if event.RelID != nil {
|
||||
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locked := event.Type == "course" && !event.CanBeEmbedded
|
||||
var slots []newagenttools.TaskSlot
|
||||
for _, g := range groups {
|
||||
if len(g.sections) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Ints(g.sections)
|
||||
start, end := g.sections[0], g.sections[0]
|
||||
for _, sec := range g.sections[1:] {
|
||||
if sec == end+1 {
|
||||
end = sec
|
||||
continue
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
start, end = sec, sec
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
}
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
if slots[i].Day != slots[j].Day {
|
||||
return slots[i].Day < slots[j].Day
|
||||
}
|
||||
return slots[i].SlotStart < slots[j].SlotStart
|
||||
})
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "event",
|
||||
SourceID: eventID,
|
||||
Name: event.Name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Locked: locked,
|
||||
Slots: slots,
|
||||
CanEmbed: event.CanBeEmbedded,
|
||||
EventType: event.Type,
|
||||
})
|
||||
eventStateIDs[eventID] = stateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
|
||||
// 4.1 event.rel_id 占位:该 item 已有 task event;
|
||||
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
|
||||
itemIDToTaskEventStateID := make(map[int]int)
|
||||
for eventID, stateID := range eventStateIDs {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil || event.Type != "task" || event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemIDToTaskEventStateID[*event.RelID] = stateID
|
||||
}
|
||||
|
||||
itemIDToEmbedHostStateID := make(map[int]int)
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
|
||||
}
|
||||
|
||||
// 5. 处理 task_items:
|
||||
// 5.1 先消化 existing(task event / 课程嵌入 / embedded_time);
|
||||
// 5.2 剩余条目再按 status 判 pending。
|
||||
itemStateIDs := make(map[int]int) // task_item_id -> stateID
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
defaultDuration := estimateTaskItemDuration(tc)
|
||||
pendingCount := 0
|
||||
|
||||
for _, item := range tc.Items {
|
||||
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
|
||||
itemStateIDs[item.ID] = stateID
|
||||
continue
|
||||
}
|
||||
|
||||
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
|
||||
hostSlots := []newagenttools.TaskSlot(nil)
|
||||
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: slots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if !isTaskItemPending(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "pending",
|
||||
Duration: defaultDuration,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
pendingCount++
|
||||
}
|
||||
|
||||
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
|
||||
if pendingCount > 0 {
|
||||
meta := newagenttools.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: catName,
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
state.TaskClasses = append(state.TaskClasses, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 统一回填嵌入关系:
|
||||
// 6.1 host 记录 EmbeddedBy;
|
||||
// 6.2 guest 记录 EmbedHost;
|
||||
// 6.3 guest 强制 existing + host slots,防止“嵌入任务残留 pending”。
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hostTask := state.TaskByStateID(hostStateID)
|
||||
itemID := *s.EmbeddedTaskID
|
||||
|
||||
guestStateID, ok := itemStateIDs[itemID]
|
||||
if !ok {
|
||||
// 兜底:只在 schedules 层看到嵌入关系,taskClasses 不含该 item 时补建 guest。
|
||||
name := ""
|
||||
categoryID := 0
|
||||
taskClassID := 0
|
||||
if s.EmbeddedTask != nil {
|
||||
name = taskItemName(*s.EmbeddedTask)
|
||||
if s.EmbeddedTask.CategoryID != nil {
|
||||
categoryID = *s.EmbeddedTask.CategoryID
|
||||
taskClassID = *s.EmbeddedTask.CategoryID
|
||||
}
|
||||
}
|
||||
category := "任务"
|
||||
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
hostSlots := []newagenttools.TaskSlot(nil)
|
||||
if hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
guestStateID = nextStateID
|
||||
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
|
||||
StateID: guestStateID,
|
||||
Source: "task_item",
|
||||
SourceID: itemID,
|
||||
Name: name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: categoryID,
|
||||
TaskClassID: taskClassID,
|
||||
})
|
||||
itemStateIDs[itemID] = guestStateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||||
v := guestStateID
|
||||
hostTask.EmbeddedBy = &v
|
||||
}
|
||||
|
||||
guestTask := state.TaskByStateID(guestStateID)
|
||||
if guestTask == nil {
|
||||
continue
|
||||
}
|
||||
if guestTask.EmbedHost == nil {
|
||||
v := hostStateID
|
||||
guestTask.EmbedHost = &v
|
||||
}
|
||||
guestTask.Status = "existing"
|
||||
if hostTask != nil && len(guestTask.Slots) == 0 {
|
||||
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
// existing 的 task_item 不应再携带 Duration,避免预览层误判成 suggested。
|
||||
guestTask.Duration = 0
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
|
||||
//
|
||||
// 说明:
|
||||
// 1. status=nil 兼容历史数据,按“未安排”处理;
|
||||
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending;
|
||||
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
|
||||
func isTaskItemPending(item model.TaskClassItem) bool {
|
||||
if item.Status == nil {
|
||||
return true
|
||||
}
|
||||
return *item.Status == model.TaskItemStatusUnscheduled
|
||||
}
|
||||
|
||||
// estimateTaskItemDuration 估算 pending 任务默认时长。
|
||||
//
|
||||
// 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1);
|
||||
// 否则回退到 2 节。
|
||||
func estimateTaskItemDuration(tc model.TaskClass) int {
|
||||
duration := 2
|
||||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
||||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
||||
duration = d
|
||||
}
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
// taskItemName 读取任务项展示名。
|
||||
func taskItemName(item model.TaskClassItem) string {
|
||||
if item.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return *item.Content
|
||||
}
|
||||
|
||||
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
|
||||
// 若 target 为空、节次非法、或不在窗口内,返回 false。
|
||||
func slotsFromTargetTime(
|
||||
target *model.TargetTime,
|
||||
dayLookup map[[2]int]int,
|
||||
) ([]newagenttools.TaskSlot, bool) {
|
||||
if target == nil {
|
||||
return nil, false
|
||||
}
|
||||
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
|
||||
return nil, false
|
||||
}
|
||||
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return []newagenttools.TaskSlot{
|
||||
{
|
||||
Day: day,
|
||||
SlotStart: target.SectionFrom,
|
||||
SlotEnd: target.SectionTo,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
|
||||
type ScheduleChangeType string
|
||||
|
||||
const (
|
||||
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
|
||||
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
|
||||
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
|
||||
)
|
||||
|
||||
// SlotCoord 表示数据库坐标系中的单节槽位(week/day_of_week/section)。
|
||||
type SlotCoord struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Section int
|
||||
}
|
||||
|
||||
// ScheduleChange 描述单个任务在前后状态间的变化。
|
||||
type ScheduleChange struct {
|
||||
Type ScheduleChangeType
|
||||
StateID int
|
||||
Source string // "event" | "task_item"
|
||||
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
|
||||
EventType string // 仅 source=event 时有意义(course/task)
|
||||
CategoryID int // 仅 source=task_item 时有意义
|
||||
Name string
|
||||
|
||||
// place/move 的新位置(展开到逐节坐标)。
|
||||
NewCoords []SlotCoord
|
||||
// move/unplace 的旧位置(展开到逐节坐标)。
|
||||
OldCoords []SlotCoord
|
||||
|
||||
// HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。
|
||||
HostEventID int
|
||||
// OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。
|
||||
OldHostEventID int
|
||||
}
|
||||
|
||||
// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。
|
||||
func DiffScheduleState(
|
||||
original *newagenttools.ScheduleState,
|
||||
modified *newagenttools.ScheduleState,
|
||||
) []ScheduleChange {
|
||||
if original == nil || modified == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
origTasks := indexByStateID(original)
|
||||
var changes []ScheduleChange
|
||||
|
||||
for i := range modified.Tasks {
|
||||
mod := &modified.Tasks[i]
|
||||
orig := origTasks[mod.StateID]
|
||||
|
||||
wasPending := orig == nil || orig.Status == "pending"
|
||||
hasSlots := len(mod.Slots) > 0
|
||||
hadSlots := orig != nil && len(orig.Slots) > 0
|
||||
|
||||
switch {
|
||||
case wasPending && hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangePlace,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
})
|
||||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeMove,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
OldHostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
case hadSlots && !hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeUnplace,
|
||||
StateID: mod.StateID,
|
||||
Source: orig.Source,
|
||||
SourceID: orig.SourceID,
|
||||
EventType: orig.EventType,
|
||||
Name: orig.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
HostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// indexByStateID 将任务列表按 state_id 建立索引。
|
||||
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
|
||||
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
m[state.Tasks[i].StateID] = &state.Tasks[i]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// slotsEqual 判断两个压缩槽位切片是否完全一致。
|
||||
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cloneTaskSlots 深拷贝槽位切片。
|
||||
func cloneTaskSlots(src []newagenttools.TaskSlot) []newagenttools.TaskSlot {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]newagenttools.TaskSlot, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
|
||||
// 非嵌入任务或宿主不存在时返回 0。
|
||||
func resolveHostEventID(task *newagenttools.ScheduleTask, state *newagenttools.ScheduleState) int {
|
||||
if task == nil || task.EmbedHost == nil {
|
||||
return 0
|
||||
}
|
||||
host := state.TaskByStateID(*task.EmbedHost)
|
||||
if host == nil {
|
||||
return 0
|
||||
}
|
||||
return host.SourceID
|
||||
}
|
||||
|
||||
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
|
||||
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.ScheduleState) []SlotCoord {
|
||||
var coords []SlotCoord
|
||||
for _, slot := range slots {
|
||||
week, dow, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for sec := slot.SlotStart; sec <= slot.SlotEnd; sec++ {
|
||||
coords = append(coords, SlotCoord{Week: week, DayOfWeek: dow, Section: sec})
|
||||
}
|
||||
}
|
||||
return coords
|
||||
}
|
||||
@@ -83,24 +83,28 @@ func RunAgentGraph(ctx context.Context, input newagentmodel.AgentGraphRunInput)
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Plan -> Plan(继续规划) / Confirm(规划完成) / Interrupt(需要追问用户)
|
||||
// Plan -> Plan(继续规划) / Confirm(规划完成) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(需要追问用户)
|
||||
if err := g.AddBranch(NodePlan, compose.NewGraphBranch(
|
||||
branchAfterPlan,
|
||||
map[string]bool{
|
||||
NodePlan: true,
|
||||
NodeConfirm: true,
|
||||
NodeInterrupt: true,
|
||||
NodePlan: true,
|
||||
NodeConfirm: true,
|
||||
NodeRoughBuild: true,
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Confirm -> Plan(用户拒绝或重规划) / RoughBuild(需粗排) / Execute(直接执行) / Interrupt(等待用户确认)
|
||||
// Confirm -> Plan(用户拒绝或重规划) / RoughBuild(需粗排) / Execute(直接执行) / Deliver(完成) / Interrupt(等待用户确认)
|
||||
if err := g.AddBranch(NodeConfirm, compose.NewGraphBranch(
|
||||
branchAfterConfirm,
|
||||
map[string]bool{
|
||||
NodePlan: true,
|
||||
NodeRoughBuild: true,
|
||||
NodeExecute: true,
|
||||
NodeDeliver: true,
|
||||
NodeInterrupt: true,
|
||||
},
|
||||
)); err != nil {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (o *FlowTerminalOutcome) Normalize() {
|
||||
o.InternalReason = strings.TrimSpace(o.InternalReason)
|
||||
}
|
||||
|
||||
const DefaultMaxRounds = 30
|
||||
const DefaultMaxRounds = 60
|
||||
|
||||
// CommonState 承载可持久化的主流程状态。
|
||||
//
|
||||
|
||||
@@ -19,6 +19,11 @@ const (
|
||||
chatStageName = "chat"
|
||||
chatStatusBlockID = "chat.status"
|
||||
chatSpeakBlockID = "chat.speak"
|
||||
// chatHistoryKindKey 用于在 history 中打运行态标记,供 prompt 层做上下文分层。
|
||||
chatHistoryKindKey = "newagent_history_kind"
|
||||
// chatHistoryKindExecuteLoopClosed 表示“上一轮 execute loop 已正常收口”。
|
||||
// prompt 侧会据此把旧 loop 归档到 msg1,而不是继续占用 msg2 窗口。
|
||||
chatHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||
)
|
||||
|
||||
type reorderPreference int
|
||||
@@ -70,6 +75,12 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
if !runtimeState.HasPendingInteraction() && flowState.Phase == newagentmodel.PhaseDone {
|
||||
terminalBefore := flowState.TerminalStatus()
|
||||
roundBefore := flowState.RoundUsed
|
||||
// 1. 只有“正常完成(completed)”才打 loop 收口标记:
|
||||
// 1.1 这样下一轮进入 execute 时,msg2 会只保留“当前活跃循环”窗口;
|
||||
// 1.2 异常收口(exhausted/aborted)不打标记,允许后续“继续”时沿用上一轮 loop 轨迹。
|
||||
if terminalBefore == newagentmodel.FlowTerminalStatusCompleted {
|
||||
appendExecuteLoopClosedMarker(conversationContext)
|
||||
}
|
||||
flowState.ResetForNextRun()
|
||||
log.Printf(
|
||||
"[DEBUG] chat reset runtime for next run chat=%s round_before=%d terminal_before=%s",
|
||||
@@ -139,6 +150,45 @@ func RunChatNode(ctx context.Context, input ChatNodeInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
// appendExecuteLoopClosedMarker 在 history 中写入“execute loop 已正常收口”标记。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责写一个轻量 marker,供 prompt 分层;
|
||||
// 2. 不负责历史裁剪,不负责消息摘要;
|
||||
// 3. 若末尾已经是同类 marker,则幂等跳过,避免重复写入。
|
||||
func appendExecuteLoopClosedMarker(conversationContext *newagentmodel.ConversationContext) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
if len(history) > 0 {
|
||||
last := history[len(history)-1]
|
||||
if isExecuteLoopClosedMarker(last) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: "",
|
||||
Extra: map[string]any{
|
||||
chatHistoryKindKey: chatHistoryKindExecuteLoopClosed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
kind, ok := msg.Extra[chatHistoryKindKey].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(kind) == chatHistoryKindExecuteLoopClosed
|
||||
}
|
||||
|
||||
// handleDirectReply 处理简单任务:直接输出回复。
|
||||
func handleDirectReply(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -130,6 +130,10 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
flowState.SuggestedOrderBaseline = buildSuggestedOrderSnapshot(input.ScheduleState)
|
||||
}
|
||||
|
||||
// 1. 每轮 execute 开始前先刷新一次执行锚点,避免 LLM 继续读取旧的当前步骤。
|
||||
// 2. 这里仅维护上下文一致性,不改变流程状态。
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
|
||||
// 2. 推送执行阶段状态,让前端知道当前进度。
|
||||
if flowState.HasCurrentPlanStep() {
|
||||
// 有 plan:显示步骤进度。
|
||||
@@ -400,6 +404,9 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
// 所有步骤已完成,进入交付阶段。
|
||||
flowState.Done()
|
||||
}
|
||||
// 1. next_plan 推进后立刻刷新 current_step / execution_context。
|
||||
// 2. 若计划已结束,这里会移除 current_step,避免下轮读取到旧步骤。
|
||||
syncExecutePinnedContext(conversationContext, flowState)
|
||||
return nil
|
||||
|
||||
case newagentmodel.ExecuteActionDone:
|
||||
@@ -462,6 +469,114 @@ func prepareExecuteNodeInput(input ExecuteNodeInput) (*newagentmodel.AgentRuntim
|
||||
// 1. 优先使用 LLM 输出的 speak;
|
||||
// 2. 其次使用 reason;
|
||||
// 3. 最后使用默认文案。
|
||||
// syncExecutePinnedContext 同步 execute 阶段的置顶上下文。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 每轮先刷新 execution_context,确保模型始终看到最新执行锚点。
|
||||
// 2. 若当前仍在计划执行且 current_step 可读,则覆盖 current_step 置顶块。
|
||||
// 3. 若计划已执行完或当前步骤不可读,则移除 current_step,避免模型误读旧步骤。
|
||||
func syncExecutePinnedContext(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) {
|
||||
if conversationContext == nil || flowState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
execContent := buildExecuteContextPinnedMarkdown(flowState)
|
||||
if strings.TrimSpace(execContent) != "" {
|
||||
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||
Key: executePinnedKey,
|
||||
Title: "执行上下文",
|
||||
Content: execContent,
|
||||
})
|
||||
}
|
||||
|
||||
if !flowState.HasPlan() {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
step, ok := flowState.CurrentPlanStep()
|
||||
if !ok {
|
||||
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||
return
|
||||
}
|
||||
|
||||
current, total := flowState.PlanProgress()
|
||||
title := strings.TrimSpace(planCurrentStepTitle)
|
||||
if title == "" {
|
||||
title = "当前步骤"
|
||||
}
|
||||
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||
Key: planCurrentStepKey,
|
||||
Title: title,
|
||||
Content: buildCurrentPlanStepPinnedMarkdown(step, current, total),
|
||||
})
|
||||
}
|
||||
|
||||
// buildExecuteContextPinnedMarkdown 构造 execute 节点给模型的执行锚点文本。
|
||||
func buildExecuteContextPinnedMarkdown(flowState *newagentmodel.CommonState) string {
|
||||
if flowState == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 8)
|
||||
if flowState.HasPlan() {
|
||||
lines = append(lines, "执行模式:计划执行(按步骤推进)")
|
||||
current, total := flowState.PlanProgress()
|
||||
lines = append(lines, fmt.Sprintf("计划进度:第 %d/%d 步", current, total))
|
||||
|
||||
if step, ok := flowState.CurrentPlanStep(); ok {
|
||||
lines = append(lines, "当前步骤:"+compactExecutePinnedText(step.Content))
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定(done_when):"+doneWhen)
|
||||
}
|
||||
lines = append(lines, "动作纪律:未满足 done_when 禁止 next_plan;满足后优先 next_plan。")
|
||||
} else {
|
||||
lines = append(lines, "当前步骤:不可读(可能已执行完成)")
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, "执行模式:自由执行(无预定义步骤)")
|
||||
}
|
||||
|
||||
if flowState.MaxRounds > 0 {
|
||||
lines = append(lines, fmt.Sprintf("轮次预算:%d/%d", flowState.RoundUsed, flowState.MaxRounds))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// buildCurrentPlanStepPinnedMarkdown 构造 current_step 置顶块内容。
|
||||
func buildCurrentPlanStepPinnedMarkdown(step newagentmodel.PlanStep, current, total int) string {
|
||||
lines := make([]string, 0, 4)
|
||||
lines = append(lines, fmt.Sprintf("步骤进度:第 %d/%d 步", current, total))
|
||||
|
||||
content := compactExecutePinnedText(step.Content)
|
||||
if content == "" {
|
||||
content = "(空)"
|
||||
}
|
||||
lines = append(lines, "步骤内容:"+content)
|
||||
|
||||
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||
if doneWhen != "" {
|
||||
lines = append(lines, "完成判定:"+doneWhen)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// compactExecutePinnedText 把多行文本压成单行,避免置顶块出现冗长换行噪音。
|
||||
func compactExecutePinnedText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\n", ";")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
|
||||
if decision == nil {
|
||||
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
planStageName = "plan"
|
||||
planStatusBlockID = "plan.status"
|
||||
planSpeakBlockID = "plan.speak"
|
||||
planSummaryBlockID = "plan.summary"
|
||||
planPinnedKey = "current_plan"
|
||||
planCurrentStepKey = "current_step"
|
||||
planCurrentStepTitle = "当前步骤"
|
||||
@@ -170,6 +171,23 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
// always_execute 开启时,计划层跳过确认闸门,直接进入执行阶段。
|
||||
// 这样可以与 Execute 节点的“写工具跳过确认”语义保持一致。
|
||||
if input.AlwaysExecute {
|
||||
// 1. 自动执行模式不会经过 Confirm 卡片,因此这里先把完整计划明确展示给用户。
|
||||
// 2. 摘要格式复用 Confirm 节点,保证“手动确认”和“自动执行”两条链路文案一致。
|
||||
// 3. 推流后同步写入历史,确保后续 Execute 阶段的上下文也能看到这份计划。
|
||||
summary := strings.TrimSpace(buildPlanSummary(decision.PlanSteps))
|
||||
if summary != "" {
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx,
|
||||
planSummaryBlockID,
|
||||
planStageName,
|
||||
summary,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动执行前计划摘要推送失败: %w", err)
|
||||
}
|
||||
conversationContext.AppendHistory(schema.AssistantMessage(summary, nil))
|
||||
}
|
||||
|
||||
flowState.ConfirmPlan()
|
||||
_ = emitter.EmitStatus(
|
||||
planStatusBlockID,
|
||||
|
||||
@@ -320,7 +320,7 @@ func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
BuildExecuteSystemPrompt(),
|
||||
state,
|
||||
ctx,
|
||||
buildExecuteStrictJSONUserPrompt(),
|
||||
buildExecuteStrictJSONUserPromptWithPlan(state),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,6 +332,50 @@ func BuildExecuteMessages(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
)
|
||||
}
|
||||
|
||||
// buildExecuteStrictJSONUserPromptWithPlan 在通用 JSON 约束上补充“当前计划步骤”强约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把“当前是第几步、当前步骤内容、done_when 判定”明确写进用户指令;
|
||||
// 2. 不负责替代系统提示词中的工具规则和安全边界;
|
||||
// 3. 当 state 无法提供有效当前步骤时,仅追加兜底提示,不在此处推进流程状态。
|
||||
func buildExecuteStrictJSONUserPromptWithPlan(state *newagentmodel.CommonState) string {
|
||||
base := buildExecuteStrictJSONUserPrompt()
|
||||
if state == nil || !state.HasPlan() {
|
||||
return base
|
||||
}
|
||||
|
||||
current, total := state.PlanProgress()
|
||||
step, ok := state.CurrentPlanStep()
|
||||
if !ok {
|
||||
return strings.TrimSpace(base + `
|
||||
|
||||
计划步骤强约束:
|
||||
- 当前没有可执行的计划步骤,请先基于已有事实检查是否已完成全部计划。
|
||||
- 若全部计划已完成:输出 action=done,并在 goal_check 总结完成证据。
|
||||
- 若未完成但缺少关键信息:输出 action=ask_user。`)
|
||||
}
|
||||
|
||||
stepContent := strings.TrimSpace(step.Content)
|
||||
if stepContent == "" {
|
||||
stepContent = "(当前步骤内容为空,以 done_when 为准)"
|
||||
}
|
||||
doneWhen := strings.TrimSpace(step.DoneWhen)
|
||||
if doneWhen == "" {
|
||||
doneWhen = "(未提供 done_when,需基于当前步骤目标给出可验证完成证据)"
|
||||
}
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`%s
|
||||
|
||||
计划步骤强约束:
|
||||
- 你当前只允许推进第 %d/%d 步。
|
||||
- 当前步骤内容:%s
|
||||
- 当前步骤完成判定(done_when):%s
|
||||
- 未满足 done_when 时:只能输出 continue / confirm / ask_user,禁止输出 next_plan。
|
||||
- 满足 done_when 时:优先输出 action=next_plan,并在 goal_check 逐条对照 done_when 给出证据。
|
||||
- 禁止跳步:不要提前执行后续步骤。`,
|
||||
base, current, total, stepContent, doneWhen))
|
||||
}
|
||||
|
||||
// buildExecutePromptWithFormatGuard 统一补一层更硬的 JSON 输出约束。
|
||||
func buildExecutePromptWithFormatGuard(base string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
const (
|
||||
executeHistoryKindKey = "newagent_history_kind"
|
||||
executeHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||
executeHistoryKindLoopClosed = "execute_loop_closed"
|
||||
|
||||
// executeLoopWindowLimit 控制“当轮 ReAct Loop 窗口”最多保留多少条记录。
|
||||
// 采用固定窗口能避免上下文无上限增长,且可保持“最近行为”可追踪。
|
||||
@@ -36,6 +37,8 @@ type executeLoopRecord struct {
|
||||
Observation string
|
||||
}
|
||||
|
||||
const executeMessage1MaxRunes = 1400
|
||||
|
||||
// buildExecuteStageMessages 组装 execute 阶段 4 条消息骨架。
|
||||
//
|
||||
// 消息结构(固定):
|
||||
@@ -50,8 +53,8 @@ func buildExecuteStageMessages(
|
||||
runtimeUserPrompt string,
|
||||
) []*schema.Message {
|
||||
msg0 := buildExecuteMessage0(stageSystemPrompt, ctx)
|
||||
msg1 := buildExecuteMessage1(ctx)
|
||||
msg2 := buildExecuteMessage2(ctx)
|
||||
msg1 := buildExecuteMessage1V3(ctx)
|
||||
msg2 := buildExecuteMessage2V3(ctx)
|
||||
msg3 := buildExecuteMessage3(state, ctx, runtimeUserPrompt)
|
||||
|
||||
return []*schema.Message{
|
||||
@@ -140,6 +143,209 @@ func buildExecuteMessage2(ctx *newagentmodel.ConversationContext) string {
|
||||
}
|
||||
|
||||
// buildExecuteMessage3 生成当前执行状态与执行锚点。
|
||||
// buildExecuteMessage1V2 生成历史摘要:
|
||||
// 1. 已收口的 loop 归档到 msg1;
|
||||
// 2. 当前活跃 loop 只保留“早期摘要”;
|
||||
// 3. 最终对 msg1 做统一长度裁剪,控制 token 开销。
|
||||
func buildExecuteMessage1V2(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"历史上下文(仅供参考):"}
|
||||
if ctx == nil {
|
||||
lines = append(lines,
|
||||
"- 用户目标:暂无可用历史输入。",
|
||||
"- 阶段锚点:按当前工具事实推进执行。",
|
||||
"- 历史归档 ReAct 摘要:暂无。",
|
||||
"- 当前循环早期摘要:暂无。",
|
||||
)
|
||||
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
history := ctx.HistorySnapshot()
|
||||
firstUser, lastUser := pickExecuteUserInputs(history)
|
||||
switch {
|
||||
case firstUser == "":
|
||||
lines = append(lines, "- 用户目标:暂无可用历史输入。")
|
||||
case lastUser != "" && lastUser != firstUser:
|
||||
lines = append(lines, "- 用户目标:"+firstUser+";最近补充:"+lastUser)
|
||||
default:
|
||||
lines = append(lines, "- 用户目标:"+firstUser)
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
|
||||
} else {
|
||||
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
|
||||
}
|
||||
|
||||
archivedLoops, activeLoops := splitExecuteLoopRecordsByBoundary(history)
|
||||
lines = append(lines, "- 历史归档 ReAct 摘要:"+buildEarlyExecuteReactSummary(archivedLoops, 0))
|
||||
lines = append(lines, "- 当前循环早期摘要:"+buildEarlyExecuteReactSummary(activeLoops, executeLoopWindowLimit))
|
||||
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// buildExecuteMessage2V2 仅展示“当前活跃 loop”的窗口记录。
|
||||
func buildExecuteMessage2V2(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"当轮 ReAct Loop 记录(窗口):"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "- 暂无可用 ReAct 记录。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
_, activeLoops := splitExecuteLoopRecordsByBoundary(ctx.HistorySnapshot())
|
||||
if len(activeLoops) == 0 {
|
||||
lines = append(lines, "- 暂无可用 ReAct 记录。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
windowLoops := tailExecuteLoops(activeLoops, executeLoopWindowLimit)
|
||||
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
|
||||
for i, loop := range windowLoops {
|
||||
lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought))
|
||||
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// splitExecuteLoopRecordsByBoundary 按“已收口标记”拆分归档/活跃 ReAct 记录。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 标记之前的记录归档到 msg1;
|
||||
// 2. 标记之后的记录作为活跃 loop 进入 msg2;
|
||||
// 3. 若没有标记,则全部视为活跃记录(兼容旧会话快照)。
|
||||
func splitExecuteLoopRecordsByBoundary(history []*schema.Message) (archived []executeLoopRecord, active []executeLoopRecord) {
|
||||
if len(history) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
boundary := findLatestExecuteLoopClosedMarker(history)
|
||||
if boundary < 0 {
|
||||
return nil, collectExecuteLoopRecords(history)
|
||||
}
|
||||
|
||||
if boundary > 0 {
|
||||
archived = collectExecuteLoopRecords(history[:boundary])
|
||||
}
|
||||
if boundary+1 < len(history) {
|
||||
active = collectExecuteLoopRecords(history[boundary+1:])
|
||||
}
|
||||
return archived, active
|
||||
}
|
||||
|
||||
func findLatestExecuteLoopClosedMarker(history []*schema.Message) int {
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Extra == nil {
|
||||
continue
|
||||
}
|
||||
kind, ok := msg.Extra[executeHistoryKindKey].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(kind) == executeHistoryKindLoopClosed {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func trimExecuteMessage1ByBudget(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) <= executeMessage1MaxRunes {
|
||||
return content
|
||||
}
|
||||
if executeMessage1MaxRunes <= 3 {
|
||||
return string(runes[:executeMessage1MaxRunes])
|
||||
}
|
||||
return string(runes[:executeMessage1MaxRunes-3]) + "..."
|
||||
}
|
||||
|
||||
// buildExecuteMessage1V3 负责把“上一轮 loop 归档”并入 msg1,并统一做长度裁剪。
|
||||
func buildExecuteMessage1V3(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"历史上下文(仅供参考):"}
|
||||
if ctx == nil {
|
||||
lines = append(lines,
|
||||
"- 用户目标:暂无可用历史输入。",
|
||||
"- 阶段锚点:按当前工具事实推进执行。",
|
||||
"- 历史归档 ReAct 摘要:暂无。",
|
||||
"- 历史归档 ReAct 窗口:暂无。",
|
||||
"- 当前循环早期摘要:暂无。",
|
||||
)
|
||||
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
history := ctx.HistorySnapshot()
|
||||
firstUser, lastUser := pickExecuteUserInputs(history)
|
||||
switch {
|
||||
case firstUser == "":
|
||||
lines = append(lines, "- 用户目标:暂无可用历史输入。")
|
||||
case lastUser != "" && lastUser != firstUser:
|
||||
lines = append(lines, "- 用户目标:"+firstUser+";最近补充:"+lastUser)
|
||||
default:
|
||||
lines = append(lines, "- 用户目标:"+firstUser)
|
||||
}
|
||||
|
||||
if hasExecuteRoughBuildDone(ctx) {
|
||||
lines = append(lines, "- 阶段锚点:粗排已完成,本轮仅做微调,不重新 place。")
|
||||
} else {
|
||||
lines = append(lines, "- 阶段锚点:按当前工具事实推进,不做无依据操作。")
|
||||
}
|
||||
|
||||
// 1. 通过收口标记拆分“归档 loop / 当前活跃 loop”。
|
||||
// 2. 归档 loop 的窗口条目直接并入 msg1,满足“上一轮 msg2 挪入 msg1”。
|
||||
// 3. 当前活跃 loop 在 msg1 只保留早期摘要,详细窗口交给 msg2。
|
||||
archivedLoops, activeLoops := splitExecuteLoopRecordsByBoundary(history)
|
||||
lines = append(lines, "- 历史归档 ReAct 摘要:"+buildEarlyExecuteReactSummary(archivedLoops, executeLoopWindowLimit))
|
||||
lines = append(lines, renderArchivedExecuteLoopWindowForMessage1V3(archivedLoops))
|
||||
lines = append(lines, "- 当前循环早期摘要:"+buildEarlyExecuteReactSummary(activeLoops, executeLoopWindowLimit))
|
||||
return trimExecuteMessage1ByBudget(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// buildExecuteMessage2V3 仅承载“当前活跃 loop”的窗口。
|
||||
// 若是新一轮刚开始(活跃 loop 为空),明确返回“已清空”状态。
|
||||
func buildExecuteMessage2V3(ctx *newagentmodel.ConversationContext) string {
|
||||
lines := []string{"当轮 ReAct Loop 记录(窗口):"}
|
||||
if ctx == nil {
|
||||
lines = append(lines, "- 暂无可用 ReAct 记录。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
_, activeLoops := splitExecuteLoopRecordsByBoundary(ctx.HistorySnapshot())
|
||||
if len(activeLoops) == 0 {
|
||||
lines = append(lines, "- 已清空(新一轮 loop 准备中)。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
windowLoops := tailExecuteLoops(activeLoops, executeLoopWindowLimit)
|
||||
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
|
||||
for i, loop := range windowLoops {
|
||||
lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought))
|
||||
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderArchivedExecuteLoopWindowForMessage1V3(records []executeLoopRecord) string {
|
||||
if len(records) == 0 {
|
||||
return "- 历史归档 ReAct 窗口:暂无。"
|
||||
}
|
||||
|
||||
windowLoops := tailExecuteLoops(records, executeLoopWindowLimit)
|
||||
windowLoops = compressExecuteLoopObservationsByTool(windowLoops)
|
||||
|
||||
lines := []string{"历史归档 ReAct 窗口(由上一轮 msg2 并入):"}
|
||||
for i, loop := range windowLoops {
|
||||
lines = append(lines, fmt.Sprintf("%d) thought/reason:%s", i+1, loop.Thought))
|
||||
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext, runtimeUserPrompt string) string {
|
||||
lines := []string{"当前执行状态:"}
|
||||
|
||||
@@ -169,6 +375,32 @@ func buildExecuteMessage3(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
||||
if initialGoal != "" && initialGoal != currentGoal {
|
||||
lines = append(lines, "- 首轮目标来源:"+initialGoal)
|
||||
}
|
||||
// 1. 有 plan 时,把当前步骤与完成判定强制写入 msg3。
|
||||
// 2. 该锚点用于约束模型“只推进当前步骤”,避免退化成泛化 ReAct。
|
||||
// 3. 当前步骤不可读时给出兜底指引,避免引用旧步骤。
|
||||
if state != nil && state.HasPlan() {
|
||||
current, total := state.PlanProgress()
|
||||
lines = append(lines, "计划步骤锚点(强约束):")
|
||||
if step, ok := state.CurrentPlanStep(); ok {
|
||||
stepContent := strings.TrimSpace(step.Content)
|
||||
if stepContent == "" {
|
||||
stepContent = "(当前步骤内容为空)"
|
||||
}
|
||||
doneWhen := strings.TrimSpace(step.DoneWhen)
|
||||
if doneWhen == "" {
|
||||
doneWhen = "(未提供 done_when,需基于步骤目标给出可验证完成证据)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- 当前步骤:第 %d/%d 步", current, total))
|
||||
lines = append(lines, "- 当前步骤内容:"+stepContent)
|
||||
lines = append(lines, "- 当前步骤完成判定(done_when):"+doneWhen)
|
||||
lines = append(lines, "- 动作纪律1:未满足 done_when 时,只能 continue / confirm / ask_user,禁止 next_plan")
|
||||
lines = append(lines, "- 动作纪律2:满足 done_when 时,优先 next_plan,并在 goal_check 对照 done_when 给证据")
|
||||
lines = append(lines, "- 动作纪律3:禁止跳到后续步骤执行")
|
||||
} else {
|
||||
lines = append(lines, "- 当前计划步骤不可读;请先判断是否已完成全部计划")
|
||||
lines = append(lines, "- 若已完成全部计划,输出 done 并给出 goal_check 证据")
|
||||
}
|
||||
}
|
||||
if taskClassText := renderExecuteTaskClassIDs(state); taskClassText != "" {
|
||||
lines = append(lines, "- 目标任务类:"+taskClassText)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
||||
)
|
||||
|
||||
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
|
||||
@@ -27,26 +29,36 @@ type refineTaskCandidate struct {
|
||||
OriginRank int
|
||||
}
|
||||
|
||||
// refineSlotCandidate 是复合规划器使用的候选坑位输入。
|
||||
type refineSlotCandidate struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前阶段使用等值映射(logicID=stateID),保证行为不变;
|
||||
// 2. 保留独立适配层,后续若切到真实 task_item_id,只需改这里;
|
||||
// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
|
||||
type compositeIDMapper struct {
|
||||
stateToLogic map[int]int
|
||||
logicToState map[int]int
|
||||
}
|
||||
|
||||
// refineMovePlanItem 是规划器输出的一条移动方案。
|
||||
type refineMovePlanItem struct {
|
||||
TaskID int
|
||||
ToWeek int
|
||||
ToDay int
|
||||
ToSectionFrom int
|
||||
ToSectionTo int
|
||||
}
|
||||
|
||||
// refinePlanOptions 是复合规划器的可选参数。
|
||||
type refinePlanOptions struct {
|
||||
ExistingDayLoad map[string]int
|
||||
// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。
|
||||
func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
|
||||
mapper := &compositeIDMapper{
|
||||
stateToLogic: make(map[int]int, len(stateIDs)),
|
||||
logicToState: make(map[int]int, len(stateIDs)),
|
||||
}
|
||||
for _, stateID := range stateIDs {
|
||||
if stateID <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
|
||||
}
|
||||
if _, exists := mapper.stateToLogic[stateID]; exists {
|
||||
return nil, fmt.Errorf("state_id=%d 重复", stateID)
|
||||
}
|
||||
// 当前迁移阶段采用等值映射,先把“映射机制”跑通。
|
||||
logicID := stateID
|
||||
mapper.stateToLogic[stateID] = logicID
|
||||
mapper.logicToState[logicID] = stateID
|
||||
}
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
|
||||
@@ -61,20 +73,24 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
}
|
||||
|
||||
// 1. 收集任务并做前置校验,确保规划输入可用。
|
||||
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
|
||||
currentSlots := buildCurrentSlotsFromPlannerTasks(plannerTasks)
|
||||
plannedMoves, err := planMinContextSwitchMoves(plannerTasks, currentSlots, refinePlanOptions{})
|
||||
currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
|
||||
plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 3. 映射回工具态坐标并在提交前做完整校验。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
@@ -163,14 +179,18 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
||||
}
|
||||
|
||||
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
||||
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
|
||||
spanNeed := make(map[int]int, len(plannerTasks))
|
||||
for _, task := range plannerTasks {
|
||||
spanNeed := make(map[int]int, len(logicTasks))
|
||||
for _, task := range logicTasks {
|
||||
spanNeed[task.SectionTo-task.SectionFrom+1]++
|
||||
}
|
||||
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
|
||||
@@ -180,7 +200,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
||||
|
||||
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
|
||||
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
|
||||
plannedMoves, err := planEvenSpreadMoves(plannerTasks, candidateSlots, refinePlanOptions{
|
||||
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
|
||||
ExistingDayLoad: dayLoadBaseline,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -188,7 +208,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
||||
}
|
||||
|
||||
// 4. 回填 + 校验 + 原子提交。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
@@ -283,10 +303,15 @@ func collectCompositePlannerTasks(
|
||||
state *ScheduleState,
|
||||
taskIDs []int,
|
||||
toolLabel string,
|
||||
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, error) {
|
||||
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
|
||||
normalizedIDs := uniquePositiveInts(taskIDs)
|
||||
if len(normalizedIDs) < 2 {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
|
||||
}
|
||||
|
||||
idMapper, err := buildCompositeIDMapper(normalizedIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:ID 映射构建失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
|
||||
plannerTasks := make([]refineTaskCandidate, 0, len(normalizedIDs))
|
||||
@@ -296,28 +321,28 @@ func collectCompositePlannerTasks(
|
||||
for rank, taskID := range normalizedIDs {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
|
||||
}
|
||||
if !IsSuggestedTask(*task) {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
|
||||
}
|
||||
if err := checkLocked(*task); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
if len(task.Slots) != 1 {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
|
||||
}
|
||||
|
||||
slot := task.Slots[0]
|
||||
if err := validateDay(state, slot.Day); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
|
||||
}
|
||||
|
||||
contextTag := normalizeMinContextTag(*task)
|
||||
@@ -340,13 +365,41 @@ func collectCompositePlannerTasks(
|
||||
})
|
||||
}
|
||||
|
||||
return plannerTasks, beforeByID, excludeIDs, nil
|
||||
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
|
||||
}
|
||||
|
||||
func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlotCandidate {
|
||||
slots := make([]refineSlotCandidate, 0, len(tasks))
|
||||
// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
|
||||
func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
slots = append(slots, refineSlotCandidate{
|
||||
logicID, ok := idMapper.stateToLogic[task.TaskID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
|
||||
}
|
||||
result = append(result, compositelogic.RefineTaskCandidate{
|
||||
TaskItemID: logicID,
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
Name: task.Name,
|
||||
ContextTag: task.ContextTag,
|
||||
OriginRank: task.OriginRank,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
|
||||
slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
slots = append(slots, compositelogic.RefineSlotCandidate{
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
@@ -359,18 +412,26 @@ func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlot
|
||||
func buildAfterSnapshotsFromPlannedMoves(
|
||||
state *ScheduleState,
|
||||
beforeByID map[int]minContextSnapshot,
|
||||
plannedMoves []refineMovePlanItem,
|
||||
plannedMoves []compositelogic.RefineMovePlanItem,
|
||||
idMapper *compositeIDMapper,
|
||||
) (map[int]minContextSnapshot, error) {
|
||||
if len(plannedMoves) == 0 {
|
||||
return nil, fmt.Errorf("规划结果为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
|
||||
moveByID := make(map[int]refineMovePlanItem, len(plannedMoves))
|
||||
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
|
||||
for _, move := range plannedMoves {
|
||||
if _, exists := moveByID[move.TaskID]; exists {
|
||||
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", move.TaskID)
|
||||
stateID, ok := idMapper.logicToState[move.TaskItemID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
|
||||
}
|
||||
moveByID[move.TaskID] = move
|
||||
if _, exists := moveByID[stateID]; exists {
|
||||
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
|
||||
}
|
||||
moveByID[stateID] = move
|
||||
}
|
||||
|
||||
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
|
||||
@@ -401,7 +462,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
|
||||
state *ScheduleState,
|
||||
args map[string]any,
|
||||
spanNeed map[int]int,
|
||||
) ([]refineSlotCandidate, error) {
|
||||
) ([]compositelogic.RefineSlotCandidate, error) {
|
||||
if len(spanNeed) == 0 {
|
||||
return nil, fmt.Errorf("未识别到任务跨度需求")
|
||||
}
|
||||
@@ -412,7 +473,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
|
||||
}
|
||||
sort.Ints(spans)
|
||||
|
||||
allSlots := make([]refineSlotCandidate, 0, 16)
|
||||
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
|
||||
seen := make(map[string]struct{}, 64)
|
||||
for _, span := range spans {
|
||||
required := spanNeed[span]
|
||||
@@ -441,7 +502,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
allSlots = append(allSlots, refineSlotCandidate{
|
||||
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
|
||||
Week: slot.Week,
|
||||
DayOfWeek: slot.DayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
@@ -494,7 +555,7 @@ func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) m
|
||||
func buildSpreadEvenDayLoadBaseline(
|
||||
state *ScheduleState,
|
||||
excludeTaskIDs []int,
|
||||
slots []refineSlotCandidate,
|
||||
slots []compositelogic.RefineSlotCandidate,
|
||||
) map[string]int {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
@@ -536,370 +597,10 @@ func buildSpreadEvenDayLoadBaseline(
|
||||
return load
|
||||
}
|
||||
|
||||
func planEvenSpreadMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, options refinePlanOptions) ([]refineMovePlanItem, error) {
|
||||
normalizedTasks, err := normalizePlannerTasks(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedSlots, err := normalizePlannerSlots(slots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalizedSlots) < len(normalizedTasks) {
|
||||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||||
}
|
||||
|
||||
dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
|
||||
for key, value := range options.ExistingDayLoad {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
dayLoad[strings.TrimSpace(key)] = value
|
||||
}
|
||||
|
||||
used := make([]bool, len(normalizedSlots))
|
||||
moves := make([]refineMovePlanItem, 0, len(normalizedTasks))
|
||||
selectedSlots := make([]refineSlotCandidate, 0, len(normalizedTasks))
|
||||
|
||||
for _, task := range normalizedTasks {
|
||||
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
|
||||
bestIdx := -1
|
||||
bestScore := int(^uint(0) >> 1)
|
||||
|
||||
for idx, slot := range normalizedSlots {
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
|
||||
continue
|
||||
}
|
||||
if slotOverlapsAny(slot, selectedSlots) {
|
||||
continue
|
||||
}
|
||||
dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
|
||||
projectedLoad := dayLoad[dayKey] + 1
|
||||
score := projectedLoad*10000 + idx
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
if bestIdx < 0 {
|
||||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
|
||||
}
|
||||
|
||||
chosen := normalizedSlots[bestIdx]
|
||||
used[bestIdx] = true
|
||||
selectedSlots = append(selectedSlots, chosen)
|
||||
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
|
||||
moves = append(moves, refineMovePlanItem{
|
||||
TaskID: task.TaskID,
|
||||
ToWeek: chosen.Week,
|
||||
ToDay: chosen.DayOfWeek,
|
||||
ToSectionFrom: chosen.SectionFrom,
|
||||
ToSectionTo: chosen.SectionTo,
|
||||
})
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
func planMinContextSwitchMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, _ refinePlanOptions) ([]refineMovePlanItem, error) {
|
||||
normalizedTasks, err := normalizePlannerTasks(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedSlots, err := normalizePlannerSlots(slots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalizedSlots) < len(normalizedTasks) {
|
||||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||||
}
|
||||
|
||||
type taskGroup struct {
|
||||
ContextKey string
|
||||
Tasks []refineTaskCandidate
|
||||
MinRank int
|
||||
}
|
||||
|
||||
groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
|
||||
groupMap := make(map[string]*taskGroup, len(normalizedTasks))
|
||||
groupOrder := make([]string, 0, len(normalizedTasks))
|
||||
for _, task := range normalizedTasks {
|
||||
key := groupingKeys[task.TaskID]
|
||||
group, exists := groupMap[key]
|
||||
if !exists {
|
||||
group = &taskGroup{
|
||||
ContextKey: key,
|
||||
MinRank: normalizedOriginRank(task),
|
||||
}
|
||||
groupMap[key] = group
|
||||
groupOrder = append(groupOrder, key)
|
||||
}
|
||||
group.Tasks = append(group.Tasks, task)
|
||||
if rank := normalizedOriginRank(task); rank < group.MinRank {
|
||||
group.MinRank = rank
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([]taskGroup, 0, len(groupMap))
|
||||
for _, key := range groupOrder {
|
||||
group := groupMap[key]
|
||||
sort.SliceStable(group.Tasks, func(i, j int) bool {
|
||||
return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
|
||||
})
|
||||
groups = append(groups, *group)
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if len(groups[i].Tasks) != len(groups[j].Tasks) {
|
||||
return len(groups[i].Tasks) > len(groups[j].Tasks)
|
||||
}
|
||||
if groups[i].MinRank != groups[j].MinRank {
|
||||
return groups[i].MinRank < groups[j].MinRank
|
||||
}
|
||||
return groups[i].ContextKey < groups[j].ContextKey
|
||||
})
|
||||
|
||||
orderedTasks := make([]refineTaskCandidate, 0, len(normalizedTasks))
|
||||
for _, group := range groups {
|
||||
orderedTasks = append(orderedTasks, group.Tasks...)
|
||||
}
|
||||
|
||||
used := make([]bool, len(normalizedSlots))
|
||||
selectedSlots := make([]refineSlotCandidate, 0, len(orderedTasks))
|
||||
moves := make([]refineMovePlanItem, 0, len(orderedTasks))
|
||||
for _, task := range orderedTasks {
|
||||
span := sectionSpan(task.SectionFrom, task.SectionTo)
|
||||
chosenIdx := -1
|
||||
for idx, slot := range normalizedSlots {
|
||||
if used[idx] {
|
||||
continue
|
||||
}
|
||||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != span {
|
||||
continue
|
||||
}
|
||||
if slotOverlapsAny(slot, selectedSlots) {
|
||||
continue
|
||||
}
|
||||
chosenIdx = idx
|
||||
break
|
||||
}
|
||||
if chosenIdx < 0 {
|
||||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
|
||||
}
|
||||
chosen := normalizedSlots[chosenIdx]
|
||||
used[chosenIdx] = true
|
||||
selectedSlots = append(selectedSlots, chosen)
|
||||
moves = append(moves, refineMovePlanItem{
|
||||
TaskID: task.TaskID,
|
||||
ToWeek: chosen.Week,
|
||||
ToDay: chosen.DayOfWeek,
|
||||
ToSectionFrom: chosen.SectionFrom,
|
||||
ToSectionTo: chosen.SectionTo,
|
||||
})
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
func normalizePlannerTasks(tasks []refineTaskCandidate) ([]refineTaskCandidate, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
normalized := make([]refineTaskCandidate, 0, len(tasks))
|
||||
seen := make(map[int]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task.TaskID <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 task_id=%d", task.TaskID)
|
||||
}
|
||||
if _, exists := seen[task.TaskID]; exists {
|
||||
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskID)
|
||||
}
|
||||
if !isValidDay(task.DayOfWeek) {
|
||||
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskID, task.DayOfWeek)
|
||||
}
|
||||
if !isValidSection(task.SectionFrom, task.SectionTo) {
|
||||
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskID, task.SectionFrom, task.SectionTo)
|
||||
}
|
||||
seen[task.TaskID] = struct{}{}
|
||||
normalized = append(normalized, task)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
return compareTaskOrder(normalized[i], normalized[j]) < 0
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizePlannerSlots(slots []refineSlotCandidate) ([]refineSlotCandidate, error) {
|
||||
if len(slots) == 0 {
|
||||
return nil, fmt.Errorf("可用坑位为空")
|
||||
}
|
||||
normalized := make([]refineSlotCandidate, 0, len(slots))
|
||||
seen := make(map[string]struct{}, len(slots))
|
||||
for _, slot := range slots {
|
||||
if slot.Week <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
|
||||
}
|
||||
if !isValidDay(slot.DayOfWeek) {
|
||||
return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
|
||||
}
|
||||
if !isValidSection(slot.SectionFrom, slot.SectionTo) {
|
||||
return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
|
||||
}
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, slot)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].Week != normalized[j].Week {
|
||||
return normalized[i].Week < normalized[j].Week
|
||||
}
|
||||
if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
|
||||
return normalized[i].DayOfWeek < normalized[j].DayOfWeek
|
||||
}
|
||||
if normalized[i].SectionFrom != normalized[j].SectionFrom {
|
||||
return normalized[i].SectionFrom < normalized[j].SectionFrom
|
||||
}
|
||||
return normalized[i].SectionTo < normalized[j].SectionTo
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func compareTaskOrder(a, b refineTaskCandidate) int {
|
||||
rankA := normalizedOriginRank(a)
|
||||
rankB := normalizedOriginRank(b)
|
||||
if rankA != rankB {
|
||||
return rankA - rankB
|
||||
}
|
||||
if a.Week != b.Week {
|
||||
return a.Week - b.Week
|
||||
}
|
||||
if a.DayOfWeek != b.DayOfWeek {
|
||||
return a.DayOfWeek - b.DayOfWeek
|
||||
}
|
||||
if a.SectionFrom != b.SectionFrom {
|
||||
return a.SectionFrom - b.SectionFrom
|
||||
}
|
||||
if a.SectionTo != b.SectionTo {
|
||||
return a.SectionTo - b.SectionTo
|
||||
}
|
||||
return a.TaskID - b.TaskID
|
||||
}
|
||||
|
||||
func normalizedOriginRank(task refineTaskCandidate) int {
|
||||
if task.OriginRank > 0 {
|
||||
return task.OriginRank
|
||||
}
|
||||
return 1_000_000 + task.TaskID
|
||||
}
|
||||
|
||||
func buildMinContextGroupingKeys(tasks []refineTaskCandidate) map[int]string {
|
||||
keys := make(map[int]string, len(tasks))
|
||||
distinctExplicit := make(map[string]struct{}, len(tasks))
|
||||
distinctNonCoarse := make(map[string]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
key := normalizeContextKey(task.ContextTag)
|
||||
keys[task.TaskID] = key
|
||||
distinctExplicit[key] = struct{}{}
|
||||
if !isCoarseContextKey(key) {
|
||||
distinctNonCoarse[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 显式标签已经足够区分时,直接沿用;
|
||||
// 2. 仅在显式标签退化到粗粒度时,才尝试名称兜底。
|
||||
if len(distinctNonCoarse) >= 2 {
|
||||
return keys
|
||||
}
|
||||
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
|
||||
return keys
|
||||
}
|
||||
|
||||
inferredKeys := make(map[int]string, len(tasks))
|
||||
distinctInferred := make(map[string]struct{}, len(tasks))
|
||||
for _, task := range tasks {
|
||||
inferred := inferSubjectContextKeyFromTaskName(task.Name)
|
||||
if inferred == "" {
|
||||
inferred = keys[task.TaskID]
|
||||
}
|
||||
inferredKeys[task.TaskID] = inferred
|
||||
distinctInferred[inferred] = struct{}{}
|
||||
}
|
||||
if len(distinctInferred) >= 2 {
|
||||
return inferredKeys
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func normalizeContextKey(tag string) string {
|
||||
text := strings.TrimSpace(tag)
|
||||
if text == "" {
|
||||
return "General"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func isCoarseContextKey(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "", "general", "high-logic", "high_logic", "memory", "review":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inferSubjectContextKeyFromTaskName(name string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(name))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
// 1. 这里使用轻量关键词,不追求全学科覆盖;
|
||||
// 2. 仅用于“显式标签不足”的兜底场景。
|
||||
switch {
|
||||
case strings.Contains(text, "概率"), strings.Contains(text, "随机变量"), strings.Contains(text, "贝叶斯"), strings.Contains(text, "分布"):
|
||||
return "subject:probability"
|
||||
case strings.Contains(text, "数制"), strings.Contains(text, "逻辑代数"), strings.Contains(text, "时序电路"), strings.Contains(text, "状态图"):
|
||||
return "subject:digital_logic"
|
||||
case strings.Contains(text, "离散"), strings.Contains(text, "图论"), strings.Contains(text, "集合"), strings.Contains(text, "命题逻辑"):
|
||||
return "subject:discrete_math"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func slotOverlapsAny(candidate refineSlotCandidate, selected []refineSlotCandidate) bool {
|
||||
for _, current := range selected {
|
||||
if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
|
||||
continue
|
||||
}
|
||||
if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func composeDayKey(week, day int) string {
|
||||
return fmt.Sprintf("%d-%d", week, day)
|
||||
}
|
||||
|
||||
func sectionSpan(from, to int) int {
|
||||
return to - from + 1
|
||||
}
|
||||
|
||||
func isValidDay(day int) bool {
|
||||
return day >= 1 && day <= 7
|
||||
}
|
||||
|
||||
func isValidSection(from, to int) bool {
|
||||
if from < 1 || to > 12 {
|
||||
return false
|
||||
}
|
||||
return from <= to
|
||||
}
|
||||
|
||||
func uniquePositiveInts(values []int) []int {
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
|
||||
Reference in New Issue
Block a user