diff --git a/backend/active_scheduler/adapters/gorm_readers.go b/backend/active_scheduler/adapters/gorm_readers.go new file mode 100644 index 0000000..bcce50b --- /dev/null +++ b/backend/active_scheduler/adapters/gorm_readers.go @@ -0,0 +1,314 @@ +package adapters + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" +) + +// GormReaders 是主动调度 dry-run 的只读适配器。 +// +// 职责边界: +// 1. 只在 adapter 层直接读取现有表,把外部领域模型转换为主动调度 facts; +// 2. 不生成候选、不写 preview、不写正式日程; +// 3. 后续拆微服务时可替换为 RPC/read model adapter,active_scheduler 主链路无需改动。 +type GormReaders struct { + db *gorm.DB +} + +func NewGormReaders(db *gorm.DB) *GormReaders { + return &GormReaders{db: db} +} + +func ReadersFromGorm(readers *GormReaders) ports.Readers { + return ports.Readers{ + TaskReader: readers, + ScheduleReader: readers, + FeedbackReader: readers, + } +} + +func (r *GormReaders) ensureDB() error { + if r == nil || r.db == nil { + return errors.New("主动调度 GormReaders 未初始化") + } + return nil +} + +func (r *GormReaders) GetTaskForActiveSchedule(ctx context.Context, req ports.TaskRequest) (ports.TaskFact, bool, error) { + if err := r.ensureDB(); err != nil { + return ports.TaskFact{}, false, err + } + if req.UserID <= 0 || req.TaskID <= 0 { + return ports.TaskFact{}, false, nil + } + + var task model.Task + err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", req.TaskID, req.UserID). + First(&task).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ports.TaskFact{}, false, nil + } + return ports.TaskFact{}, false, err + } + + estimatedSections := task.EstimatedSections + if estimatedSections <= 0 { + estimatedSections = 1 + } + if estimatedSections > 4 { + estimatedSections = 4 + } + return ports.TaskFact{ + ID: task.ID, + UserID: task.UserID, + Title: task.Title, + Priority: task.Priority, + IsCompleted: task.IsCompleted, + DeadlineAt: task.DeadlineAt, + UrgencyThresholdAt: task.UrgencyThresholdAt, + EstimatedSections: estimatedSections, + }, true, nil +} + +func (r *GormReaders) GetScheduleFactsByWindow(ctx context.Context, req ports.ScheduleWindowRequest) (ports.ScheduleWindowFacts, error) { + if err := r.ensureDB(); err != nil { + return ports.ScheduleWindowFacts{}, err + } + if req.UserID <= 0 || req.WindowStart.IsZero() || !req.WindowEnd.After(req.WindowStart) { + return ports.ScheduleWindowFacts{}, nil + } + + windowSlots, err := buildWindowSlots(req.WindowStart, req.WindowEnd) + if err != nil { + return ports.ScheduleWindowFacts{}, err + } + weeks := uniqueWeeks(windowSlots) + + var schedules []model.Schedule + if len(weeks) > 0 { + err = r.db.WithContext(ctx). + Preload("Event"). + Where("user_id = ? AND week IN ?", req.UserID, weeks). + Find(&schedules).Error + if err != nil { + return ports.ScheduleWindowFacts{}, err + } + } + + occupiedByKey := make(map[string]model.Schedule, len(schedules)) + eventFacts := make(map[int]*ports.ScheduleEventFact) + targetAlreadyScheduled := false + for _, schedule := range schedules { + if schedule.Event == nil { + continue + } + slot, ok := slotFromSchedule(schedule) + if !ok || slot.StartAt.Before(req.WindowStart) || !slot.StartAt.Before(req.WindowEnd) { + continue + } + occupiedByKey[slotKey(slot)] = schedule + eventFact := eventFacts[schedule.EventID] + if eventFact == nil { + eventFact = scheduleToEventFact(schedule) + eventFacts[schedule.EventID] = eventFact + } + eventFact.Slots = append(eventFact.Slots, slot) + if isSameTarget(schedule.Event, req.TargetType, req.TargetID) { + targetAlreadyScheduled = true + } + } + + occupiedSlots := make([]ports.Slot, 0, len(occupiedByKey)) + freeSlots := make([]ports.Slot, 0, len(windowSlots)) + for _, slot := range windowSlots { + if schedule, exists := occupiedByKey[slotKey(slot)]; exists { + occupied, ok := slotFromSchedule(schedule) + if ok { + occupiedSlots = append(occupiedSlots, occupied) + } + continue + } + freeSlots = append(freeSlots, slot) + } + + events := make([]ports.ScheduleEventFact, 0, len(eventFacts)) + for _, fact := range eventFacts { + events = append(events, *fact) + } + return ports.ScheduleWindowFacts{ + Events: events, + OccupiedSlots: occupiedSlots, + FreeSlots: freeSlots, + NextDynamicTask: firstDynamicTask(events, req.Now), + TargetAlreadyScheduled: targetAlreadyScheduled, + }, nil +} + +func (r *GormReaders) GetFeedbackSignal(ctx context.Context, req ports.FeedbackRequest) (ports.FeedbackFact, bool, error) { + if err := r.ensureDB(); err != nil { + return ports.FeedbackFact{}, false, err + } + // 1. 第一版没有独立 feedback 表,显式传入 schedule_event target 时,把该事件视为已定位反馈目标。 + // 2. 若无法定位目标,返回 found=true + TargetKnown=false,让 observe 阶段稳定降级 ask_user。 + if req.TargetType != string(trigger.TargetTypeScheduleEvent) || req.TargetID <= 0 { + return ports.FeedbackFact{ + FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey), + TargetKnown: false, + SubmittedAt: time.Now(), + }, true, nil + } + + var event model.ScheduleEvent + err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", req.TargetID, req.UserID). + First(&event).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ports.FeedbackFact{ + FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey), + TargetKnown: false, + SubmittedAt: time.Now(), + }, true, nil + } + return ports.FeedbackFact{}, false, err + } + taskItemID := 0 + if event.RelID != nil { + taskItemID = *event.RelID + } + return ports.FeedbackFact{ + FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey), + TargetKnown: true, + TargetEventID: event.ID, + TargetTaskItemID: taskItemID, + TargetTitle: event.Name, + SubmittedAt: time.Now(), + }, true, nil +} + +func buildWindowSlots(startAt, endAt time.Time) ([]ports.Slot, error) { + slots := make([]ports.Slot, 0, 24) + for day := truncateToDate(startAt); day.Before(endAt); day = day.AddDate(0, 0, 1) { + week, dayOfWeek, err := conv.RealDateToRelativeDate(day.Format(conv.DateFormat)) + if err != nil { + return nil, err + } + for section := 1; section <= 12; section++ { + sectionStart, sectionEnd, err := conv.RelativeTimeToRealTime(week, dayOfWeek, section, section) + if err != nil { + return nil, err + } + if sectionStart.Before(startAt) || !sectionStart.Before(endAt) { + continue + } + slots = append(slots, ports.Slot{ + Week: week, + DayOfWeek: dayOfWeek, + Section: section, + StartAt: sectionStart, + EndAt: sectionEnd, + }) + } + } + return slots, nil +} + +func slotFromSchedule(schedule model.Schedule) (ports.Slot, bool) { + startAt, endAt, err := conv.RelativeTimeToRealTime(schedule.Week, schedule.DayOfWeek, schedule.Section, schedule.Section) + if err != nil { + return ports.Slot{}, false + } + return ports.Slot{ + Week: schedule.Week, + DayOfWeek: schedule.DayOfWeek, + Section: schedule.Section, + StartAt: startAt, + EndAt: endAt, + }, true +} + +func scheduleToEventFact(schedule model.Schedule) *ports.ScheduleEventFact { + event := schedule.Event + relID := 0 + if event.RelID != nil { + relID = *event.RelID + } + sourceType := event.TaskSourceType + if sourceType == "" && event.Type == "task" { + sourceType = string(trigger.TargetTypeTaskItem) + } + return &ports.ScheduleEventFact{ + ID: event.ID, + UserID: event.UserID, + Title: event.Name, + SourceType: sourceType, + RelID: relID, + IsDynamicTask: event.Type == "task", + TaskItemID: relID, + } +} + +func isSameTarget(event *model.ScheduleEvent, targetType string, targetID int) bool { + if event == nil || targetID <= 0 || event.RelID == nil || event.Type != "task" { + return false + } + sourceType := event.TaskSourceType + if sourceType == "" { + sourceType = string(trigger.TargetTypeTaskItem) + } + return sourceType == targetType && *event.RelID == targetID +} + +func firstDynamicTask(events []ports.ScheduleEventFact, now time.Time) *ports.ScheduleEventFact { + for i := range events { + if !events[i].IsDynamicTask { + continue + } + for _, slot := range events[i].Slots { + if slot.StartAt.IsZero() || !slot.StartAt.Before(now) { + return &events[i] + } + } + } + return nil +} + +func uniqueWeeks(slots []ports.Slot) []int { + seen := make(map[int]struct{}) + weeks := make([]int, 0) + for _, slot := range slots { + if _, exists := seen[slot.Week]; exists { + continue + } + seen[slot.Week] = struct{}{} + weeks = append(weeks, slot.Week) + } + return weeks +} + +func slotKey(slot ports.Slot) string { + return fmt.Sprintf("%d:%d:%d", slot.Week, slot.DayOfWeek, slot.Section) +} + +func truncateToDate(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/active_scheduler/apply/convert.go b/backend/active_scheduler/apply/convert.go new file mode 100644 index 0000000..3acb20d --- /dev/null +++ b/backend/active_scheduler/apply/convert.go @@ -0,0 +1,261 @@ +package apply + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + rawChangeTypeAdd ChangeType = "add" + rawChangeTypeNone ChangeType = "none" +) + +type candidateSnapshot struct { + CandidateID string + CandidateType ChangeType + Changes []ApplyChange +} + +// ConvertConfirmToApplyRequest 把 preview 中的候选和 confirm 请求转换为正式 apply 请求。 +// +// 职责边界: +// 1. 只读取调用方传入的 preview 快照,不直接访问数据库; +// 2. 负责候选定位、edited_changes 覆盖、范围校验、幂等摘要和 ApplyCommand 生成; +// 3. 不写 schedules,也不执行 task/schedule 当前真值重校验,后者由 ScheduleApplyPort 完成。 +func ConvertConfirmToApplyRequest(preview model.ActiveSchedulePreview, req ConfirmRequest, now time.Time) (*ApplyActiveScheduleRequest, error) { + if req.PreviewID == "" { + req.PreviewID = preview.ID + } + if req.Action == "" { + req.Action = ConfirmActionConfirm + } + if req.RequestedAt.IsZero() { + req.RequestedAt = now + } + if req.UserID <= 0 { + return nil, newApplyError(ErrorCodeInvalidRequest, "user_id 必须由接入层填入", nil) + } + if strings.TrimSpace(req.CandidateID) == "" { + return nil, newApplyError(ErrorCodeInvalidRequest, "candidate_id 不能为空", nil) + } + if req.Action != ConfirmActionConfirm { + return nil, newApplyError(ErrorCodeInvalidRequest, "当前只支持 confirm 动作", nil) + } + if err := ValidatePreviewConfirmable(preview, req.UserID, now); err != nil { + return nil, err + } + + requestHash, err := BuildConfirmRequestHash(preview.ID, req) + if err != nil { + return nil, err + } + if DetectIdempotencyConflict(preview, requestHash.ApplyHash, req.IdempotencyKey) { + return nil, newApplyError(ErrorCodeIdempotencyConflict, "同一个幂等键已绑定不同确认内容", nil) + } + + candidate, err := FindCandidateInPreview(preview, req.CandidateID) + if err != nil { + return nil, err + } + + originalChanges, err := NormalizeChanges(candidate.Changes, candidate.CandidateType) + if err != nil { + return nil, err + } + + changes := originalChanges + if len(req.EditedChanges) > 0 { + editedChanges, normalizeErr := NormalizeChanges(req.EditedChanges, candidate.CandidateType) + if normalizeErr != nil { + return nil, normalizeErr + } + if validateErr := ValidateChangeScope(originalChanges, editedChanges); validateErr != nil { + return nil, validateErr + } + changes = editedChanges + } + + normalizedHash, err := BuildNormalizedChangesHash(changes) + if err != nil { + return nil, err + } + commands, skipped, err := ConvertChangesToCommands(changes) + if err != nil { + return nil, err + } + + return &ApplyActiveScheduleRequest{ + PreviewID: preview.ID, + ApplyID: requestHash.ApplyID, + IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), + RequestHash: requestHash.ApplyHash, + RequestBodyHash: requestHash.BodyHash, + UserID: req.UserID, + CandidateID: req.CandidateID, + BaseVersion: preview.BaseVersion, + Changes: changes, + Commands: commands, + SkippedChanges: skipped, + NormalizedChangesHash: normalizedHash, + RequestedAt: req.RequestedAt, + TraceID: req.TraceID, + }, nil +} + +// FindCandidateInPreview 从 selected_candidate_json 或 candidates_json 中定位 confirm 指定候选。 +// +// 职责边界: +// 1. 优先使用 selected_candidate_json,只有候选 ID 不匹配时才回退 candidates_json; +// 2. 兼容 Go 结构体默认 JSON 字段名和前端常用 snake_case 字段; +// 3. 只返回候选快照,不判断候选是否仍可落库。 +func FindCandidateInPreview(preview model.ActiveSchedulePreview, candidateID string) (candidateSnapshot, error) { + candidateID = strings.TrimSpace(candidateID) + if candidateID == "" { + return candidateSnapshot{}, newApplyError(ErrorCodeInvalidRequest, "candidate_id 不能为空", nil) + } + + if preview.SelectedCandidateJSON != nil && strings.TrimSpace(*preview.SelectedCandidateJSON) != "" { + selected, err := parseCandidateSnapshot([]byte(*preview.SelectedCandidateJSON)) + if err != nil { + return candidateSnapshot{}, err + } + if selected.CandidateID == candidateID { + return selected, nil + } + } + + candidates, err := parseCandidateList(preview.CandidatesJSON) + if err != nil { + return candidateSnapshot{}, err + } + for _, item := range candidates { + if item.CandidateID == candidateID { + return item, nil + } + } + return candidateSnapshot{}, newApplyError(ErrorCodeTargetNotFound, "confirm 指定的 candidate_id 不属于当前 preview", nil) +} + +// NormalizeChanges 对候选或用户编辑后的 changes 做最小规范化。 +// +// 职责边界: +// 1. 填充 change_type、target、duration 和 slots 等缺省字段; +// 2. 合并同一目标的连续节次,降低后续 port 写入复杂度; +// 3. 不做数据库事实校验,不判断冲突。 +func NormalizeChanges(changes []ApplyChange, candidateType ChangeType) ([]ApplyChange, error) { + if len(changes) == 0 && isNoopChangeType(candidateType) { + changes = []ApplyChange{{Type: candidateType}} + } + if len(changes) == 0 { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "候选没有可转换的 changes", nil) + } + + normalized := make([]ApplyChange, 0, len(changes)) + for _, change := range changes { + item, err := normalizeChange(change, candidateType) + if err != nil { + return nil, err + } + normalized = append(normalized, item) + } + + sort.SliceStable(normalized, func(i, j int) bool { + return changeSortKey(normalized[i]) < changeSortKey(normalized[j]) + }) + merged := mergeContinuousChanges(normalized) + for i := range merged { + hash, err := hashJSON(changeForHash(merged[i])) + if err != nil { + return nil, err + } + merged[i].NormalizedHash = hash + } + return merged, nil +} + +// ValidateChangeScope 校验 edited_changes 没有新增候选外目标。 +// +// 职责边界: +// 1. 只比较候选原始 changes 与用户编辑后的 changes; +// 2. 允许 EditedAllowed=true 的 change 改时间坐标,但不允许改 target/type; +// 3. 更细的冲突、课程覆盖、base_version 重校验仍交给 apply port。 +func ValidateChangeScope(original []ApplyChange, edited []ApplyChange) error { + allowed := make(map[string]ApplyChange, len(original)) + for _, change := range original { + allowed[changeScopeKey(change)] = change + } + seen := make(map[string]struct{}, len(edited)) + for _, change := range edited { + key := changeScopeKey(change) + base, ok := allowed[key] + if !ok { + return newApplyError(ErrorCodeInvalidEditedChanges, "edited_changes 包含候选外目标或变更类型", nil) + } + if _, exists := seen[key]; exists { + return newApplyError(ErrorCodeInvalidEditedChanges, "edited_changes 存在重复目标", nil) + } + seen[key] = struct{}{} + if !base.EditedAllowed && !sameChangeForScope(base, change) { + return newApplyError(ErrorCodeInvalidEditedChanges, "该 change 不允许用户编辑", nil) + } + } + return nil +} + +// ConvertChangesToCommands 把规范化后的 changes 转成正式写入命令。 +// +// 职责边界: +// 1. MVP 只生成 task_pool 新增和补做块新增两类命令; +// 2. ask_user/notify_only/close 只返回 skipped_changes,不写正式日程; +// 3. compress_with_next_dynamic_task 明确拒绝,避免 confirm 后出现不可应用候选。 +func ConvertChangesToCommands(changes []ApplyChange) ([]ApplyCommand, []SkippedChange, error) { + commands := make([]ApplyCommand, 0, len(changes)) + skipped := make([]SkippedChange, 0) + for _, change := range changes { + switch change.Type { + case ChangeTypeAddTaskPoolToSchedule: + if change.TargetID <= 0 { + return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "task_pool change 缺少 task_id/target_id", nil) + } + commands = append(commands, ApplyCommand{ + CommandType: CommandTypeInsertTaskPoolEvent, + ChangeID: change.ChangeID, + ChangeType: change.Type, + TargetType: firstNonEmpty(change.TargetType, "task_pool"), + TargetID: change.TargetID, + Slots: slotsFromChange(change), + Metadata: cloneMetadata(change.Metadata), + }) + case ChangeTypeCreateMakeup: + sourceEventID := firstPositive(change.SourceEventID, change.MakeupForEventID, change.EventID, change.TargetID) + if sourceEventID <= 0 { + return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 缺少原 schedule_event id", nil) + } + commands = append(commands, ApplyCommand{ + CommandType: CommandTypeInsertMakeupEvent, + ChangeID: change.ChangeID, + ChangeType: change.Type, + TargetType: firstNonEmpty(change.TargetType, "schedule_event"), + TargetID: firstPositive(change.TargetID, sourceEventID), + Slots: slotsFromChange(change), + SourceEventID: sourceEventID, + Metadata: cloneMetadata(change.Metadata), + }) + case ChangeTypeAskUser, ChangeTypeNotifyOnly, ChangeTypeClose: + skipped = append(skipped, SkippedChange{ + ChangeID: change.ChangeID, + ChangeType: change.Type, + Reason: "该候选只更新交互状态或通知结果,不写正式日程", + }) + case ChangeTypeCompressWithNextDynamicTask: + return nil, nil, newApplyError(ErrorCodeUnsupportedChangeType, "MVP 明确关闭压缩融合 apply", nil) + default: + return nil, nil, newApplyError(ErrorCodeUnsupportedChangeType, fmt.Sprintf("不支持的 change_type: %s", change.Type), nil) + } + } + return commands, skipped, nil +} diff --git a/backend/active_scheduler/apply/convert_helpers.go b/backend/active_scheduler/apply/convert_helpers.go new file mode 100644 index 0000000..de357ab --- /dev/null +++ b/backend/active_scheduler/apply/convert_helpers.go @@ -0,0 +1,455 @@ +package apply + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +func normalizeChange(change ApplyChange, candidateType ChangeType) (ApplyChange, error) { + change.Type = normalizeChangeType(change.Type, candidateType) + if change.Type == ChangeTypeCompressWithNextDynamicTask { + return ApplyChange{}, newApplyError(ErrorCodeUnsupportedChangeType, "MVP 明确关闭压缩融合 apply", nil) + } + if isNoopChangeType(change.Type) { + return change, nil + } + + fillTargetFields(&change) + fillSlotFields(&change) + if err := validateSlotFields(change); err != nil { + return ApplyChange{}, err + } + return change, nil +} + +func normalizeChangeType(changeType ChangeType, candidateType ChangeType) ChangeType { + if changeType == "" || changeType == rawChangeTypeNone { + if isNoopChangeType(candidateType) { + return candidateType + } + return candidateType + } + if changeType == rawChangeTypeAdd && candidateType == ChangeTypeAddTaskPoolToSchedule { + return ChangeTypeAddTaskPoolToSchedule + } + if changeType == rawChangeTypeAdd && candidateType == ChangeTypeCreateMakeup { + return ChangeTypeCreateMakeup + } + return changeType +} + +func fillTargetFields(change *ApplyChange) { + switch change.Type { + case ChangeTypeAddTaskPoolToSchedule: + change.TargetType = firstNonEmpty(change.TargetType, "task_pool") + change.TargetID = firstPositive(change.TargetID, change.TaskID) + change.TaskID = firstPositive(change.TaskID, change.TargetID) + case ChangeTypeCreateMakeup: + sourceEventID := firstPositive(change.SourceEventID, change.MakeupForEventID, change.EventID, change.TargetID) + change.TargetType = firstNonEmpty(change.TargetType, "schedule_event") + change.TargetID = firstPositive(change.TargetID, sourceEventID) + change.EventID = firstPositive(change.EventID, sourceEventID) + change.SourceEventID = sourceEventID + change.MakeupForEventID = firstPositive(change.MakeupForEventID, sourceEventID) + } +} + +func fillSlotFields(change *ApplyChange) { + if len(change.Slots) > 0 { + sort.SliceStable(change.Slots, func(i, j int) bool { + return slotSortKey(change.Slots[i]) < slotSortKey(change.Slots[j]) + }) + first := change.Slots[0] + last := change.Slots[len(change.Slots)-1] + change.Week = firstPositive(change.Week, first.Week) + change.DayOfWeek = firstPositive(change.DayOfWeek, first.DayOfWeek) + change.SectionFrom = firstPositive(change.SectionFrom, first.Section) + change.SectionTo = firstPositive(change.SectionTo, last.Section) + } + if change.DurationSections <= 0 && change.SectionFrom > 0 && change.SectionTo >= change.SectionFrom { + change.DurationSections = change.SectionTo - change.SectionFrom + 1 + } + if change.DurationSections <= 0 { + change.DurationSections = 1 + } + if change.SectionTo <= 0 && change.SectionFrom > 0 { + change.SectionTo = change.SectionFrom + change.DurationSections - 1 + } + if len(change.Slots) == 0 && change.Week > 0 && change.DayOfWeek > 0 && change.SectionFrom > 0 && change.SectionTo >= change.SectionFrom { + change.Slots = buildSlots(change.Week, change.DayOfWeek, change.SectionFrom, change.SectionTo) + } +} + +func validateSlotFields(change ApplyChange) error { + if change.TargetID <= 0 { + return newApplyError(ErrorCodeInvalidEditedChanges, "change 缺少合法 target_id", nil) + } + if change.Week <= 0 || change.DayOfWeek <= 0 || change.SectionFrom <= 0 || change.SectionTo <= 0 { + return newApplyError(ErrorCodeInvalidEditedChanges, "change 缺少合法节次坐标", nil) + } + if change.DayOfWeek < 1 || change.DayOfWeek > 7 { + return newApplyError(ErrorCodeInvalidEditedChanges, "day_of_week 必须在 1 到 7 之间", nil) + } + if change.SectionFrom < 1 || change.SectionFrom > 12 || change.SectionTo < 1 || change.SectionTo > 12 || change.SectionTo < change.SectionFrom { + return newApplyError(ErrorCodeInvalidEditedChanges, "section_from/section_to 必须是合法连续节次", nil) + } + if change.DurationSections != change.SectionTo-change.SectionFrom+1 { + return newApplyError(ErrorCodeInvalidEditedChanges, "duration_sections 与节次数量不一致", nil) + } + return nil +} + +func parseCandidateList(raw *string) ([]candidateSnapshot, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + var first any + if err := json.Unmarshal([]byte(*raw), &first); err != nil { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 不是合法 JSON", err) + } + switch typed := first.(type) { + case []any: + result := make([]candidateSnapshot, 0, len(typed)) + var raws []json.RawMessage + if err := json.Unmarshal([]byte(*raw), &raws); err != nil { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 候选数组解析失败", err) + } + for _, item := range raws { + candidate, err := parseCandidateSnapshot(item) + if err != nil { + return nil, err + } + result = append(result, candidate) + } + return result, nil + case map[string]any: + obj := make(map[string]json.RawMessage) + if err := json.Unmarshal([]byte(*raw), &obj); err != nil { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 候选对象解析失败", err) + } + if nested := rawField(obj, "candidates", "Candidates"); len(nested) > 0 { + nestedText := string(nested) + return parseCandidateList(&nestedText) + } + candidate, err := parseCandidateSnapshot([]byte(*raw)) + if err != nil { + return nil, err + } + return []candidateSnapshot{candidate}, nil + default: + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 结构不受支持", nil) + } +} + +func parseCandidateSnapshot(raw []byte) (candidateSnapshot, error) { + obj := make(map[string]json.RawMessage) + if err := json.Unmarshal(raw, &obj); err != nil { + return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate JSON 解析失败", err) + } + candidate := candidateSnapshot{ + CandidateID: stringField(obj, "candidate_id", "CandidateID", "id", "ID"), + CandidateType: ChangeType(stringField(obj, "candidate_type", "CandidateType", "type", "change_type")), + } + if candidate.CandidateID == "" { + return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate 缺少 candidate_id", nil) + } + if candidate.CandidateType == "" { + return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate 缺少 candidate_type", nil) + } + + changeRaws := rawArrayField(obj, "changes", "Changes", "preview_changes", "PreviewChanges") + candidate.Changes = make([]ApplyChange, 0, len(changeRaws)) + for _, rawChange := range changeRaws { + change, err := parseApplyChange(rawChange, candidate.CandidateType) + if err != nil { + return candidateSnapshot{}, err + } + candidate.Changes = append(candidate.Changes, change) + } + if len(candidate.Changes) == 0 && isNoopChangeType(candidate.CandidateType) { + candidate.Changes = []ApplyChange{{Type: candidate.CandidateType}} + } + return candidate, nil +} + +func parseApplyChange(raw []byte, candidateType ChangeType) (ApplyChange, error) { + obj := make(map[string]json.RawMessage) + if err := json.Unmarshal(raw, &obj); err != nil { + return ApplyChange{}, newApplyError(ErrorCodeInvalidEditedChanges, "change JSON 解析失败", err) + } + change := ApplyChange{ + ChangeID: stringField(obj, "change_id", "ChangeID", "id", "ID"), + Type: ChangeType(stringField(obj, "type", "change_type", "ChangeType")), + TargetType: stringField(obj, "target_type", "TargetType"), + TargetID: intField(obj, "target_id", "TargetID"), + TaskID: intField(obj, "task_id", "TaskID"), + EventID: intField(obj, "event_id", "EventID"), + Week: intField(obj, "week", "Week"), + DayOfWeek: intField(obj, "day_of_week", "DayOfWeek"), + SectionFrom: intField(obj, "section_from", "SectionFrom"), + SectionTo: intField(obj, "section_to", "SectionTo"), + DurationSections: intField(obj, "duration_sections", "DurationSections"), + MakeupForEventID: intField(obj, "makeup_for_event_id", "MakeupForEventID"), + SourceEventID: intField(obj, "source_event_id", "SourceEventID"), + EditedAllowed: boolField(obj, "edited_allowed", "EditedAllowed"), + Metadata: mapStringField(obj, "metadata", "Metadata"), + } + if change.Type == "" { + change.Type = candidateType + } + if len(change.Metadata) > 0 { + change.MakeupForEventID = firstPositive(change.MakeupForEventID, parsePositiveInt(change.Metadata["makeup_for_event_id"])) + change.SourceEventID = firstPositive(change.SourceEventID, parsePositiveInt(change.Metadata["source_event_id"])) + } + change.Slots = slotsFromRawChange(obj) + return change, nil +} + +func slotsFromRawChange(obj map[string]json.RawMessage) []Slot { + if raw := rawField(obj, "slots", "Slots"); len(raw) > 0 { + var slots []Slot + if err := json.Unmarshal(raw, &slots); err == nil && len(slots) > 0 { + return slots + } + } + if raw := rawField(obj, "to_slot", "ToSlot"); len(raw) > 0 { + span, ok := parseSlotSpan(raw) + if ok { + return buildSlots(span.Start.Week, span.Start.DayOfWeek, span.Start.Section, span.End.Section) + } + } + return nil +} + +func parseSlotSpan(raw []byte) (SlotSpan, bool) { + obj := make(map[string]json.RawMessage) + if err := json.Unmarshal(raw, &obj); err != nil { + return SlotSpan{}, false + } + start := parseSlot(rawField(obj, "start", "Start")) + end := parseSlot(rawField(obj, "end", "End")) + duration := intField(obj, "duration_sections", "DurationSections") + if end.IsZero() && !start.IsZero() { + end = start + if duration > 1 { + end.Section = start.Section + duration - 1 + } + } + return SlotSpan{Start: start, End: end, DurationSections: firstPositive(duration, end.Section-start.Section+1)}, !start.IsZero() && !end.IsZero() +} + +func parseSlot(raw []byte) Slot { + if len(raw) == 0 { + return Slot{} + } + obj := make(map[string]json.RawMessage) + if err := json.Unmarshal(raw, &obj); err != nil { + return Slot{} + } + return Slot{ + Week: intField(obj, "week", "Week"), + DayOfWeek: intField(obj, "day_of_week", "DayOfWeek"), + Section: intField(obj, "section", "Section"), + } +} + +func rawField(obj map[string]json.RawMessage, keys ...string) json.RawMessage { + for _, key := range keys { + if raw, ok := obj[key]; ok && len(raw) > 0 && string(raw) != "null" { + return raw + } + } + return nil +} + +func rawArrayField(obj map[string]json.RawMessage, keys ...string) []json.RawMessage { + raw := rawField(obj, keys...) + if len(raw) == 0 { + return nil + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil + } + return items +} + +func stringField(obj map[string]json.RawMessage, keys ...string) string { + raw := rawField(obj, keys...) + if len(raw) == 0 { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err == nil { + return strings.TrimSpace(value) + } + return strings.Trim(strings.TrimSpace(string(raw)), `"`) +} + +func intField(obj map[string]json.RawMessage, keys ...string) int { + raw := rawField(obj, keys...) + if len(raw) == 0 { + return 0 + } + var value int + if err := json.Unmarshal(raw, &value); err == nil { + return value + } + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return parsePositiveInt(asString) + } + return 0 +} + +func boolField(obj map[string]json.RawMessage, keys ...string) bool { + raw := rawField(obj, keys...) + if len(raw) == 0 { + return false + } + var value bool + if err := json.Unmarshal(raw, &value); err == nil { + return value + } + return false +} + +func mapStringField(obj map[string]json.RawMessage, keys ...string) map[string]string { + raw := rawField(obj, keys...) + if len(raw) == 0 { + return nil + } + var result map[string]string + if err := json.Unmarshal(raw, &result); err == nil { + return result + } + var loose map[string]any + if err := json.Unmarshal(raw, &loose); err != nil { + return nil + } + result = make(map[string]string, len(loose)) + for key, value := range loose { + result[key] = fmt.Sprint(value) + } + return result +} + +func buildSlots(week int, dayOfWeek int, sectionFrom int, sectionTo int) []Slot { + if week <= 0 || dayOfWeek <= 0 || sectionFrom <= 0 || sectionTo < sectionFrom { + return nil + } + slots := make([]Slot, 0, sectionTo-sectionFrom+1) + for section := sectionFrom; section <= sectionTo; section++ { + slots = append(slots, Slot{Week: week, DayOfWeek: dayOfWeek, Section: section}) + } + return slots +} + +func slotsFromChange(change ApplyChange) []Slot { + if len(change.Slots) > 0 { + return append([]Slot(nil), change.Slots...) + } + return buildSlots(change.Week, change.DayOfWeek, change.SectionFrom, change.SectionTo) +} + +func mergeContinuousChanges(changes []ApplyChange) []ApplyChange { + if len(changes) <= 1 { + return changes + } + merged := make([]ApplyChange, 0, len(changes)) + for _, change := range changes { + if len(merged) == 0 { + merged = append(merged, change) + continue + } + last := &merged[len(merged)-1] + if canMergeChange(*last, change) { + last.SectionTo = change.SectionTo + last.DurationSections += change.DurationSections + last.Slots = append(last.Slots, change.Slots...) + continue + } + merged = append(merged, change) + } + return merged +} + +func canMergeChange(left ApplyChange, right ApplyChange) bool { + return left.Type == right.Type && + left.TargetType == right.TargetType && + left.TargetID == right.TargetID && + left.SourceEventID == right.SourceEventID && + left.Week == right.Week && + left.DayOfWeek == right.DayOfWeek && + left.SectionTo+1 == right.SectionFrom && + left.EditedAllowed == right.EditedAllowed +} + +func isNoopChangeType(changeType ChangeType) bool { + return changeType == ChangeTypeAskUser || changeType == ChangeTypeNotifyOnly || changeType == ChangeTypeClose +} + +func changeSortKey(change ApplyChange) string { + return fmt.Sprintf("%s:%s:%010d:%010d:%010d:%010d:%010d", + change.Type, change.TargetType, change.TargetID, change.SourceEventID, change.Week, change.DayOfWeek, change.SectionFrom) +} + +func slotSortKey(slot Slot) string { + return fmt.Sprintf("%010d:%010d:%010d", slot.Week, slot.DayOfWeek, slot.Section) +} + +func changeScopeKey(change ApplyChange) string { + return fmt.Sprintf("%s:%s:%d:%d", change.Type, change.TargetType, change.TargetID, change.SourceEventID) +} + +func sameChangeForScope(left ApplyChange, right ApplyChange) bool { + return left.Type == right.Type && + left.TargetType == right.TargetType && + left.TargetID == right.TargetID && + left.SourceEventID == right.SourceEventID && + left.Week == right.Week && + left.DayOfWeek == right.DayOfWeek && + left.SectionFrom == right.SectionFrom && + left.SectionTo == right.SectionTo && + left.DurationSections == right.DurationSections +} + +func changeForHash(change ApplyChange) ApplyChange { + change.NormalizedHash = "" + return change +} + +func cloneMetadata(metadata map[string]string) map[string]string { + if len(metadata) == 0 { + return nil + } + cloned := make(map[string]string, len(metadata)) + for key, value := range metadata { + cloned[key] = value + } + return cloned +} + +func firstPositive(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + +func parsePositiveInt(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} diff --git a/backend/active_scheduler/apply/hash.go b/backend/active_scheduler/apply/hash.go new file mode 100644 index 0000000..f525495 --- /dev/null +++ b/backend/active_scheduler/apply/hash.go @@ -0,0 +1,99 @@ +package apply + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" +) + +type RequestHash struct { + BodyHash string + ApplyHash string + ApplyID string +} + +// BuildConfirmRequestHash 计算 confirm 请求的幂等摘要。 +// +// 职责边界: +// 1. body_hash 只覆盖一次确认动作中真正影响 apply 的 body 字段; +// 2. apply_hash 按 preview_id + idempotency_key + body_hash 计算,满足同 key 不同内容可识别; +// 3. 不查询历史记录,是否冲突由 DetectIdempotencyConflict 或接入层唯一约束判断。 +func BuildConfirmRequestHash(previewID string, req ConfirmRequest) (RequestHash, error) { + previewID = strings.TrimSpace(firstNonEmpty(req.PreviewID, previewID)) + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if previewID == "" { + return RequestHash{}, newApplyError(ErrorCodeInvalidRequest, "preview_id 不能为空", nil) + } + if idempotencyKey == "" { + return RequestHash{}, newApplyError(ErrorCodeInvalidRequest, "idempotency_key 不能为空", nil) + } + + bodyHash, err := hashJSON(confirmRequestBodyForHash{ + CandidateID: strings.TrimSpace(req.CandidateID), + Action: normalizeConfirmAction(req.Action), + EditedChanges: req.EditedChanges, + IdempotencyKey: idempotencyKey, + }) + if err != nil { + return RequestHash{}, err + } + + applyHash := sha256Text(previewID + "\n" + idempotencyKey + "\n" + bodyHash) + applyID := "asap_" + applyHash[:24] + return RequestHash{ + BodyHash: bodyHash, + ApplyHash: applyHash, + ApplyID: applyID, + }, nil +} + +// BuildNormalizedChangesHash 计算转换后 changes 的稳定摘要。 +// +// 职责边界: +// 1. 只用于审计和幂等辅助,不替代正式 DB 重校验; +// 2. 输入应是 NormalizeChanges 后的结果,避免相同语义因顺序不同得到不同摘要; +// 3. 序列化失败会返回 invalid_request,调用方应拒绝本次 confirm。 +func BuildNormalizedChangesHash(changes []ApplyChange) (string, error) { + return hashJSON(changes) +} + +type confirmRequestBodyForHash struct { + CandidateID string `json:"candidate_id"` + Action ConfirmAction `json:"action"` + EditedChanges []ApplyChange `json:"edited_changes,omitempty"` + IdempotencyKey string `json:"idempotency_key"` +} + +func hashJSON(value any) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", newApplyError(ErrorCodeInvalidRequest, "请求体无法生成稳定摘要", err) + } + return sha256Bytes(raw), nil +} + +func sha256Text(text string) string { + return sha256Bytes([]byte(text)) +} + +func sha256Bytes(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func normalizeConfirmAction(action ConfirmAction) ConfirmAction { + if action == "" { + return ConfirmActionConfirm + } + return action +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/active_scheduler/apply/types.go b/backend/active_scheduler/apply/types.go new file mode 100644 index 0000000..0f43670 --- /dev/null +++ b/backend/active_scheduler/apply/types.go @@ -0,0 +1,249 @@ +package apply + +import ( + "context" + "errors" + "fmt" + "time" +) + +type ConfirmAction string + +const ( + ConfirmActionConfirm ConfirmAction = "confirm" +) + +type ChangeType string + +const ( + ChangeTypeAddTaskPoolToSchedule ChangeType = "add_task_pool_to_schedule" + ChangeTypeCreateMakeup ChangeType = "create_makeup" + ChangeTypeAskUser ChangeType = "ask_user" + ChangeTypeNotifyOnly ChangeType = "notify_only" + ChangeTypeClose ChangeType = "close" + ChangeTypeCompressWithNextDynamicTask ChangeType = "compress_with_next_dynamic_task" +) + +type CommandType string + +const ( + CommandTypeInsertTaskPoolEvent CommandType = "insert_task_pool_event" + CommandTypeInsertMakeupEvent CommandType = "insert_makeup_event" +) + +type ApplyStatus string + +const ( + ApplyStatusNone ApplyStatus = "none" + ApplyStatusApplying ApplyStatus = "applying" + ApplyStatusApplied ApplyStatus = "applied" + ApplyStatusFailed ApplyStatus = "failed" + ApplyStatusRejected ApplyStatus = "rejected" + ApplyStatusExpired ApplyStatus = "expired" +) + +type ErrorCode string + +const ( + ErrorCodeExpired ErrorCode = "expired" + ErrorCodeIdempotencyConflict ErrorCode = "idempotency_conflict" + ErrorCodeBaseVersionChanged ErrorCode = "base_version_changed" + ErrorCodeTargetNotFound ErrorCode = "target_not_found" + ErrorCodeTargetCompleted ErrorCode = "target_completed" + ErrorCodeTargetAlreadySchedule ErrorCode = "target_already_scheduled" + ErrorCodeSlotConflict ErrorCode = "slot_conflict" + ErrorCodeInvalidEditedChanges ErrorCode = "invalid_edited_changes" + ErrorCodeUnsupportedChangeType ErrorCode = "unsupported_change_type" + ErrorCodeDBError ErrorCode = "db_error" + ErrorCodeInvalidRequest ErrorCode = "invalid_request" + ErrorCodeForbidden ErrorCode = "forbidden" + ErrorCodeAlreadyApplied ErrorCode = "already_applied" +) + +// ApplyError 表示 confirm/apply 链路可被 API 直接映射的业务错误。 +// +// 职责边界: +// 1. 只承载错误分类和可读信息,便于主线程写入 apply_error 或转成 HTTP 响应; +// 2. 不负责决定 preview 状态流转,状态更新仍由接入层或后续 preview repo 完成; +// 3. Err 保留底层错误,Error() 返回中文消息,便于日志排障。 +type ApplyError struct { + Code ErrorCode + Message string + Err error +} + +func (e *ApplyError) Error() string { + if e == nil { + return "" + } + if e.Message != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return string(e.Code) +} + +func (e *ApplyError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func newApplyError(code ErrorCode, message string, err error) error { + return &ApplyError{Code: code, Message: message, Err: err} +} + +func errorCodeOf(err error) ErrorCode { + if err == nil { + return "" + } + var applyErr *ApplyError + if errors.As(err, &applyErr) { + return applyErr.Code + } + return ErrorCodeDBError +} + +// Slot 是 confirm 请求与 apply command 之间共享的最小节次坐标。 +// +// 职责边界: +// 1. 只表达 week/day_of_week/section,不绑定 schedules 表; +// 2. 不负责相对时间到绝对时间的转换,该转换由 apply port/adapter 完成; +// 3. IsZero 用于识别前端未传坐标或候选 JSON 缺字段的情况。 +type Slot struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + Section int `json:"section"` +} + +func (s Slot) IsZero() bool { + return s.Week == 0 && s.DayOfWeek == 0 && s.Section == 0 +} + +// SlotSpan 表示一段连续节次,供转换器展开为正式写入命令。 +type SlotSpan struct { + Start Slot `json:"start"` + End Slot `json:"end"` + DurationSections int `json:"duration_sections"` +} + +// ApplyChange 是 confirm 请求和候选转换后的统一 change DTO。 +// +// 职责边界: +// 1. 表达用户最终确认的结构化变更,可来自 preview 原候选或 edited_changes; +// 2. 不承载数据库模型,也不表示已经真实落库; +// 3. Type 决定是否可转换为正式写入命令,ask_user/notify_only/close 会被保留为跳过项。 +type ApplyChange struct { + ChangeID string `json:"change_id,omitempty"` + Type ChangeType `json:"type"` + TargetType string `json:"target_type,omitempty"` + TargetID int `json:"target_id,omitempty"` + TaskID int `json:"task_id,omitempty"` + EventID int `json:"event_id,omitempty"` + Week int `json:"week,omitempty"` + DayOfWeek int `json:"day_of_week,omitempty"` + SectionFrom int `json:"section_from,omitempty"` + SectionTo int `json:"section_to,omitempty"` + DurationSections int `json:"duration_sections,omitempty"` + MakeupForEventID int `json:"makeup_for_event_id,omitempty"` + SourceEventID int `json:"source_event_id,omitempty"` + Slots []Slot `json:"slots,omitempty"` + EditedAllowed bool `json:"edited_allowed,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + NormalizedHash string `json:"normalized_hash,omitempty"` +} + +// ConfirmRequest 是主动调度详情页提交确认时的入口 DTO。 +// +// 职责边界: +// 1. PreviewID 可由路由层补齐,body 内没有 preview_id 时也能参与转换; +// 2. EditedChanges 为空时,转换器会回退使用 preview 中 candidate 的原始 changes; +// 3. IdempotencyKey 只代表一次确认动作,不代表 candidate 身份。 +type ConfirmRequest struct { + PreviewID string `json:"preview_id,omitempty"` + UserID int `json:"user_id,omitempty"` + CandidateID string `json:"candidate_id"` + Action ConfirmAction `json:"action"` + EditedChanges []ApplyChange `json:"edited_changes,omitempty"` + IdempotencyKey string `json:"idempotency_key"` + RequestedAt time.Time `json:"requested_at,omitempty"` + TraceID string `json:"trace_id,omitempty"` +} + +type ApplyCommand struct { + CommandType CommandType `json:"command_type"` + ChangeID string `json:"change_id,omitempty"` + ChangeType ChangeType `json:"change_type"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + Slots []Slot `json:"slots"` + SourceEventID int `json:"source_event_id,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type SkippedChange struct { + ChangeID string `json:"change_id,omitempty"` + ChangeType ChangeType `json:"change_type"` + Reason string `json:"reason"` +} + +// ApplyActiveScheduleRequest 是传给正式写入 port 的请求 DTO。 +// +// 职责边界: +// 1. 只描述已完成 preview/candidate 转换和基础校验后的写入意图; +// 2. 不直接执行 schedules 写入,真正事务由 ScheduleApplyPort/adapter 负责; +// 3. RequestHash 用于 preview_id + idempotency_key + body_hash 的幂等识别。 +type ApplyActiveScheduleRequest struct { + PreviewID string `json:"preview_id"` + ApplyID string `json:"apply_id"` + IdempotencyKey string `json:"idempotency_key"` + RequestHash string `json:"request_hash"` + RequestBodyHash string `json:"request_body_hash"` + UserID int `json:"user_id"` + CandidateID string `json:"candidate_id"` + BaseVersion string `json:"base_version"` + Changes []ApplyChange `json:"changes"` + Commands []ApplyCommand `json:"commands"` + SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"` + NormalizedChangesHash string `json:"normalized_changes_hash"` + RequestedAt time.Time `json:"requested_at"` + TraceID string `json:"trace_id,omitempty"` +} + +type ApplyActiveScheduleResult struct { + ApplyID string `json:"apply_id"` + ApplyStatus ApplyStatus `json:"apply_status"` + AppliedEventIDs []int `json:"applied_event_ids,omitempty"` + AppliedScheduleIDs []int `json:"applied_schedule_ids,omitempty"` + AppliedChanges []ApplyChange `json:"applied_changes,omitempty"` + SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"` + WarningMessages []string `json:"warning_messages,omitempty"` + ErrorCode ErrorCode `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + RequestHash string `json:"request_hash,omitempty"` + NormalizedChangeHash string `json:"normalized_change_hash,omitempty"` +} + +type ConfirmResult struct { + PreviewID string `json:"preview_id"` + ApplyID string `json:"apply_id"` + ApplyStatus ApplyStatus `json:"apply_status"` + CandidateID string `json:"candidate_id"` + RequestHash string `json:"request_hash,omitempty"` + RequestBodyHash string `json:"request_body_hash,omitempty"` + ApplyRequest *ApplyActiveScheduleRequest `json:"apply_request,omitempty"` + ApplyResult *ApplyActiveScheduleResult `json:"apply_result,omitempty"` + SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"` + ErrorCode ErrorCode `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// ScheduleApplyPort 是主动调度 apply 层唯一允许调用的正式写入端口。 +// +// 职责边界: +// 1. 负责在事务内重读 task/schedule/task_item 真值并写入正式日程; +// 2. 负责返回真实 applied_changes/applied_event_ids,而不是候选原始内容; +// 3. apply 包本身不直接 import DAO 写 schedules,避免绕过既有领域能力。 +type ScheduleApplyPort interface { + ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error) +} diff --git a/backend/active_scheduler/apply/validate.go b/backend/active_scheduler/apply/validate.go new file mode 100644 index 0000000..960c712 --- /dev/null +++ b/backend/active_scheduler/apply/validate.go @@ -0,0 +1,98 @@ +package apply + +import ( + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" +) + +// IsPreviewExpired 判断 preview 是否已经超过确认有效期。 +// +// 职责边界: +// 1. 只比较 expires_at 与调用方传入的 now; +// 2. 不读取数据库,也不更新 preview.status; +// 3. now 为空时按“不能安全确认”处理,避免调用方误放过过期预览。 +func IsPreviewExpired(preview model.ActiveSchedulePreview, now time.Time) bool { + if now.IsZero() || preview.ExpiresAt.IsZero() { + return true + } + return !now.Before(preview.ExpiresAt) +} + +// IsPreviewOwnedByUser 判断 preview 是否归属当前用户。 +// +// 职责边界: +// 1. 只做 user_id 等值判断; +// 2. userID 非法时直接返回 false; +// 3. 不判断用户是否仍存在,该事实应由 API 鉴权或接入层保证。 +func IsPreviewOwnedByUser(preview model.ActiveSchedulePreview, userID int) bool { + return userID > 0 && preview.UserID == userID +} + +// IsPreviewAlreadyApplied 判断 preview 是否已经成功应用过。 +// +// 职责边界: +// 1. 同时兼容 preview.status 与 apply_status 两个字段; +// 2. 只识别“已成功应用”,不把 failed/rejected 视为成功; +// 3. 返回 true 时主线程应避免再次写正式日程。 +func IsPreviewAlreadyApplied(preview model.ActiveSchedulePreview) bool { + return preview.Status == model.ActiveSchedulePreviewStatusApplied || + preview.ApplyStatus == model.ActiveScheduleApplyStatusApplied +} + +// ValidatePreviewConfirmable 执行 confirm 入口的基础 preview 判断。 +// +// 职责边界: +// 1. 只校验预览归属、过期、状态与已应用等轻量规则; +// 2. 不校验 task/schedule 当前真值,也不判断冲突,正式重校验由 apply port 完成; +// 3. 返回 nil 表示可以继续做候选转换,返回 ApplyError 表示本次 confirm 应被拒绝。 +func ValidatePreviewConfirmable(preview model.ActiveSchedulePreview, userID int, now time.Time) error { + if preview.ID == "" { + return newApplyError(ErrorCodeTargetNotFound, "预览不存在或未加载", nil) + } + if !IsPreviewOwnedByUser(preview, userID) { + return newApplyError(ErrorCodeForbidden, "预览不属于当前用户", nil) + } + if IsPreviewExpired(preview, now) || preview.Status == model.ActiveSchedulePreviewStatusExpired || preview.ApplyStatus == model.ActiveScheduleApplyStatusExpired { + return newApplyError(ErrorCodeExpired, "预览已过期,请重新生成建议", nil) + } + if IsPreviewAlreadyApplied(preview) { + return newApplyError(ErrorCodeAlreadyApplied, "该预览已经应用过,不能重复写入日程", nil) + } + if preview.Status == model.ActiveSchedulePreviewStatusIgnored { + return newApplyError(ErrorCodeInvalidRequest, "该预览已被忽略,不能继续确认", nil) + } + if preview.Status == model.ActiveSchedulePreviewStatusFailed { + return newApplyError(ErrorCodeInvalidRequest, "该预览生成失败,不能继续确认", nil) + } + if preview.Status != "" && preview.Status != model.ActiveSchedulePreviewStatusReady && preview.Status != model.ActiveSchedulePreviewStatusPending { + return newApplyError(ErrorCodeInvalidRequest, "预览状态不允许确认", nil) + } + if preview.ApplyStatus != "" && + preview.ApplyStatus != model.ActiveScheduleApplyStatusNone && + preview.ApplyStatus != model.ActiveScheduleApplyStatusFailed && + preview.ApplyStatus != model.ActiveScheduleApplyStatusRejected { + return newApplyError(ErrorCodeInvalidRequest, "当前 apply 状态不允许重新确认", nil) + } + return nil +} + +// DetectIdempotencyConflict 判断同一个 preview_id + idempotency_key 是否被复用于不同请求体。 +// +// 职责边界: +// 1. 只比较当前请求和 preview 已记录的 apply_idempotency_key / apply_request_hash; +// 2. 不查询数据库唯一约束,主线程仍需要在事务或行锁内调用; +// 3. 返回 true 表示必须拒绝,避免同 key 不同内容污染审计链路。 +func DetectIdempotencyConflict(preview model.ActiveSchedulePreview, requestHash string, idempotencyKey string) bool { + if strings.TrimSpace(idempotencyKey) == "" || strings.TrimSpace(preview.ApplyIdempotencyKey) == "" { + return false + } + if preview.ApplyIdempotencyKey != idempotencyKey { + return false + } + if preview.ApplyRequestHash == "" { + return false + } + return preview.ApplyRequestHash != requestHash +} diff --git a/backend/active_scheduler/applyadapter/adapter.go b/backend/active_scheduler/applyadapter/adapter.go new file mode 100644 index 0000000..579c3f8 --- /dev/null +++ b/backend/active_scheduler/applyadapter/adapter.go @@ -0,0 +1,491 @@ +package applyadapter + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GormApplyAdapter 负责把主动调度确认后的变更写入正式 schedule 表。 +// +// 职责边界: +// 1. 只写 schedule_events / schedules,并在事务内完成目标重校验与冲突重校验; +// 2. 不回写 active_schedule_previews,不发布 outbox,不调用 API/service/task; +// 3. 不创建 task_item,也不更新 task / task_items 状态,task_pool 是否已安排由 schedule_events 反查判断。 +type GormApplyAdapter struct { + db *gorm.DB +} + +func NewGormApplyAdapter(db *gorm.DB) *GormApplyAdapter { + return &GormApplyAdapter{db: db} +} + +// ApplyActiveScheduleChanges 在单个数据库事务内应用主动调度变更。 +// +// 事务语义: +// 1. 先规范化所有 change 的节次,并检查本次请求内部是否自相冲突; +// 2. 事务内锁定目标事实并重查 schedules 占用,任何冲突都直接返回 slot_conflict; +// 3. 所有 event 和 schedules 都成功插入后才提交;任一错误都会回滚,避免半写。 +// +// 输入输出: +// 1. req.UserID / req.PreviewID / req.Changes 必须有效; +// 2. 返回的 AppliedEventIDs 是新建 schedule_events.id; +// 3. error 若为 *ApplyError,上游可按 Code 分类处理。 +func (a *GormApplyAdapter) ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error) { + if a == nil || a.db == nil { + return ApplyActiveScheduleResult{}, newApplyError(ErrorCodeInvalidRequest, "主动调度 apply adapter 未初始化", nil) + } + normalized, err := normalizeRequest(req) + if err != nil { + return ApplyActiveScheduleResult{}, err + } + + result := ApplyActiveScheduleResult{ApplyID: req.ApplyID} + err = a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + appliedEventIDs := make([]int, 0, len(normalized)) + appliedScheduleIDs := make([]int, 0) + for _, change := range normalized { + var eventIDs []int + var scheduleIDs []int + var applyErr error + switch { + case isAddTaskPoolChange(change): + eventIDs, scheduleIDs, applyErr = a.applyTaskPoolChange(ctx, tx, req, change) + case isCreateMakeupChange(change): + eventIDs, scheduleIDs, applyErr = a.applyMakeupChange(ctx, tx, req, change) + default: + applyErr = newApplyError(ErrorCodeUnsupportedChangeType, fmt.Sprintf("不支持的主动调度变更类型:%s", change.ChangeType), nil) + } + if applyErr != nil { + return applyErr + } + appliedEventIDs = append(appliedEventIDs, eventIDs...) + appliedScheduleIDs = append(appliedScheduleIDs, scheduleIDs...) + } + result.AppliedEventIDs = appliedEventIDs + result.AppliedScheduleIDs = appliedScheduleIDs + return nil + }) + if err != nil { + return ApplyActiveScheduleResult{}, classifyDBError(err) + } + return result, nil +} + +func (a *GormApplyAdapter) applyTaskPoolChange(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange) ([]int, []int, error) { + targetID := change.TargetID + if change.TargetType != "" && change.TargetType != TargetTypeTaskPool { + return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "add_task_pool_to_schedule 只能写入 task_pool 目标", nil) + } + + // 调用目的:锁住同一个 task_pool 任务,串行化“是否已经进入日程”的判断,避免并发确认写出重复任务块。 + task, err := lockTaskPool(ctx, tx, req.UserID, targetID) + if err != nil { + return nil, nil, err + } + if task.IsCompleted { + return nil, nil, newApplyError(ErrorCodeTargetCompleted, "task_pool 任务已完成,不能再加入日程", nil) + } + if err := ensureTaskPoolNotScheduled(ctx, tx, req.UserID, task.ID); err != nil { + return nil, nil, err + } + if err := ensureSlotsFree(ctx, tx, req.UserID, change); err != nil { + return nil, nil, err + } + + eventName := strings.TrimSpace(task.Title) + if eventName == "" { + eventName = fmt.Sprintf("任务 %d", task.ID) + } + relID := task.ID + return insertTaskEventWithSchedules(ctx, tx, req, change, eventPayload{ + Name: eventName, + TaskSourceType: TaskSourceTypeTaskPool, + RelID: relID, + Sections: change.Sections, + }) +} + +func (a *GormApplyAdapter) applyMakeupChange(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange) ([]int, []int, error) { + target, err := resolveMakeupTarget(ctx, tx, req.UserID, change) + if err != nil { + return nil, nil, err + } + if err := ensureSlotsFree(ctx, tx, req.UserID, change); err != nil { + return nil, nil, err + } + + return insertTaskEventWithSchedules(ctx, tx, req, change, eventPayload{ + Name: target.Name, + TaskSourceType: target.TaskSourceType, + RelID: target.RelID, + MakeupForEventID: &target.MakeupForEventID, + Sections: change.Sections, + }) +} + +type normalizedChange struct { + ApplyChange + Week int + DayOfWeek int + Sections []int +} + +func normalizeRequest(req ApplyActiveScheduleRequest) ([]normalizedChange, error) { + if req.UserID <= 0 { + return nil, newApplyError(ErrorCodeInvalidRequest, "user_id 不能为空", nil) + } + if strings.TrimSpace(req.PreviewID) == "" { + return nil, newApplyError(ErrorCodeInvalidRequest, "preview_id 不能为空", nil) + } + if len(req.Changes) == 0 { + return nil, newApplyError(ErrorCodeInvalidRequest, "changes 不能为空", nil) + } + + seenSlots := make(map[string]struct{}) + normalized := make([]normalizedChange, 0, len(req.Changes)) + for _, change := range req.Changes { + sections, err := normalizeSections(change) + if err != nil { + return nil, err + } + for _, section := range sections { + key := fmt.Sprintf("%d:%d:%d", change.ToSlot.Start.Week, change.ToSlot.Start.DayOfWeek, section) + if _, exists := seenSlots[key]; exists { + return nil, newApplyError(ErrorCodeSlotConflict, "本次确认请求内部存在重复节次", nil) + } + seenSlots[key] = struct{}{} + } + normalized = append(normalized, normalizedChange{ + ApplyChange: change, + Week: change.ToSlot.Start.Week, + DayOfWeek: change.ToSlot.Start.DayOfWeek, + Sections: sections, + }) + } + return normalized, nil +} + +func normalizeSections(change ApplyChange) ([]int, error) { + if change.TargetID <= 0 { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "变更目标 ID 不能为空", nil) + } + if change.ToSlot == nil { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "变更缺少目标节次", nil) + } + start := change.ToSlot.Start + end := change.ToSlot.End + if start.Week <= 0 || start.DayOfWeek < 1 || start.DayOfWeek > 7 || start.Section < 1 || start.Section > 12 { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标起始节次不合法", nil) + } + duration := change.DurationSections + if duration <= 0 { + duration = change.ToSlot.DurationSections + } + if end.Section <= 0 && duration > 0 { + end = Slot{Week: start.Week, DayOfWeek: start.DayOfWeek, Section: start.Section + duration - 1} + } + if end.Week <= 0 && end.DayOfWeek <= 0 && end.Section <= 0 { + end = start + } + if end.Week != start.Week || end.DayOfWeek != start.DayOfWeek || end.Section < start.Section { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标节次必须是同一天内的连续区间", nil) + } + if end.Section > 12 { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标结束节次不合法", nil) + } + actualDuration := end.Section - start.Section + 1 + if duration > 0 && duration != actualDuration { + return nil, newApplyError(ErrorCodeInvalidEditedChanges, "duration_sections 与目标节次跨度不一致", nil) + } + sections := make([]int, 0, actualDuration) + for section := start.Section; section <= end.Section; section++ { + sections = append(sections, section) + } + return sections, nil +} + +func isAddTaskPoolChange(change normalizedChange) bool { + if change.ChangeType == ChangeTypeAddTaskPoolToSchedule { + return true + } + return change.ChangeType == changeTypeAdd && change.TargetType == TargetTypeTaskPool +} + +func isCreateMakeupChange(change normalizedChange) bool { + return change.ChangeType == ChangeTypeCreateMakeup +} + +func lockTaskPool(ctx context.Context, tx *gorm.DB, userID, taskID int) (model.Task, error) { + var task model.Task + err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND user_id = ?", taskID, userID). + First(&task).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.Task{}, newApplyError(ErrorCodeTargetNotFound, "task_pool 任务不存在或不属于当前用户", nil) + } + return model.Task{}, newApplyError(ErrorCodeDBError, "读取 task_pool 任务失败", err) + } + return task, nil +} + +func ensureTaskPoolNotScheduled(ctx context.Context, tx *gorm.DB, userID, taskID int) error { + var count int64 + err := tx.WithContext(ctx). + Model(&model.ScheduleEvent{}). + Where("user_id = ? AND type = ? AND task_source_type = ? AND rel_id = ?", userID, scheduleEventTypeTask, TaskSourceTypeTaskPool, taskID). + Count(&count).Error + if err != nil { + return newApplyError(ErrorCodeDBError, "检查 task_pool 是否已进入日程失败", err) + } + if count > 0 { + return newApplyError(ErrorCodeTargetAlreadyScheduled, "task_pool 任务已进入日程", nil) + } + return nil +} + +func ensureSlotsFree(ctx context.Context, tx *gorm.DB, userID int, change normalizedChange) error { + sections := change.Sections + if len(sections) == 0 { + return newApplyError(ErrorCodeInvalidEditedChanges, "目标节次不能为空", nil) + } + sort.Ints(sections) + startSection := sections[0] + endSection := sections[len(sections)-1] + + // 1. 在事务内对目标节次加行锁,命中任何已有 schedules 都视为冲突。 + // 2. 若并发事务在检查后抢先插入同一唯一键,后续 Create 会被唯一索引兜底拦截并整体回滚。 + // 3. MVP 不处理课程嵌入,任何已有课程、固定日程或任务都不可覆盖。 + var occupied []model.Schedule + err := tx.WithContext(ctx). + Model(&model.Schedule{}). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND week = ? AND day_of_week = ? AND section IN ?", userID, change.Week, change.DayOfWeek, sections). + Find(&occupied).Error + if err != nil { + return newApplyError(ErrorCodeDBError, "检查目标节次冲突失败", err) + } + if len(occupied) > 0 { + return newApplyError(ErrorCodeSlotConflict, fmt.Sprintf("第 %d-%d 节已被占用", startSection, endSection), nil) + } + return nil +} + +type eventPayload struct { + Name string + TaskSourceType string + RelID int + MakeupForEventID *int + Sections []int +} + +func insertTaskEventWithSchedules(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange, payload eventPayload) ([]int, []int, error) { + sections := append([]int(nil), payload.Sections...) + sort.Ints(sections) + start := sections[0] + end := sections[len(sections)-1] + startTime, endTime, err := conv.RelativeTimeToRealTime(change.Week, change.DayOfWeek, start, end) + if err != nil { + return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标节次无法转换为绝对时间", err) + } + + previewID := strings.TrimSpace(req.PreviewID) + event := model.ScheduleEvent{ + UserID: req.UserID, + Name: payload.Name, + Type: scheduleEventTypeTask, + TaskSourceType: payload.TaskSourceType, + RelID: &payload.RelID, + MakeupForEventID: payload.MakeupForEventID, + ActivePreviewID: &previewID, + CanBeEmbedded: false, + StartTime: startTime, + EndTime: endTime, + } + if err := tx.WithContext(ctx).Create(&event).Error; err != nil { + return nil, nil, newApplyError(ErrorCodeDBError, "写入 schedule_events 失败", err) + } + + schedules := make([]model.Schedule, 0, len(sections)) + for _, section := range sections { + schedules = append(schedules, model.Schedule{ + EventID: event.ID, + UserID: req.UserID, + Week: change.Week, + DayOfWeek: change.DayOfWeek, + Section: section, + Status: scheduleStatusNormal, + }) + } + if err := tx.WithContext(ctx).Create(&schedules).Error; err != nil { + return nil, nil, newApplyError(ErrorCodeDBError, "写入 schedules 失败", err) + } + + scheduleIDs := make([]int, 0, len(schedules)) + for _, schedule := range schedules { + scheduleIDs = append(scheduleIDs, schedule.ID) + } + return []int{event.ID}, scheduleIDs, nil +} + +type makeupTarget struct { + Name string + TaskSourceType string + RelID int + MakeupForEventID int +} + +func resolveMakeupTarget(ctx context.Context, tx *gorm.DB, userID int, change normalizedChange) (makeupTarget, error) { + makeupForEventID := parsePositiveInt(change.Metadata["makeup_for_event_id"]) + if change.TargetType == "" || change.TargetType == TargetTypeScheduleEvent { + if change.TargetID > 0 { + makeupForEventID = change.TargetID + } + return resolveMakeupFromEvent(ctx, tx, userID, makeupForEventID) + } + if makeupForEventID <= 0 { + return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 必须提供 makeup_for_event_id", nil) + } + if _, err := lockScheduleEvent(ctx, tx, userID, makeupForEventID); err != nil { + return makeupTarget{}, err + } + + switch change.TargetType { + case TargetTypeTaskPool: + task, err := lockTaskPool(ctx, tx, userID, change.TargetID) + if err != nil { + return makeupTarget{}, err + } + if task.IsCompleted { + return makeupTarget{}, newApplyError(ErrorCodeTargetCompleted, "补做目标 task_pool 已完成", nil) + } + return makeupTarget{ + Name: nonEmpty(task.Title, fmt.Sprintf("任务 %d", task.ID)), + TaskSourceType: TaskSourceTypeTaskPool, + RelID: task.ID, + MakeupForEventID: makeupForEventID, + }, nil + case TargetTypeTaskItem: + item, err := lockTaskItemForUser(ctx, tx, userID, change.TargetID) + if err != nil { + return makeupTarget{}, err + } + return makeupTarget{ + Name: nonEmpty(stringPtrValue(item.Content), fmt.Sprintf("任务块 %d", item.ID)), + TaskSourceType: TaskSourceTypeTaskItem, + RelID: item.ID, + MakeupForEventID: makeupForEventID, + }, nil + default: + return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 目标类型不合法", nil) + } +} + +func resolveMakeupFromEvent(ctx context.Context, tx *gorm.DB, userID, eventID int) (makeupTarget, error) { + event, err := lockScheduleEvent(ctx, tx, userID, eventID) + if err != nil { + return makeupTarget{}, err + } + if event.Type != scheduleEventTypeTask || event.RelID == nil || *event.RelID <= 0 { + return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "补做来源必须是已排任务日程", nil) + } + sourceType := event.TaskSourceType + if sourceType == "" { + sourceType = TaskSourceTypeTaskItem + } + if sourceType != TaskSourceTypeTaskItem && sourceType != TaskSourceTypeTaskPool { + return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "补做来源任务类型不合法", nil) + } + return makeupTarget{ + Name: nonEmpty(event.Name, fmt.Sprintf("补做任务 %d", event.ID)), + TaskSourceType: sourceType, + RelID: *event.RelID, + MakeupForEventID: event.ID, + }, nil +} + +func lockScheduleEvent(ctx context.Context, tx *gorm.DB, userID, eventID int) (model.ScheduleEvent, error) { + if eventID <= 0 { + return model.ScheduleEvent{}, newApplyError(ErrorCodeInvalidEditedChanges, "makeup_for_event_id 不能为空", nil) + } + var event model.ScheduleEvent + err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND user_id = ?", eventID, userID). + First(&event).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.ScheduleEvent{}, newApplyError(ErrorCodeTargetNotFound, "补做来源日程不存在或不属于当前用户", nil) + } + return model.ScheduleEvent{}, newApplyError(ErrorCodeDBError, "读取补做来源日程失败", err) + } + return event, nil +} + +func lockTaskItemForUser(ctx context.Context, tx *gorm.DB, userID, taskItemID int) (model.TaskClassItem, error) { + var item model.TaskClassItem + err := tx.WithContext(ctx). + Table("task_items"). + Select("task_items.*"). + Joins("JOIN task_classes ON task_classes.id = task_items.category_id"). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("task_items.id = ? AND task_classes.user_id = ?", taskItemID, userID). + First(&item).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.TaskClassItem{}, newApplyError(ErrorCodeTargetNotFound, "task_item 不存在或不属于当前用户", nil) + } + return model.TaskClassItem{}, newApplyError(ErrorCodeDBError, "读取 task_item 失败", err) + } + return item, nil +} + +func parsePositiveInt(value string) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +func nonEmpty(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func classifyDBError(err error) error { + if err == nil { + return nil + } + var applyErr *ApplyError + if errors.As(err, &applyErr) { + return applyErr + } + message := strings.ToLower(err.Error()) + if strings.Contains(message, "duplicate entry") || + strings.Contains(message, "unique constraint") || + strings.Contains(message, "unique violation") || + strings.Contains(message, "idx_user_slot_atomic") { + return newApplyError(ErrorCodeSlotConflict, "目标节次已被其他日程占用", err) + } + return newApplyError(ErrorCodeDBError, "主动调度正式写库失败", err) +} diff --git a/backend/active_scheduler/applyadapter/types.go b/backend/active_scheduler/applyadapter/types.go new file mode 100644 index 0000000..8ffa1e1 --- /dev/null +++ b/backend/active_scheduler/applyadapter/types.go @@ -0,0 +1,127 @@ +package applyadapter + +import "time" + +const ( + ChangeTypeAddTaskPoolToSchedule = "add_task_pool_to_schedule" + ChangeTypeCreateMakeup = "create_makeup" + + changeTypeAdd = "add" + + TargetTypeTaskPool = "task_pool" + TargetTypeTaskItem = "task_item" + TargetTypeScheduleEvent = "schedule_event" + + scheduleEventTypeTask = "task" + scheduleStatusNormal = "normal" + + TaskSourceTypeTaskPool = "task_pool" + TaskSourceTypeTaskItem = "task_item" +) + +const ( + ErrorCodeInvalidRequest = "invalid_request" + ErrorCodeUnsupportedChangeType = "unsupported_change_type" + ErrorCodeTargetNotFound = "target_not_found" + ErrorCodeTargetCompleted = "target_completed" + ErrorCodeTargetAlreadyScheduled = "target_already_scheduled" + ErrorCodeSlotConflict = "slot_conflict" + ErrorCodeInvalidEditedChanges = "invalid_edited_changes" + ErrorCodeDBError = "db_error" +) + +// ApplyActiveScheduleRequest 是主动调度确认后交给 schedule 域的正式写库请求。 +// +// 职责边界: +// 1. 只承载已经由上游 preview/confirm 校验过的用户、候选和变更事实; +// 2. 不负责表达 preview 状态回写,adapter 成功后仅返回正式落库 ID; +// 3. Changes 可以来自原始 preview_changes,也可以来自用户编辑后的 edited_changes。 +type ApplyActiveScheduleRequest struct { + PreviewID string + ApplyID string + UserID int + CandidateID string + Changes []ApplyChange + RequestedAt time.Time + TraceID string +} + +// ApplyChange 是 apply adapter 可执行的最小变更单元。 +// +// 字段语义: +// 1. ChangeType 支持 add_task_pool_to_schedule / create_makeup; +// 2. TargetType + TargetID 描述要落库的任务来源或原日程块; +// 3. ToSlot 是最终确认后的落位节次,adapter 不信任调用方的冲突判断,会在事务内重查。 +type ApplyChange struct { + ChangeID string + ChangeType string + TargetType string + TargetID int + ToSlot *SlotSpan + DurationSections int + Metadata map[string]string +} + +// Slot 描述 schedules 表的一格原子节次坐标。 +type Slot struct { + Week int + DayOfWeek int + Section int +} + +// SlotSpan 描述一个连续节次块。 +// +// 说明: +// 1. Start 必填; +// 2. End 可由 DurationSections 推导,但调用方传入时必须与 Start 同周同日且连续; +// 3. DurationSections 小于等于 0 时,adapter 会按 Start/End 计算。 +type SlotSpan struct { + Start Slot + End Slot + DurationSections int +} + +// ApplyActiveScheduleResult 是正式日程写库结果。 +// +// 职责边界: +// 1. AppliedEventIDs 返回本次新建的 schedule_events.id; +// 2. AppliedScheduleIDs 返回本次新建的 schedules.id; +// 3. 不包含 preview apply_status,避免 adapter 越权回写 active_schedule_previews。 +type ApplyActiveScheduleResult struct { + ApplyID string + AppliedEventIDs []int + AppliedScheduleIDs []int +} + +// ApplyError 是 adapter 返回给上游的可分类业务错误。 +// +// 说明: +// 1. Code 用于上游决定 preview apply_error / 交互文案; +// 2. Cause 保留底层错误,便于日志排障; +// 3. Error() 面向调用方,保持中文可读。 +type ApplyError struct { + Code string + Message string + Cause error +} + +func (e *ApplyError) Error() string { + if e == nil { + return "" + } + if e.Cause == nil { + return e.Message + } + return e.Message + ": " + e.Cause.Error() +} + +func (e *ApplyError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func newApplyError(code, message string, cause error) error { + return &ApplyError{Code: code, Message: message, Cause: cause} +} diff --git a/backend/active_scheduler/candidate/candidate.go b/backend/active_scheduler/candidate/candidate.go new file mode 100644 index 0000000..759f4f5 --- /dev/null +++ b/backend/active_scheduler/candidate/candidate.go @@ -0,0 +1,337 @@ +package candidate + +import ( + "fmt" + "sort" + + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/observe" + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" +) + +type Type string + +const ( + TypeAddTaskPoolToSchedule Type = "add_task_pool_to_schedule" + TypeCreateMakeup Type = "create_makeup" + TypeAskUser Type = "ask_user" + TypeNotifyOnly Type = "notify_only" + TypeClose Type = "close" + TypeCompressWithNextDynamicTask Type = "compress_with_next_dynamic_task" // 预留常量:第一版禁止生成该候选。 +) + +type ChangeType string + +const ( + ChangeTypeAdd ChangeType = "add" + ChangeTypeCreateMakeup ChangeType = "create_makeup" + ChangeTypeAskUser ChangeType = "ask_user" + ChangeTypeNone ChangeType = "none" +) + +// Candidate 是主动调度后端确定性生成的候选。 +// +// 职责边界: +// 1. 只描述可写 preview 的结构化变更或非变更建议; +// 2. 不包含 DAO model,不直接修改正式日程; +// 3. 第一版不会生成 compress_with_next_dynamic_task。 +type Candidate struct { + CandidateID string + CandidateType Type + Title string + Summary string + Target Target + Changes []ChangeItem + BeforeSummary string + AfterSummary string + Risk string + Score int + Validation Validation + Source string +} + +type Target struct { + TargetType string + TargetID int + Title string +} + +type ChangeItem struct { + ChangeType ChangeType + TargetType string + TargetID int + FromSlot *ports.Slot + ToSlot *ports.SlotSpan + DurationSections int + AffectedEventIDs []int + EditedAllowed bool + Metadata map[string]string +} + +type Validation struct { + Valid bool + Reason string +} + +// Generator 负责枚举、校验、排序并截断候选。 +// +// 职责边界: +// 1. 只消费 context 和 observe 结果; +// 2. 不调用 LLM,不写 preview,不发通知; +// 3. 校验失败的候选直接丢弃,避免把合法性判断交给后续选择器。 +type Generator struct{} + +func NewGenerator() *Generator { + return &Generator{} +} + +// GenerateCandidates 执行 dry-run 主链路第三步:生成候选。 +func (g *Generator) GenerateCandidates(ctx *schedulercontext.ActiveScheduleContext, observation observe.Result) []Candidate { + var candidates []Candidate + for _, issue := range observation.Issues { + switch issue.Code { + case observe.IssueTargetCompleted, observe.IssueTargetAlreadyScheduled: + candidates = append(candidates, closeCandidate(ctx, issue)) + case observe.IssueFeedbackTargetUnknown: + candidates = append(candidates, askUserCandidate(ctx, issue, "我还不能确定是哪一个日程块没有完成,需要用户确认目标。")) + case observe.IssueCanAddTaskPoolToSchedule: + if candidate, ok := g.addTaskPoolCandidate(ctx); ok { + candidates = append(candidates, candidate) + } + case observe.IssueNeedMakeupBlock: + if candidate, ok := g.createMakeupCandidate(ctx); ok { + candidates = append(candidates, candidate) + } + case observe.IssueNoFreeSlot, observe.IssueCapacityInsufficient: + candidates = append(candidates, notifyOnlyCandidate(ctx, issue, "当前 24 小时内没有足够空位,第一版不会生成压缩融合候选。")) + case observe.IssueNoValidTimeWindow: + candidates = append(candidates, askUserCandidate(ctx, issue, "缺少必要时间窗或目标事实,需要用户补充后再安排。")) + } + } + return trimCandidates(rankCandidates(validateCandidates(candidates))) +} + +func (g *Generator) addTaskPoolCandidate(ctx *schedulercontext.ActiveScheduleContext) (Candidate, bool) { + needed := ctx.Target.EstimatedSections + if needed <= 0 { + needed = 1 + } + span, ok := firstContiguousFreeSpan(ctx.ScheduleFacts.FreeSlots, needed) + if !ok { + return Candidate{}, false + } + id := fmt.Sprintf("%s:%d:%d:%d:%d", TypeAddTaskPoolToSchedule, ctx.Target.TaskID, span.Start.Week, span.Start.DayOfWeek, span.Start.Section) + return Candidate{ + CandidateID: id, + CandidateType: TypeAddTaskPoolToSchedule, + Title: "加入日程", + Summary: "把重要且紧急任务放入滚动 24 小时内的空闲节次。", + Target: targetFromContext(ctx), + Changes: []ChangeItem{{ + ChangeType: ChangeTypeAdd, + TargetType: string(trigger.TargetTypeTaskPool), + TargetID: ctx.Target.TaskID, + ToSlot: &span, + DurationSections: needed, + EditedAllowed: true, + Metadata: map[string]string{ + "task_source_type": string(trigger.TargetTypeTaskPool), + }, + }}, + BeforeSummary: "任务尚未进入正式日程。", + AfterSummary: "任务将占用第一个可用连续节次块。", + Risk: "仅新增 task_pool 日程块,不移动已有日程。", + Score: 100 - span.Start.Section, + Validation: Validation{Valid: true}, + Source: "backend_deterministic", + }, true +} + +func (g *Generator) createMakeupCandidate(ctx *schedulercontext.ActiveScheduleContext) (Candidate, bool) { + span, ok := firstContiguousFreeSpan(ctx.ScheduleFacts.FreeSlots, 1) + if !ok { + return Candidate{}, false + } + targetID := ctx.FeedbackFacts.TargetEventID + if targetID <= 0 { + targetID = ctx.Trigger.TargetID + } + id := fmt.Sprintf("%s:%d:%d:%d:%d", TypeCreateMakeup, targetID, span.Start.Week, span.Start.DayOfWeek, span.Start.Section) + return Candidate{ + CandidateID: id, + CandidateType: TypeCreateMakeup, + Title: "新增补做块", + Summary: "为未完成的日程块新增一个补做时间,不移动原任务。", + Target: targetFromContext(ctx), + Changes: []ChangeItem{{ + ChangeType: ChangeTypeCreateMakeup, + TargetType: string(trigger.TargetTypeScheduleEvent), + TargetID: targetID, + ToSlot: &span, + DurationSections: 1, + AffectedEventIDs: []int{targetID}, + EditedAllowed: true, + Metadata: map[string]string{ + "makeup_for_event_id": fmt.Sprintf("%d", targetID), + }, + }}, + BeforeSummary: "用户反馈该日程块未完成。", + AfterSummary: "新增 1 节补做块,原日程不移动。", + Risk: "第一版不做局部重排;若补做块仍不合适,需要用户手动调整。", + Score: 90 - span.Start.Section, + Validation: Validation{Valid: true}, + Source: "backend_deterministic", + }, true +} + +func closeCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue) Candidate { + return Candidate{ + CandidateID: fmt.Sprintf("%s:%s:%d", TypeClose, ctx.Trigger.TargetType, ctx.Trigger.TargetID), + CandidateType: TypeClose, + Title: "关闭主动调度", + Summary: issue.Reason, + Target: targetFromContext(ctx), + Changes: []ChangeItem{{ + ChangeType: ChangeTypeNone, + TargetType: string(ctx.Trigger.TargetType), + TargetID: ctx.Trigger.TargetID, + }}, + BeforeSummary: "当前事实已覆盖触发原因。", + AfterSummary: "无需生成预览或通知。", + Risk: "无正式日程变更。", + Score: 0, + Validation: Validation{Valid: true}, + Source: "backend_deterministic", + } +} + +func askUserCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue, summary string) Candidate { + return Candidate{ + CandidateID: fmt.Sprintf("%s:%s:%d", TypeAskUser, issue.Code, ctx.Trigger.TargetID), + CandidateType: TypeAskUser, + Title: "需要用户确认", + Summary: summary, + Target: targetFromContext(ctx), + Changes: []ChangeItem{{ + ChangeType: ChangeTypeAskUser, + TargetType: string(ctx.Trigger.TargetType), + TargetID: ctx.Trigger.TargetID, + }}, + BeforeSummary: "缺少安全生成调整方案所需的事实。", + AfterSummary: "等待用户补充信息后再重新 dry-run。", + Risk: "不会修改正式日程。", + Score: 0, + Validation: Validation{Valid: true}, + Source: "backend_deterministic", + } +} + +func notifyOnlyCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue, summary string) Candidate { + return Candidate{ + CandidateID: fmt.Sprintf("%s:%s:%d", TypeNotifyOnly, issue.Code, ctx.Trigger.TargetID), + CandidateType: TypeNotifyOnly, + Title: "仅提醒", + Summary: summary, + Target: targetFromContext(ctx), + Changes: []ChangeItem{{ + ChangeType: ChangeTypeNone, + TargetType: string(ctx.Trigger.TargetType), + TargetID: ctx.Trigger.TargetID, + }}, + BeforeSummary: "当前窗口没有可安全安排的连续空位。", + AfterSummary: "不生成压缩融合或正式变更。", + Risk: "任务可能继续保持未安排状态。", + Score: 0, + Validation: Validation{Valid: true}, + Source: "backend_deterministic", + } +} + +func targetFromContext(ctx *schedulercontext.ActiveScheduleContext) Target { + return Target{ + TargetType: string(ctx.Trigger.TargetType), + TargetID: ctx.Trigger.TargetID, + Title: ctx.Target.Title, + } +} + +func firstContiguousFreeSpan(slots []ports.Slot, needed int) (ports.SlotSpan, bool) { + if needed <= 0 { + return ports.SlotSpan{}, false + } + sorted := append([]ports.Slot(nil), slots...) + sort.Slice(sorted, func(i, j int) bool { + return slotLess(sorted[i], sorted[j]) + }) + for i := range sorted { + end := i + needed - 1 + if end >= len(sorted) { + break + } + if isContiguous(sorted[i : end+1]) { + return ports.SlotSpan{Start: sorted[i], End: sorted[end], DurationSections: needed}, true + } + } + return ports.SlotSpan{}, false +} + +func isContiguous(slots []ports.Slot) bool { + if len(slots) == 0 { + return false + } + for i := 1; i < len(slots); i++ { + prev := slots[i-1] + curr := slots[i] + if prev.Week != curr.Week || prev.DayOfWeek != curr.DayOfWeek || prev.Section+1 != curr.Section { + return false + } + } + return true +} + +func slotLess(left, right ports.Slot) bool { + if !left.StartAt.IsZero() && !right.StartAt.IsZero() && !left.StartAt.Equal(right.StartAt) { + return left.StartAt.Before(right.StartAt) + } + if left.Week != right.Week { + return left.Week < right.Week + } + if left.DayOfWeek != right.DayOfWeek { + return left.DayOfWeek < right.DayOfWeek + } + return left.Section < right.Section +} + +func validateCandidates(candidates []Candidate) []Candidate { + valid := make([]Candidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.CandidateType == TypeCompressWithNextDynamicTask { + // 1. 压缩融合只作为 schema 预留; + // 2. 第一版 dry-run 禁止生成,防止后续 preview/apply 误认为可以执行。 + continue + } + if candidate.CandidateID == "" || candidate.CandidateType == "" { + continue + } + if candidate.CandidateType == TypeAddTaskPoolToSchedule && len(candidate.Changes) == 0 { + continue + } + valid = append(valid, candidate) + } + return valid +} + +func rankCandidates(candidates []Candidate) []Candidate { + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].Score > candidates[j].Score + }) + return candidates +} + +func trimCandidates(candidates []Candidate) []Candidate { + if len(candidates) <= 3 { + return candidates + } + return candidates[:3] +} diff --git a/backend/active_scheduler/context/builder.go b/backend/active_scheduler/context/builder.go new file mode 100644 index 0000000..566538c --- /dev/null +++ b/backend/active_scheduler/context/builder.go @@ -0,0 +1,220 @@ +package schedulercontext + +import ( + "context" + "errors" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" +) + +// Builder 负责把统一 trigger 转成主动调度只读事实快照。 +// +// 职责边界: +// 1. 只通过 ports 读取外部事实; +// 2. 不生成 candidates,不调用 LLM,不写 preview; +// 3. 缺少业务事实时尽量写入 MissingInfo,让 observe 阶段裁决 ask_user。 +type Builder struct { + readers ports.Readers + clock func() time.Time +} + +func NewBuilder(readers ports.Readers) (*Builder, error) { + if readers.ScheduleReader == nil { + return nil, errors.New("ScheduleReader 不能为空") + } + if readers.TaskReader == nil { + return nil, errors.New("TaskReader 不能为空") + } + if readers.FeedbackReader == nil { + return nil, errors.New("FeedbackReader 不能为空") + } + return &Builder{ + readers: readers, + clock: time.Now, + }, nil +} + +// SetClock 允许测试注入稳定时钟。 +// +// 职责边界: +// 1. 仅影响 real_now; +// 2. 不覆盖 trigger.MockNow 的业务语义; +// 3. nil 会被忽略,避免测试误把时钟置空。 +func (b *Builder) SetClock(clock func() time.Time) { + if clock != nil { + b.clock = clock + } +} + +// BuildContext 执行 dry-run 主链路第一步:构造主动调度上下文。 +func (b *Builder) BuildContext(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*ActiveScheduleContext, error) { + if err := trig.Validate(); err != nil { + return nil, err + } + + realNow := b.clock() + effectiveNow := trig.EffectiveNow(realNow) + windowStart := effectiveNow + windowEnd := effectiveNow.Add(24 * time.Hour) + result := &ActiveScheduleContext{ + Trigger: trig, + User: UserFacts{ + UserID: trig.UserID, + Timezone: effectiveNow.Location().String(), + }, + Now: NowFacts{ + RealNow: realNow, + EffectiveNow: effectiveNow, + }, + Window: WindowFacts{ + StartAt: windowStart, + EndAt: windowEnd, + WindowReason: WindowReasonRolling24H, + }, + Target: TargetFacts{SourceType: trig.TargetType}, + Trace: TraceFacts{ + TraceID: trig.TraceID, + BuildSteps: []string{ + "1. 校验 trigger 并确定 real_now / effective_now。", + "2. 构造滚动 24 小时时间窗,后续读取均基于同一窗口。", + }, + }, + } + + switch trig.TriggerType { + case trigger.TriggerTypeImportantUrgentTask: + if err := b.fillTaskPoolFacts(ctx, result); err != nil { + return nil, err + } + case trigger.TriggerTypeUnfinishedFeedback: + if err := b.fillFeedbackFacts(ctx, result); err != nil { + return nil, err + } + } + + if err := b.fillScheduleFacts(ctx, result); err != nil { + return nil, err + } + b.fillDerivedFacts(result) + return result, nil +} + +func (b *Builder) fillTaskPoolFacts(ctx context.Context, result *ActiveScheduleContext) error { + task, found, err := b.readers.TaskReader.GetTaskForActiveSchedule(ctx, ports.TaskRequest{ + UserID: result.Trigger.UserID, + TaskID: result.Trigger.TargetID, + Now: result.Now.EffectiveNow, + }) + if err != nil { + return err + } + if !found { + result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "target_task") + result.Trace.Warnings = append(result.Trace.Warnings, "未读取到目标 task_pool 任务,后续应转为 ask_user。") + return nil + } + + estimatedSections := task.EstimatedSections + if estimatedSections <= 0 { + // 1. 旧数据可能没有 estimated_sections;MVP 兜底为 1 节,避免空值阻断 dry-run。 + // 2. 正式 adapter 后续应尽量提供真实字段,减少兜底带来的预览偏差。 + estimatedSections = 1 + } + result.TaskPoolFacts.TargetTask = &task + result.Target.TaskID = task.ID + result.Target.Title = task.Title + result.Target.EstimatedSections = estimatedSections + result.Target.DeadlineAt = task.DeadlineAt + result.Target.UrgencyThresholdAt = task.UrgencyThresholdAt + result.Target.Priority = task.Priority + if task.IsCompleted { + result.Target.Status = "completed" + } else { + result.Target.Status = "pending" + } + result.Trace.BuildSteps = append(result.Trace.BuildSteps, "3. 通过 TaskReader 读取 task_pool 目标事实。") + return nil +} + +func (b *Builder) fillFeedbackFacts(ctx context.Context, result *ActiveScheduleContext) error { + feedback, found, err := b.readers.FeedbackReader.GetFeedbackSignal(ctx, ports.FeedbackRequest{ + UserID: result.Trigger.UserID, + FeedbackID: result.Trigger.FeedbackID, + IdempotencyKey: result.Trigger.IdempotencyKey, + TargetType: string(result.Trigger.TargetType), + TargetID: result.Trigger.TargetID, + }) + if err != nil { + return err + } + if !found { + result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "feedback_signal") + result.Trace.Warnings = append(result.Trace.Warnings, "未读取到反馈信号,后续应转为 ask_user。") + return nil + } + + result.FeedbackFacts = FeedbackFacts{ + FeedbackID: feedback.FeedbackID, + FeedbackText: feedback.Text, + TargetKnown: feedback.TargetKnown, + TargetEventID: feedback.TargetEventID, + TargetTaskItemID: feedback.TargetTaskItemID, + FeedbackTarget: feedback.TargetTitle, + } + result.Target.ScheduleEventID = feedback.TargetEventID + result.Target.TaskItemID = feedback.TargetTaskItemID + result.Target.Title = feedback.TargetTitle + result.Target.EstimatedSections = 1 + if !feedback.TargetKnown { + result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "feedback_target") + } + result.Trace.BuildSteps = append(result.Trace.BuildSteps, "3. 通过 FeedbackReader 读取 unfinished_feedback 信号。") + return nil +} + +func (b *Builder) fillScheduleFacts(ctx context.Context, result *ActiveScheduleContext) error { + facts, err := b.readers.ScheduleReader.GetScheduleFactsByWindow(ctx, ports.ScheduleWindowRequest{ + UserID: result.Trigger.UserID, + TargetType: string(result.Trigger.TargetType), + TargetID: result.Trigger.TargetID, + WindowStart: result.Window.StartAt, + WindowEnd: result.Window.EndAt, + Now: result.Now.EffectiveNow, + }) + if err != nil { + return err + } + result.ScheduleFacts = ScheduleFacts{ + Events: facts.Events, + OccupiedSlots: facts.OccupiedSlots, + FreeSlots: facts.FreeSlots, + NextDynamicTask: facts.NextDynamicTask, + } + result.Window.RelativeSlots = append(result.Window.RelativeSlots, facts.OccupiedSlots...) + result.Window.RelativeSlots = append(result.Window.RelativeSlots, facts.FreeSlots...) + result.DerivedFacts.TargetAlreadyScheduled = facts.TargetAlreadyScheduled + result.Trace.BuildSteps = append(result.Trace.BuildSteps, "4. 通过 ScheduleReader 读取滚动 24 小时日程事实。") + return nil +} + +func (b *Builder) fillDerivedFacts(result *ActiveScheduleContext) { + result.DerivedFacts.AvailableCapacity = len(result.ScheduleFacts.FreeSlots) + if result.TaskPoolFacts.TargetTask != nil { + result.DerivedFacts.TargetCompleted = result.TaskPoolFacts.TargetTask.IsCompleted + } + if result.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && !result.FeedbackFacts.TargetKnown { + result.DerivedFacts.MissingInfo = appendMissing(result.DerivedFacts.MissingInfo, "feedback_target") + } + result.Trace.BuildSteps = append(result.Trace.BuildSteps, "5. 汇总完成状态、已排状态、可用容量与缺失事实。") +} + +func appendMissing(values []string, next string) []string { + for _, value := range values { + if value == next { + return values + } + } + return append(values, next) +} diff --git a/backend/active_scheduler/context/context.go b/backend/active_scheduler/context/context.go new file mode 100644 index 0000000..4c68de9 --- /dev/null +++ b/backend/active_scheduler/context/context.go @@ -0,0 +1,94 @@ +package schedulercontext + +import ( + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" +) + +const ( + WindowReasonRolling24H = "rolling_24h" +) + +// ActiveScheduleContext 是主动调度 dry-run 的只读事实快照。 +// +// 职责边界: +// 1. 负责承载 BuildContext 阶段聚合出的事实; +// 2. 不包含 DAO、service 或 provider 实例; +// 3. 不负责生成候选,也不负责写 preview、通知或正式日程。 +type ActiveScheduleContext struct { + Trigger trigger.ActiveScheduleTrigger + User UserFacts + Now NowFacts + Window WindowFacts + Target TargetFacts + TaskPoolFacts TaskPoolFacts + ScheduleFacts ScheduleFacts + FeedbackFacts FeedbackFacts + DerivedFacts DerivedFacts + Trace TraceFacts +} + +type UserFacts struct { + UserID int + Timezone string +} + +type NowFacts struct { + RealNow time.Time + EffectiveNow time.Time +} + +type WindowFacts struct { + StartAt time.Time + EndAt time.Time + RelativeSlots []ports.Slot + WindowReason string +} + +type TargetFacts struct { + SourceType trigger.TargetType + TaskID int + ScheduleEventID int + TaskItemID int + Title string + EstimatedSections int + DeadlineAt *time.Time + UrgencyThresholdAt *time.Time + Priority int + Status string +} + +type TaskPoolFacts struct { + TargetTask *ports.TaskFact +} + +type ScheduleFacts struct { + Events []ports.ScheduleEventFact + OccupiedSlots []ports.Slot + FreeSlots []ports.Slot + NextDynamicTask *ports.ScheduleEventFact +} + +type FeedbackFacts struct { + FeedbackID string + FeedbackText string + FeedbackTarget string + TargetKnown bool + TargetEventID int + TargetTaskItemID int +} + +type DerivedFacts struct { + TargetAlreadyScheduled bool + TargetCompleted bool + AvailableCapacity int + MissingInfo []string +} + +type TraceFacts struct { + TraceID string + BuildSteps []string + Warnings []string +} diff --git a/backend/active_scheduler/observe/observe.go b/backend/active_scheduler/observe/observe.go new file mode 100644 index 0000000..cb2f8ec --- /dev/null +++ b/backend/active_scheduler/observe/observe.go @@ -0,0 +1,293 @@ +package observe + +import ( + "time" + + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" +) + +type DecisionAction string + +const ( + DecisionActionClose DecisionAction = "close" + DecisionActionAskUser DecisionAction = "ask_user" + DecisionActionNotifyOnly DecisionAction = "notify_only" + DecisionActionSelectCandidate DecisionAction = "select_candidate" +) + +type IssueCode string + +const ( + IssueTargetCompleted IssueCode = "target_completed" + IssueTargetAlreadyScheduled IssueCode = "target_already_scheduled" + IssueNoValidTimeWindow IssueCode = "no_valid_time_window" + IssueCapacityInsufficient IssueCode = "capacity_insufficient" + IssueNoFreeSlot IssueCode = "no_free_slot" + IssueFeedbackTargetUnknown IssueCode = "feedback_target_unknown" + IssueNeedMakeupBlock IssueCode = "need_makeup_block" + IssueCanAddTaskPoolToSchedule IssueCode = "can_add_task_pool_to_schedule" + IssueCanCompressWithNextDynamicTask IssueCode = "can_compress_with_next_dynamic_task" +) + +// Metrics 是主动观测阶段输出的事实指标。 +type Metrics struct { + Target TargetMetrics + Window WindowMetrics + Feedback FeedbackMetrics + Risk RiskMetrics +} + +type TargetMetrics struct { + Completed bool + AlreadyScheduled bool + DeadlineAlreadyPassed bool + MinutesToDeadline int + EstimatedSections int +} + +type WindowMetrics struct { + TotalSlots int + FreeSlots int + OccupiedSlots int + UsableSlotsBeforeDeadline int + CapacityGap int +} + +type FeedbackMetrics struct { + HasFeedback bool + FeedbackTargetKnown bool + UnfinishedElapsedMinutes int +} + +type RiskMetrics struct { + ConflictCount int + AffectedEventCount int + AffectedTaskCount int + RequiresReorder bool +} + +type Issue struct { + IssueID string + Code IssueCode + Severity string + TargetType string + TargetID int + Reason string + Evidence map[string]string + CanGenerateCandidate bool +} + +type Decision struct { + Action DecisionAction + ReasonCode string + PrimaryIssueCode IssueCode + ShouldNotify bool + ShouldWritePreview bool + LLMSelectionRequired bool + FallbackCandidateID string +} + +type Result struct { + Metrics Metrics + Issues []Issue + Decision Decision + Trace []string +} + +// Analyzer 负责把 ActiveScheduleContext 转成确定性观测结果。 +// +// 职责边界: +// 1. 只生成 metrics / issues / 初步 decision; +// 2. 不枚举候选,不调用 LLM; +// 3. 候选生成后由 FinalizeDecision 根据候选数量收口最终 action。 +type Analyzer struct{} + +func NewAnalyzer() *Analyzer { + return &Analyzer{} +} + +// Observe 执行主动观测。 +func (a *Analyzer) Observe(ctx *schedulercontext.ActiveScheduleContext) Result { + result := Result{ + Metrics: buildMetrics(ctx), + Trace: []string{ + "1. 基于上下文构造 metrics,保证后续裁决只依赖结构化事实。", + "2. 按触发类型检测 issue,不在观测阶段修改正式日程。", + }, + } + result.Issues = detectIssues(ctx, result.Metrics) + result.Decision = provisionalDecision(result.Issues) + return result +} + +// FinalizeDecision 根据候选生成结果收口最终裁决。 +// +// 职责边界: +// 1. 只根据后端已生成、已校验的候选收口 decision; +// 2. 不改变候选内容; +// 3. 候选为空时不能写 preview,必须降级为 ask_user / notify_only / close。 +func (a *Analyzer) FinalizeDecision(result Result, candidateCount int, fallbackCandidateID string) Result { + if len(result.Issues) == 0 { + result.Decision = Decision{Action: DecisionActionClose, ReasonCode: "no_issue"} + return result + } + primary := result.Issues[0].Code + if hasIssue(result.Issues, IssueTargetCompleted) || hasIssue(result.Issues, IssueTargetAlreadyScheduled) { + result.Decision = Decision{Action: DecisionActionClose, ReasonCode: string(primary), PrimaryIssueCode: primary} + return result + } + if hasIssue(result.Issues, IssueFeedbackTargetUnknown) || hasIssue(result.Issues, IssueNoValidTimeWindow) { + result.Decision = Decision{Action: DecisionActionAskUser, ReasonCode: string(primary), PrimaryIssueCode: primary, ShouldNotify: true} + return result + } + if candidateCount > 0 { + result.Decision = Decision{ + Action: DecisionActionSelectCandidate, + ReasonCode: "candidate_available", + PrimaryIssueCode: primary, + ShouldNotify: true, + ShouldWritePreview: true, + LLMSelectionRequired: true, + FallbackCandidateID: fallbackCandidateID, + } + return result + } + result.Decision = Decision{Action: DecisionActionNotifyOnly, ReasonCode: string(primary), PrimaryIssueCode: primary, ShouldNotify: true} + return result +} + +func buildMetrics(ctx *schedulercontext.ActiveScheduleContext) Metrics { + estimated := ctx.Target.EstimatedSections + if estimated <= 0 { + estimated = 1 + } + usable := countUsableSlots(ctx) + deadlinePassed := false + minutesToDeadline := 0 + if ctx.Target.DeadlineAt != nil { + deadlinePassed = ctx.Target.DeadlineAt.Before(ctx.Now.EffectiveNow) + minutesToDeadline = int(ctx.Target.DeadlineAt.Sub(ctx.Now.EffectiveNow).Minutes()) + } + return Metrics{ + Target: TargetMetrics{ + Completed: ctx.DerivedFacts.TargetCompleted, + AlreadyScheduled: ctx.DerivedFacts.TargetAlreadyScheduled, + DeadlineAlreadyPassed: deadlinePassed, + MinutesToDeadline: minutesToDeadline, + EstimatedSections: estimated, + }, + Window: WindowMetrics{ + TotalSlots: len(ctx.ScheduleFacts.FreeSlots) + len(ctx.ScheduleFacts.OccupiedSlots), + FreeSlots: len(ctx.ScheduleFacts.FreeSlots), + OccupiedSlots: len(ctx.ScheduleFacts.OccupiedSlots), + UsableSlotsBeforeDeadline: usable, + CapacityGap: estimated - usable, + }, + Feedback: FeedbackMetrics{ + HasFeedback: ctx.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && ctx.FeedbackFacts.FeedbackID != "", + FeedbackTargetKnown: ctx.FeedbackFacts.TargetKnown, + }, + Risk: RiskMetrics{ + AffectedEventCount: len(ctx.ScheduleFacts.Events), + RequiresReorder: ctx.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && len(ctx.ScheduleFacts.FreeSlots) == 0, + }, + } +} + +func countUsableSlots(ctx *schedulercontext.ActiveScheduleContext) int { + if ctx.Target.DeadlineAt == nil { + return len(ctx.ScheduleFacts.FreeSlots) + } + count := 0 + for _, slot := range ctx.ScheduleFacts.FreeSlots { + if slot.StartAt.IsZero() || !slot.StartAt.After(*ctx.Target.DeadlineAt) { + count++ + } + } + return count +} + +func detectIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue { + switch ctx.Trigger.TriggerType { + case trigger.TriggerTypeImportantUrgentTask: + return detectImportantUrgentIssues(ctx, metrics) + case trigger.TriggerTypeUnfinishedFeedback: + return detectUnfinishedFeedbackIssues(ctx, metrics) + default: + return []Issue{} + } +} + +func detectImportantUrgentIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue { + if metrics.Target.Completed { + return []Issue{newIssue(IssueTargetCompleted, ctx, "目标任务已完成,主动调度无需继续处理。", false)} + } + if metrics.Target.AlreadyScheduled { + return []Issue{newIssue(IssueTargetAlreadyScheduled, ctx, "目标任务已经进入日程,不能重复加入 task_pool。", false)} + } + if len(ctx.DerivedFacts.MissingInfo) > 0 { + return []Issue{newIssue(IssueNoValidTimeWindow, ctx, "缺少目标任务或时间窗事实,需要用户补充信息。", false)} + } + if metrics.Window.FreeSlots == 0 { + return []Issue{newIssue(IssueNoFreeSlot, ctx, "滚动 24 小时内没有可用节次。", false)} + } + if metrics.Window.CapacityGap > 0 { + return []Issue{newIssue(IssueCapacityInsufficient, ctx, "可用节次不足以完整放入目标任务。", false)} + } + return []Issue{newIssue(IssueCanAddTaskPoolToSchedule, ctx, "目标任务可加入滚动 24 小时内的空闲节次。", true)} +} + +func detectUnfinishedFeedbackIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue { + if !metrics.Feedback.HasFeedback || !metrics.Feedback.FeedbackTargetKnown { + return []Issue{newIssue(IssueFeedbackTargetUnknown, ctx, "无法确定用户反馈的未完成日程块,需要进一步确认。", false)} + } + if metrics.Window.FreeSlots == 0 { + return []Issue{newIssue(IssueNoFreeSlot, ctx, "反馈目标已定位,但滚动 24 小时内没有补做空位。", false)} + } + return []Issue{newIssue(IssueNeedMakeupBlock, ctx, "反馈目标已定位,可生成新增补做块候选。", true)} +} + +func newIssue(code IssueCode, ctx *schedulercontext.ActiveScheduleContext, reason string, canGenerate bool) Issue { + return Issue{ + IssueID: string(code) + ":1", + Code: code, + Severity: issueSeverity(code), + TargetType: string(ctx.Trigger.TargetType), + TargetID: ctx.Trigger.TargetID, + Reason: reason, + Evidence: map[string]string{ + "trigger_type": string(ctx.Trigger.TriggerType), + "window_start": ctx.Window.StartAt.Format(time.RFC3339), + "window_end": ctx.Window.EndAt.Format(time.RFC3339), + }, + CanGenerateCandidate: canGenerate, + } +} + +func issueSeverity(code IssueCode) string { + switch code { + case IssueTargetCompleted, IssueTargetAlreadyScheduled: + return "info" + case IssueFeedbackTargetUnknown, IssueNoValidTimeWindow: + return "warning" + default: + return "critical" + } +} + +func provisionalDecision(issues []Issue) Decision { + if len(issues) == 0 { + return Decision{Action: DecisionActionClose, ReasonCode: "no_issue"} + } + return Decision{Action: DecisionActionNotifyOnly, ReasonCode: "pending_candidates", PrimaryIssueCode: issues[0].Code} +} + +func hasIssue(issues []Issue, code IssueCode) bool { + for _, issue := range issues { + if issue.Code == code { + return true + } + } + return false +} diff --git a/backend/active_scheduler/ports/facts.go b/backend/active_scheduler/ports/facts.go new file mode 100644 index 0000000..562493d --- /dev/null +++ b/backend/active_scheduler/ports/facts.go @@ -0,0 +1,132 @@ +package ports + +import ( + "context" + "time" +) + +// Slot 是主动调度内部使用的原子节次坐标。 +// +// 职责边界: +// 1. 只描述可比较、可落预览的时间格; +// 2. 不绑定 schedules 表模型; +// 3. StartAt / EndAt 可为空值,排序会退回到 week/day/section。 +type Slot struct { + Week int + DayOfWeek int + Section int + StartAt time.Time + EndAt time.Time +} + +// SlotSpan 表示一个连续节次块。 +type SlotSpan struct { + Start Slot + End Slot + DurationSections int +} + +// TaskFact 是 task_pool 任务在主动调度里的最小事实快照。 +type TaskFact struct { + ID int + UserID int + Title string + Priority int + IsCompleted bool + DeadlineAt *time.Time + UrgencyThresholdAt *time.Time + EstimatedSections int +} + +// ScheduleEventFact 是日程块在主动调度里的最小事实快照。 +type ScheduleEventFact struct { + ID int + UserID int + Title string + SourceType string + RelID int + IsDynamicTask bool + IsCompleted bool + Slots []Slot + TaskClassID int + TaskItemID int + CanBeShortened bool +} + +// ScheduleWindowFacts 是滚动窗口内日程事实快照。 +type ScheduleWindowFacts struct { + Events []ScheduleEventFact + OccupiedSlots []Slot + FreeSlots []Slot + NextDynamicTask *ScheduleEventFact + TargetAlreadyScheduled bool +} + +// FeedbackFact 是 unfinished_feedback 的最小事实快照。 +type FeedbackFact struct { + FeedbackID string + Text string + TargetKnown bool + TargetEventID int + TargetTaskItemID int + TargetTitle string + SubmittedAt time.Time +} + +// TaskRequest 是任务读取端口的入参。 +type TaskRequest struct { + UserID int + TaskID int + Now time.Time +} + +// ScheduleWindowRequest 是日程窗口读取端口的入参。 +type ScheduleWindowRequest struct { + UserID int + TargetType string + TargetID int + WindowStart time.Time + WindowEnd time.Time + Now time.Time +} + +// FeedbackRequest 是反馈读取端口的入参。 +type FeedbackRequest struct { + UserID int + FeedbackID string + IdempotencyKey string + TargetType string + TargetID int +} + +// TaskReader 负责读取主动调度所需的 task_pool 事实。 +// +// 职责边界: +// 1. 可以由 adapter 调用既有 service / DAO 组装事实; +// 2. active_scheduler 主链路只依赖该端口,不直接 import 其它领域 DAO; +// 3. found=false 表示目标不存在或当前用户无权访问,由观察链路转成 ask_user。 +type TaskReader interface { + GetTaskForActiveSchedule(ctx context.Context, req TaskRequest) (task TaskFact, found bool, err error) +} + +// ScheduleReader 负责读取滚动时间窗内的日程事实。 +type ScheduleReader interface { + GetScheduleFactsByWindow(ctx context.Context, req ScheduleWindowRequest) (ScheduleWindowFacts, error) +} + +// FeedbackReader 负责读取用户反馈信号。 +type FeedbackReader interface { + GetFeedbackSignal(ctx context.Context, req FeedbackRequest) (feedback FeedbackFact, found bool, err error) +} + +// Readers 聚合 dry-run 主链路依赖的外部读取端口。 +// +// 职责边界: +// 1. 只聚合读取依赖,不包含正式写入 preview / schedule / notification 的能力; +// 2. 便于 API、worker 和测试使用同一套 dry-run service; +// 3. 任一必需端口为空时,由 service 初始化阶段拒绝。 +type Readers struct { + TaskReader TaskReader + ScheduleReader ScheduleReader + FeedbackReader FeedbackReader +} diff --git a/backend/active_scheduler/preview/converter.go b/backend/active_scheduler/preview/converter.go new file mode 100644 index 0000000..d89c0ac --- /dev/null +++ b/backend/active_scheduler/preview/converter.go @@ -0,0 +1,358 @@ +package preview + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/candidate" + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/observe" + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/model" +) + +func candidateDTO(item candidate.Candidate) CandidateDTO { + return CandidateDTO{ + CandidateID: item.CandidateID, + CandidateType: string(item.CandidateType), + Title: item.Title, + Summary: item.Summary, + Target: CandidateTargetDTO{ + TargetType: item.Target.TargetType, + TargetID: item.Target.TargetID, + Title: item.Target.Title, + }, + Changes: changeDTOs(item.CandidateID, item.Changes), + BeforeSummary: item.BeforeSummary, + AfterSummary: item.AfterSummary, + Risk: item.Risk, + Score: item.Score, + Validation: item.Validation, + Source: item.Source, + } +} + +func changeDTOs(candidateID string, changes []candidate.ChangeItem) []ActiveScheduleChangeItem { + result := make([]ActiveScheduleChangeItem, 0, len(changes)) + for index, change := range changes { + var fromSlot *SlotDTO + if change.FromSlot != nil { + value := slotDTO(*change.FromSlot) + fromSlot = &value + } + var toSlot *SlotSpanDTO + if change.ToSlot != nil { + value := slotSpanDTO(*change.ToSlot) + toSlot = &value + } + result = append(result, ActiveScheduleChangeItem{ + ChangeID: fmt.Sprintf("%s:chg_%d", candidateID, index+1), + ChangeType: string(change.ChangeType), + TargetType: change.TargetType, + TargetID: change.TargetID, + FromSlot: fromSlot, + ToSlot: toSlot, + DurationSections: change.DurationSections, + AffectedEventIDs: append([]int(nil), change.AffectedEventIDs...), + EditedAllowed: change.EditedAllowed, + Metadata: copyStringMap(change.Metadata), + }) + } + return result +} + +func contextSummaryDTO(activeContext *schedulercontext.ActiveScheduleContext) ContextSummaryDTO { + if activeContext == nil { + return ContextSummaryDTO{} + } + return ContextSummaryDTO{ + UserID: activeContext.User.UserID, + Timezone: activeContext.User.Timezone, + TriggerSource: string(activeContext.Trigger.Source), + RequestedAt: activeContext.Trigger.RequestedAt, + WindowStart: activeContext.Window.StartAt, + WindowEnd: activeContext.Window.EndAt, + WindowReason: activeContext.Window.WindowReason, + TargetType: string(activeContext.Trigger.TargetType), + TargetID: activeContext.Trigger.TargetID, + TargetTitle: activeContext.Target.Title, + MissingInfo: append([]string(nil), activeContext.DerivedFacts.MissingInfo...), + TraceSteps: append([]string(nil), activeContext.Trace.BuildSteps...), + Warnings: append([]string(nil), activeContext.Trace.Warnings...), + } +} + +func buildBeforeSummary(activeContext *schedulercontext.ActiveScheduleContext, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion { + version := SchedulePreviewVersion{ + Title: "调整前", + WindowStart: activeContext.Window.StartAt, + WindowEnd: activeContext.Window.EndAt, + SummaryLines: compactLines(selected.BeforeSummary), + } + affected := affectedEventSet(changes) + for _, event := range activeContext.ScheduleFacts.Events { + entry := entryFromEvent(event) + if affected[event.ID] || (selected.Target.TargetType == "schedule_event" && selected.Target.TargetID == event.ID) { + entry.Status = "affected" + } + version.Entries = append(version.Entries, entry) + } + return version +} + +func buildAfterSummary(before SchedulePreviewVersion, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion { + after := SchedulePreviewVersion{ + Title: "调整后", + WindowStart: before.WindowStart, + WindowEnd: before.WindowEnd, + Entries: append([]SchedulePreviewEntry(nil), before.Entries...), + SummaryLines: compactLines(selected.AfterSummary), + } + for _, change := range changes { + // 1. 只把会产生可视化新块的 change 追加到 after;ask_user / none 不伪造正式日程。 + // 2. 该 entry 仅用于展示和后续 confirm 校验输入,不代表已经写入 schedule_events / schedules。 + if change.ToSlot == nil || (change.ChangeType != string(candidate.ChangeTypeAdd) && change.ChangeType != string(candidate.ChangeTypeCreateMakeup)) { + continue + } + after.Entries = append(after.Entries, SchedulePreviewEntry{ + EntryID: "preview:" + change.ChangeID, + SourceType: change.TargetType, + SourceID: change.TargetID, + Title: selected.Target.Title, + StartAt: change.ToSlot.Start.StartAt, + EndAt: change.ToSlot.End.EndAt, + Week: change.ToSlot.Start.Week, + DayOfWeek: change.ToSlot.Start.DayOfWeek, + SectionFrom: change.ToSlot.Start.Section, + SectionTo: change.ToSlot.End.Section, + Status: "added", + Editable: change.EditedAllowed, + }) + } + return after +} + +func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry { + slots := append([]ports.Slot(nil), event.Slots...) + sort.Slice(slots, func(i, j int) bool { + if !slots[i].StartAt.IsZero() && !slots[j].StartAt.IsZero() && !slots[i].StartAt.Equal(slots[j].StartAt) { + return slots[i].StartAt.Before(slots[j].StartAt) + } + if slots[i].Week != slots[j].Week { + return slots[i].Week < slots[j].Week + } + if slots[i].DayOfWeek != slots[j].DayOfWeek { + return slots[i].DayOfWeek < slots[j].DayOfWeek + } + return slots[i].Section < slots[j].Section + }) + + entry := SchedulePreviewEntry{ + EntryID: fmt.Sprintf("%s:%d", event.SourceType, event.ID), + SourceType: event.SourceType, + SourceID: event.ID, + Title: event.Title, + Status: "unchanged", + Editable: event.IsDynamicTask, + } + if len(slots) == 0 { + return entry + } + first := slots[0] + last := slots[len(slots)-1] + entry.StartAt = first.StartAt + entry.EndAt = last.EndAt + entry.Week = first.Week + entry.DayOfWeek = first.DayOfWeek + entry.SectionFrom = first.Section + entry.SectionTo = last.Section + return entry +} + +func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem) RiskDTO { + affectedIDs := make([]int, 0) + seen := make(map[int]bool) + for _, change := range changes { + for _, id := range change.AffectedEventIDs { + if !seen[id] { + seen[id] = true + affectedIDs = append(affectedIDs, id) + } + } + } + level := "low" + if !selected.Validation.Valid { + level = "high" + } else if observation.Metrics.Risk.RequiresReorder || len(affectedIDs) > 0 { + level = "medium" + } + return RiskDTO{ + Level: level, + Summary: selected.Risk, + Validation: selected.Validation, + RiskMetrics: observation.Metrics.Risk, + AffectedIDs: affectedIDs, + RequiresLLM: observation.Decision.LLMSelectionRequired, + FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID, + } +} + +func buildBaseVersion(activeContext *schedulercontext.ActiveScheduleContext, changes []ActiveScheduleChangeItem) string { + type eventVersion struct { + ID int `json:"id"` + Slots []SlotDTO `json:"slots"` + } + events := make([]eventVersion, 0, len(activeContext.ScheduleFacts.Events)) + for _, event := range activeContext.ScheduleFacts.Events { + slots := make([]SlotDTO, 0, len(event.Slots)) + for _, slot := range event.Slots { + slots = append(slots, slotDTO(slot)) + } + events = append(events, eventVersion{ID: event.ID, Slots: slots}) + } + payload := struct { + UserID int `json:"user_id"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + WindowStart time.Time `json:"window_start"` + WindowEnd time.Time `json:"window_end"` + Events []eventVersion `json:"events"` + Changes []ActiveScheduleChangeItem `json:"changes"` + }{ + UserID: activeContext.User.UserID, + TargetType: string(activeContext.Trigger.TargetType), + TargetID: activeContext.Trigger.TargetID, + WindowStart: activeContext.Window.StartAt, + WindowEnd: activeContext.Window.EndAt, + Events: events, + Changes: changes, + } + raw, _ := json.Marshal(payload) + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func detailFromModel(row *model.ActiveSchedulePreview, now time.Time) (ActiveSchedulePreviewDetail, error) { + selected, err := decodeJSONField(row.SelectedCandidateJSON, CandidateDTO{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + candidates, err := decodeJSONField(row.CandidatesJSON, []CandidateDTO{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + decision, err := decodeJSONField(row.DecisionJSON, observe.Decision{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + metrics, err := decodeJSONField(row.MetricsJSON, observe.Metrics{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + issues, err := decodeJSONField(row.IssuesJSON, []observe.Issue{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + contextSummary, err := decodeJSONField(row.ContextSummaryJSON, ContextSummaryDTO{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + before, err := decodeJSONField(row.BeforeSummaryJSON, SchedulePreviewVersion{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + changes, err := decodeJSONField(row.PreviewChangesJSON, []ActiveScheduleChangeItem{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + after, err := decodeJSONField(row.AfterSummaryJSON, SchedulePreviewVersion{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + risk, err := decodeJSONField(row.RiskJSON, RiskDTO{}) + if err != nil { + return ActiveSchedulePreviewDetail{}, err + } + + expired := !row.ExpiresAt.After(now) + canConfirm := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired + canIgnore := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired + + return ActiveSchedulePreviewDetail{ + PreviewID: row.ID, + Status: row.Status, + ApplyStatus: row.ApplyStatus, + ExpiresAt: row.ExpiresAt, + GeneratedAt: row.GeneratedAt, + Expired: expired, + Trigger: PreviewTriggerDTO{ + TriggerID: row.TriggerID, + TriggerType: row.TriggerType, + Source: contextSummary.TriggerSource, + TargetType: row.TargetType, + TargetID: row.TargetID, + RequestedAt: contextSummary.RequestedAt, + }, + Explanation: row.ExplanationText, + Notification: row.NotificationSummary, + SelectedCandidate: selected, + Candidates: candidates, + Decision: decision, + Metrics: metrics, + Issues: issues, + ContextSummary: contextSummary, + Before: before, + After: after, + Changes: changes, + Risk: risk, + BaseVersion: row.BaseVersion, + CanConfirm: canConfirm, + CanIgnore: canIgnore, + TraceID: row.TraceID, + }, nil +} + +func jsonString(value any) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(raw), nil +} + +func compactLines(lines ...string) []string { + result := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result +} + +func affectedEventSet(changes []ActiveScheduleChangeItem) map[int]bool { + result := make(map[int]bool) + for _, change := range changes { + for _, id := range change.AffectedEventIDs { + result[id] = true + } + } + return result +} + +func copyStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + output := make(map[string]string, len(input)) + for key, value := range input { + output[key] = value + } + return output +} diff --git a/backend/active_scheduler/preview/dto.go b/backend/active_scheduler/preview/dto.go new file mode 100644 index 0000000..84b1dc7 --- /dev/null +++ b/backend/active_scheduler/preview/dto.go @@ -0,0 +1,232 @@ +package preview + +import ( + "encoding/json" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/candidate" + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/observe" + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" +) + +// CreatePreviewRequest 是把 dry-run 结果固化成主动调度预览的请求 DTO。 +// +// 职责边界: +// 1. 负责承载 preview 写库所需的 dry-run 结果与可选覆盖字段; +// 2. 不承载 confirm/apply 请求,也不允许调用方传入正式日程写入参数; +// 3. GeneratedAt 为空时由 Service 时钟生成,ExpiresAt 固定由 generated_at + 1h 推导。 +type CreatePreviewRequest struct { + ActiveContext *schedulercontext.ActiveScheduleContext `json:"-"` + Observation observe.Result `json:"-"` + Candidates []candidate.Candidate `json:"-"` + PreviewID string `json:"preview_id,omitempty"` + TriggerID string `json:"trigger_id,omitempty"` + BaseVersion string `json:"base_version,omitempty"` + GeneratedAt time.Time `json:"generated_at,omitempty"` + ExplanationText string `json:"explanation_text,omitempty"` + NotificationSummary string `json:"notification_summary,omitempty"` +} + +// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。 +type CreatePreviewResponse struct { + Detail ActiveSchedulePreviewDetail `json:"detail"` +} + +// GetPreviewRequest 是查询 preview 详情的请求 DTO。 +// +// 职责边界: +// 1. UserID 来自鉴权上下文,不能信任前端透传; +// 2. PreviewID 来自路由参数; +// 3. 查询只返回预览快照,不执行过期状态回写、不触发 apply。 +type GetPreviewRequest struct { + UserID int `json:"user_id"` + PreviewID string `json:"preview_id"` +} + +// ActiveSchedulePreviewDetail 是主动调度预览详情页响应 DTO。 +// +// 职责边界: +// 1. 负责把 active_schedule_previews 中的 JSON 快照还原成前端可展示结构; +// 2. 不包含正式写日程能力,不代表 confirm 请求已通过校验; +// 3. CanConfirm 只表达当前快照状态可发起确认,最终是否能应用仍由 confirm/apply 链路重校验。 +type ActiveSchedulePreviewDetail struct { + PreviewID string `json:"preview_id"` + Status string `json:"status"` + ApplyStatus string `json:"apply_status"` + ExpiresAt time.Time `json:"expires_at"` + GeneratedAt time.Time `json:"generated_at"` + Expired bool `json:"expired"` + Trigger PreviewTriggerDTO `json:"trigger"` + Explanation string `json:"explanation"` + Notification string `json:"notification_summary"` + SelectedCandidate CandidateDTO `json:"selected_candidate"` + Candidates []CandidateDTO `json:"candidates"` + Decision observe.Decision `json:"decision"` + Metrics observe.Metrics `json:"metrics"` + Issues []observe.Issue `json:"issues"` + ContextSummary ContextSummaryDTO `json:"context_summary"` + Before SchedulePreviewVersion `json:"before"` + After SchedulePreviewVersion `json:"after"` + Changes []ActiveScheduleChangeItem `json:"changes"` + Risk RiskDTO `json:"risk"` + BaseVersion string `json:"base_version"` + CanConfirm bool `json:"can_confirm"` + CanIgnore bool `json:"can_ignore"` + TraceID string `json:"trace_id"` +} + +type PreviewTriggerDTO struct { + TriggerID string `json:"trigger_id"` + TriggerType string `json:"trigger_type"` + Source string `json:"source"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + RequestedAt time.Time `json:"requested_at"` +} + +type CandidateDTO struct { + CandidateID string `json:"candidate_id"` + CandidateType string `json:"candidate_type"` + Title string `json:"title"` + Summary string `json:"summary"` + Target CandidateTargetDTO `json:"target"` + Changes []ActiveScheduleChangeItem `json:"changes"` + BeforeSummary string `json:"before_summary"` + AfterSummary string `json:"after_summary"` + Risk string `json:"risk"` + Score int `json:"score"` + Validation candidate.Validation `json:"validation"` + Source string `json:"source"` +} + +type CandidateTargetDTO struct { + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + Title string `json:"title"` +} + +type ContextSummaryDTO struct { + UserID int `json:"user_id"` + Timezone string `json:"timezone"` + TriggerSource string `json:"trigger_source"` + RequestedAt time.Time `json:"requested_at"` + WindowStart time.Time `json:"window_start"` + WindowEnd time.Time `json:"window_end"` + WindowReason string `json:"window_reason"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + TargetTitle string `json:"target_title"` + MissingInfo []string `json:"missing_info"` + TraceSteps []string `json:"trace_steps"` + Warnings []string `json:"warnings"` +} + +type SchedulePreviewVersion struct { + Title string `json:"title"` + WindowStart time.Time `json:"window_start"` + WindowEnd time.Time `json:"window_end"` + Entries []SchedulePreviewEntry `json:"entries"` + SummaryLines []string `json:"summary_lines"` +} + +type SchedulePreviewEntry struct { + EntryID string `json:"entry_id"` + SourceType string `json:"source_type"` + SourceID int `json:"source_id"` + Title string `json:"title"` + StartAt time.Time `json:"start_at,omitempty"` + EndAt time.Time `json:"end_at,omitempty"` + Week int `json:"week,omitempty"` + DayOfWeek int `json:"day_of_week,omitempty"` + SectionFrom int `json:"section_from,omitempty"` + SectionTo int `json:"section_to,omitempty"` + Status string `json:"status"` + Editable bool `json:"editable"` +} + +type ActiveScheduleChangeItem struct { + ChangeID string `json:"change_id"` + ChangeType string `json:"change_type"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + FromSlot *SlotDTO `json:"from_slot,omitempty"` + ToSlot *SlotSpanDTO `json:"to_slot,omitempty"` + DurationSections int `json:"duration_sections"` + AffectedEventIDs []int `json:"affected_event_ids"` + EditedAllowed bool `json:"edited_allowed"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type SlotDTO struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + Section int `json:"section"` + StartAt time.Time `json:"start_at,omitempty"` + EndAt time.Time `json:"end_at,omitempty"` +} + +type SlotSpanDTO struct { + Start SlotDTO `json:"start"` + End SlotDTO `json:"end"` + DurationSections int `json:"duration_sections"` +} + +type RiskDTO struct { + Level string `json:"level"` + Summary string `json:"summary"` + Validation candidate.Validation `json:"validation"` + RiskMetrics observe.RiskMetrics `json:"risk_metrics"` + AffectedIDs []int `json:"affected_event_ids"` + RequiresLLM bool `json:"requires_llm"` + FallbackUsed bool `json:"fallback_used"` +} + +// rawPreviewSnapshot 聚合需要写入 active_schedule_previews JSON 字段的快照。 +type rawPreviewSnapshot struct { + selectedCandidate CandidateDTO + candidates []CandidateDTO + decision observe.Decision + metrics observe.Metrics + issues []observe.Issue + contextSummary ContextSummaryDTO + before SchedulePreviewVersion + changes []ActiveScheduleChangeItem + after SchedulePreviewVersion + risk RiskDTO +} + +func slotDTO(slot ports.Slot) SlotDTO { + return SlotDTO{ + Week: slot.Week, + DayOfWeek: slot.DayOfWeek, + Section: slot.Section, + StartAt: slot.StartAt, + EndAt: slot.EndAt, + } +} + +func slotSpanDTO(span ports.SlotSpan) SlotSpanDTO { + return SlotSpanDTO{ + Start: slotDTO(span.Start), + End: slotDTO(span.End), + DurationSections: span.DurationSections, + } +} + +// decodeJSONField 只负责 preview 包内部 DTO 解码。 +// +// 说明: +// 1. 当前任务限制只允许修改 preview 目录,不能把 JSON helper 下沉到公共层; +// 2. 因此这里暂时保留包内小函数,后续若第二个 active_scheduler 子包也需要同类能力,再按 AGENTS 规则抽公共层; +// 3. 解码失败返回原始错误,避免把损坏快照静默展示给用户。 +func decodeJSONField[T any](raw *string, fallback T) (T, error) { + if raw == nil || *raw == "" { + return fallback, nil + } + var value T + if err := json.Unmarshal([]byte(*raw), &value); err != nil { + return fallback, err + } + return value, nil +} diff --git a/backend/active_scheduler/preview/service.go b/backend/active_scheduler/preview/service.go new file mode 100644 index 0000000..836a6ef --- /dev/null +++ b/backend/active_scheduler/preview/service.go @@ -0,0 +1,281 @@ +package preview + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/candidate" + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/observe" + "github.com/LoveLosita/smartflow/backend/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ( + ErrInvalidPreviewRequest = errors.New("主动调度预览请求不合法") + ErrPreviewNotFound = errors.New("主动调度预览不存在") +) + +// Repository 是 preview service 依赖的最小持久化端口。 +// +// 职责边界: +// 1. 只覆盖本轮 preview 写入和详情查询需要的方法; +// 2. 不暴露正式日程写入、通知投递或 confirm apply 能力; +// 3. 现有 dao.ActiveScheduleDAO 已满足该接口,后续迁移独立 repo 时可并行替换实现。 +type Repository interface { + CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error + GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error) +} + +// Service 负责主动调度 preview 的写入和查询。 +// +// 职责边界: +// 1. 将 dry-run 结果固化为 active_schedule_previews 中的 ready 快照; +// 2. 查询时校验 user_id,并返回 API 可直接透传的详情 DTO; +// 3. 不正式写日程、不发通知、不处理 confirm/apply,也不修改 trigger 状态。 +type Service struct { + repo Repository + clock func() time.Time +} + +func NewService(repo Repository) (*Service, error) { + if repo == nil { + return nil, fmt.Errorf("%w: preview repository 不能为空", ErrInvalidPreviewRequest) + } + return &Service{repo: repo, clock: time.Now}, nil +} + +// SetClock 注入测试时钟。 +// +// 职责边界: +// 1. 只影响 generated_at / expires_at 和查询时的 expired 计算; +// 2. 不改写 dry-run 上下文中的业务当前时间; +// 3. clock 为空时保持原时钟,避免运行期误注入导致 panic。 +func (s *Service) SetClock(clock func() time.Time) { + if s == nil || clock == nil { + return + } + s.clock = clock +} + +// CreatePreview 把 dry-run 结果保存为 ready preview。 +// +// 职责边界: +// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实; +// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate; +// 3. 写库后只返回详情 DTO,不发布通知、不正式应用候选、不回写 trigger。 +func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) { + if s == nil || s.repo == nil { + return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest) + } + if req.ActiveContext == nil { + return nil, fmt.Errorf("%w: dry-run 结果不能为空", ErrInvalidPreviewRequest) + } + if len(req.Candidates) == 0 { + return nil, fmt.Errorf("%w: dry-run 未生成可保存候选", ErrInvalidPreviewRequest) + } + + activeContext := req.ActiveContext + triggerID := strings.TrimSpace(req.TriggerID) + if triggerID == "" { + triggerID = strings.TrimSpace(activeContext.Trigger.TriggerID) + } + if triggerID == "" { + return nil, fmt.Errorf("%w: trigger_id 不能为空", ErrInvalidPreviewRequest) + } + + generatedAt := req.GeneratedAt + if generatedAt.IsZero() { + generatedAt = s.now() + } + previewID := strings.TrimSpace(req.PreviewID) + if previewID == "" { + previewID = "asp_" + uuid.NewString() + } + + // 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。 + selected := req.Candidates[0] + snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected) + baseVersion := strings.TrimSpace(req.BaseVersion) + if baseVersion == "" { + baseVersion = buildBaseVersion(activeContext, snapshot.changes) + } + + explanation := strings.TrimSpace(req.ExplanationText) + if explanation == "" { + explanation = selected.Summary + } + notificationSummary := strings.TrimSpace(req.NotificationSummary) + if notificationSummary == "" { + notificationSummary = selected.Summary + } + + row, err := buildPreviewModel(previewID, triggerID, generatedAt, baseVersion, explanation, notificationSummary, activeContext, snapshot) + if err != nil { + return nil, err + } + + // 2. 写入 active_schedule_previews。这里不包事务写其它表,因为本服务不负责 trigger/notification/apply 状态推进。 + if err := s.repo.CreatePreview(ctx, row); err != nil { + return nil, err + } + + detail, err := detailFromModel(row, s.now()) + if err != nil { + return nil, err + } + return &CreatePreviewResponse{Detail: detail}, nil +} + +// GetPreview 查询 preview 详情,并强制校验归属用户。 +// +// 职责边界: +// 1. preview_id 不存在或不属于 user_id 时统一返回 ErrPreviewNotFound,避免泄漏其它用户数据; +// 2. 查询不会把过期 preview 回写为 expired,过期状态仅在 DTO 中计算; +// 3. 不读取正式日程实时状态,因此不会触发 confirm 的 base_version 重校验。 +func (s *Service) GetPreview(ctx context.Context, userID int, previewID string) (*ActiveSchedulePreviewDetail, error) { + if s == nil || s.repo == nil { + return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest) + } + if userID <= 0 || strings.TrimSpace(previewID) == "" { + return nil, ErrPreviewNotFound + } + + row, err := s.repo.GetPreviewByID(ctx, strings.TrimSpace(previewID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPreviewNotFound + } + return nil, err + } + if row == nil || row.UserID != userID { + return nil, ErrPreviewNotFound + } + + detail, err := detailFromModel(row, s.now()) + if err != nil { + return nil, err + } + return &detail, nil +} + +func (s *Service) now() time.Time { + if s == nil || s.clock == nil { + return time.Now() + } + return s.clock() +} + +func buildPreviewModel( + previewID string, + triggerID string, + generatedAt time.Time, + baseVersion string, + explanation string, + notificationSummary string, + activeContext *schedulercontext.ActiveScheduleContext, + snapshot rawPreviewSnapshot, +) (*model.ActiveSchedulePreview, error) { + selectedJSON, err := jsonString(snapshot.selectedCandidate) + if err != nil { + return nil, err + } + candidatesJSON, err := jsonString(snapshot.candidates) + if err != nil { + return nil, err + } + decisionJSON, err := jsonString(snapshot.decision) + if err != nil { + return nil, err + } + metricsJSON, err := jsonString(snapshot.metrics) + if err != nil { + return nil, err + } + issuesJSON, err := jsonString(snapshot.issues) + if err != nil { + return nil, err + } + contextJSON, err := jsonString(snapshot.contextSummary) + if err != nil { + return nil, err + } + beforeJSON, err := jsonString(snapshot.before) + if err != nil { + return nil, err + } + changesJSON, err := jsonString(snapshot.changes) + if err != nil { + return nil, err + } + afterJSON, err := jsonString(snapshot.after) + if err != nil { + return nil, err + } + riskJSON, err := jsonString(snapshot.risk) + if err != nil { + return nil, err + } + + return &model.ActiveSchedulePreview{ + ID: previewID, + UserID: activeContext.User.UserID, + TriggerID: triggerID, + TriggerType: string(activeContext.Trigger.TriggerType), + TargetType: string(activeContext.Trigger.TargetType), + TargetID: activeContext.Trigger.TargetID, + Status: model.ActiveSchedulePreviewStatusReady, + SelectedCandidateID: snapshot.selectedCandidate.CandidateID, + CandidateCount: len(snapshot.candidates), + SelectedCandidateJSON: &selectedJSON, + CandidatesJSON: &candidatesJSON, + DecisionJSON: &decisionJSON, + MetricsJSON: &metricsJSON, + IssuesJSON: &issuesJSON, + ContextSummaryJSON: &contextJSON, + BeforeSummaryJSON: &beforeJSON, + PreviewChangesJSON: &changesJSON, + AfterSummaryJSON: &afterJSON, + RiskJSON: &riskJSON, + ExplanationText: explanation, + NotificationSummary: notificationSummary, + BaseVersion: baseVersion, + ExpiresAt: generatedAt.Add(time.Hour), + GeneratedAt: generatedAt, + ApplyStatus: model.ActiveScheduleApplyStatusNone, + TraceID: activeContext.Trace.TraceID, + }, nil +} + +func buildSnapshot( + activeContext *schedulercontext.ActiveScheduleContext, + observation observe.Result, + candidates []candidate.Candidate, + selected candidate.Candidate, +) rawPreviewSnapshot { + selectedDTO := candidateDTO(selected) + candidateDTOs := make([]CandidateDTO, 0, len(candidates)) + for _, item := range candidates { + candidateDTOs = append(candidateDTOs, candidateDTO(item)) + } + changes := changeDTOs(selected.CandidateID, selected.Changes) + before := buildBeforeSummary(activeContext, selected, changes) + after := buildAfterSummary(before, selected, changes) + + return rawPreviewSnapshot{ + selectedCandidate: selectedDTO, + candidates: candidateDTOs, + decision: observation.Decision, + metrics: observation.Metrics, + issues: observation.Issues, + contextSummary: contextSummaryDTO(activeContext), + before: before, + changes: changes, + after: after, + risk: riskDTO(selected, observation, changes), + } +} diff --git a/backend/active_scheduler/service/dry_run.go b/backend/active_scheduler/service/dry_run.go new file mode 100644 index 0000000..cc02e11 --- /dev/null +++ b/backend/active_scheduler/service/dry_run.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "errors" + "time" + + "github.com/LoveLosita/smartflow/backend/active_scheduler/candidate" + schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context" + "github.com/LoveLosita/smartflow/backend/active_scheduler/observe" + "github.com/LoveLosita/smartflow/backend/active_scheduler/ports" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" +) + +// DryRunResult 是 API dry-run / worker 测试入口可直接消费的同步结果。 +type DryRunResult struct { + Context *schedulercontext.ActiveScheduleContext + Observation observe.Result + Candidates []candidate.Candidate +} + +// DryRunService 编排主动调度 dry-run 主链路。 +// +// 职责边界: +// 1. 固定执行 BuildContext -> Observe -> GenerateCandidates; +// 2. 不调用 LLM、不写 preview、不发 notification、不正式写日程; +// 3. 后续 API / worker 应复用该入口,避免出现第二套 dry-run 诊断逻辑。 +type DryRunService struct { + builder *schedulercontext.Builder + analyzer *observe.Analyzer + generator *candidate.Generator +} + +// NewDryRunService 创建主动调度 dry-run 服务。 +func NewDryRunService(readers ports.Readers) (*DryRunService, error) { + builder, err := schedulercontext.NewBuilder(readers) + if err != nil { + return nil, err + } + return &DryRunService{ + builder: builder, + analyzer: observe.NewAnalyzer(), + generator: candidate.NewGenerator(), + }, nil +} + +// SetClock 注入测试时钟。 +func (s *DryRunService) SetClock(clock func() time.Time) { + if s != nil && s.builder != nil { + s.builder.SetClock(clock) + } +} + +// DryRun 执行主动调度同步诊断。 +func (s *DryRunService) DryRun(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*DryRunResult, error) { + if s == nil || s.builder == nil || s.analyzer == nil || s.generator == nil { + return nil, errors.New("DryRunService 尚未正确初始化") + } + + // 1. 构造上下文:读取 task / schedule / feedback 的只读事实快照。 + activeContext, err := s.builder.BuildContext(ctx, trig) + if err != nil { + return nil, err + } + + // 2. 主动观测:生成 metrics、issues 和初步裁决,不生成正式变更。 + observation := s.analyzer.Observe(activeContext) + + // 3. 候选生成:只枚举第一版允许的确定性候选,压缩融合保持关闭。 + candidates := s.generator.GenerateCandidates(activeContext, observation) + fallbackCandidateID := "" + if len(candidates) > 0 { + fallbackCandidateID = candidates[0].CandidateID + } + observation = s.analyzer.FinalizeDecision(observation, len(applicableCandidates(candidates)), fallbackCandidateID) + + return &DryRunResult{ + Context: activeContext, + Observation: observation, + Candidates: candidates, + }, nil +} + +func applicableCandidates(candidates []candidate.Candidate) []candidate.Candidate { + result := make([]candidate.Candidate, 0, len(candidates)) + for _, item := range candidates { + if item.CandidateType == candidate.TypeAddTaskPoolToSchedule || item.CandidateType == candidate.TypeCreateMakeup { + result = append(result, item) + } + } + return result +} diff --git a/backend/active_scheduler/service/preview_confirm.go b/backend/active_scheduler/service/preview_confirm.go new file mode 100644 index 0000000..8abf064 --- /dev/null +++ b/backend/active_scheduler/service/preview_confirm.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply" + "github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter" + activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview" + "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/model" +) + +// PreviewConfirmService 编排第三阶段的预览生成、查询和确认应用。 +// +// 职责边界: +// 1. 复用 dry-run 结果写 preview,不重新实现候选生成; +// 2. confirm 时只负责 preview 状态、幂等和 apply port 调用编排; +// 3. 正式 schedule 写入仍由 applyadapter 在事务中完成。 +type PreviewConfirmService struct { + dryRun *DryRunService + preview *activepreview.Service + activeDAO *dao.ActiveScheduleDAO + applyAdapter *applyadapter.GormApplyAdapter + clock func() time.Time +} + +func NewPreviewConfirmService(dryRun *DryRunService, previewService *activepreview.Service, activeDAO *dao.ActiveScheduleDAO, applyAdapter *applyadapter.GormApplyAdapter) (*PreviewConfirmService, error) { + if dryRun == nil { + return nil, errors.New("dry-run service 不能为空") + } + if previewService == nil { + return nil, errors.New("preview service 不能为空") + } + if activeDAO == nil { + return nil, errors.New("active schedule dao 不能为空") + } + if applyAdapter == nil { + return nil, errors.New("apply adapter 不能为空") + } + return &PreviewConfirmService{ + dryRun: dryRun, + preview: previewService, + activeDAO: activeDAO, + applyAdapter: applyAdapter, + clock: time.Now, + }, nil +} + +func (s *PreviewConfirmService) SetClock(clock func() time.Time) { + if s != nil && clock != nil { + s.clock = clock + } +} + +func (s *PreviewConfirmService) CreatePreviewFromDryRun(ctx context.Context, req activepreview.CreatePreviewRequest) (*activepreview.CreatePreviewResponse, error) { + if s == nil || s.preview == nil { + return nil, errors.New("preview confirm service 未初始化") + } + return s.preview.CreatePreview(ctx, req) +} + +func (s *PreviewConfirmService) GetPreview(ctx context.Context, userID int, previewID string) (*activepreview.ActiveSchedulePreviewDetail, error) { + if s == nil || s.preview == nil { + return nil, errors.New("preview confirm service 未初始化") + } + return s.preview.GetPreview(ctx, userID, previewID) +} + +// ConfirmPreview 同步确认并应用主动调度预览。 +// +// 步骤化说明: +// 1. 先读取 preview 并做同用户校验,避免跨用户确认; +// 2. 对已应用且命中同一幂等键的请求直接返回历史结果,避免重复写日程; +// 3. 转换 candidate/edited_changes 为 apply 请求; +// 4. 先把 preview 标记 applying,再调用正式 apply adapter; +// 5. 成功或失败都回写 preview,保证接口返回后可排障。 +func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeapply.ConfirmRequest) (*activeapply.ConfirmResult, error) { + if s == nil || s.activeDAO == nil || s.applyAdapter == nil { + return nil, errors.New("preview confirm service 未初始化") + } + now := s.now() + if req.RequestedAt.IsZero() { + req.RequestedAt = now + } + previewRow, err := s.activeDAO.GetPreviewByID(ctx, req.PreviewID) + if err != nil { + return nil, err + } + if previewRow.UserID != req.UserID { + return nil, fmt.Errorf("preview 不属于当前用户") + } + if previewRow.ApplyStatus == model.ActiveScheduleApplyStatusApplied { + if previewRow.ApplyIdempotencyKey == req.IdempotencyKey { + return alreadyAppliedResult(*previewRow), nil + } + return nil, fmt.Errorf("preview 已应用,不能使用新的幂等键重复确认") + } + + applyReq, err := activeapply.ConvertConfirmToApplyRequest(*previewRow, req, now) + if err != nil { + _ = s.markApplyFailed(ctx, previewRow.ID, "", err) + return nil, err + } + if len(applyReq.Commands) == 0 { + return nil, fmt.Errorf("当前候选没有可正式应用的日程变更") + } + if err = s.markApplying(ctx, *applyReq); err != nil { + return nil, err + } + + adapterReq := toAdapterRequest(*applyReq) + adapterResult, err := s.applyAdapter.ApplyActiveScheduleChanges(ctx, adapterReq) + if err != nil { + _ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, err) + return nil, err + } + + result := activeapply.ApplyActiveScheduleResult{ + ApplyID: applyReq.ApplyID, + ApplyStatus: activeapply.ApplyStatusApplied, + AppliedEventIDs: adapterResult.AppliedEventIDs, + AppliedScheduleIDs: adapterResult.AppliedScheduleIDs, + AppliedChanges: applyReq.Changes, + SkippedChanges: applyReq.SkippedChanges, + RequestHash: applyReq.RequestHash, + NormalizedChangeHash: applyReq.NormalizedChangesHash, + } + if err = s.markApplied(ctx, *applyReq, result); err != nil { + return nil, err + } + return &activeapply.ConfirmResult{ + PreviewID: applyReq.PreviewID, + ApplyID: applyReq.ApplyID, + ApplyStatus: activeapply.ApplyStatusApplied, + CandidateID: applyReq.CandidateID, + RequestHash: applyReq.RequestHash, + RequestBodyHash: applyReq.RequestBodyHash, + ApplyRequest: applyReq, + ApplyResult: &result, + SkippedChanges: applyReq.SkippedChanges, + }, nil +} + +func (s *PreviewConfirmService) markApplying(ctx context.Context, req activeapply.ApplyActiveScheduleRequest) error { + return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{ + "apply_id": req.ApplyID, + "apply_status": model.ActiveScheduleApplyStatusApplying, + "apply_candidate_id": req.CandidateID, + "apply_idempotency_key": req.IdempotencyKey, + "apply_request_hash": req.RequestHash, + }) +} + +func (s *PreviewConfirmService) markApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest, result activeapply.ApplyActiveScheduleResult) error { + now := s.now() + appliedChangesJSON := mustJSON(result.AppliedChanges) + appliedEventIDsJSON := mustJSON(result.AppliedEventIDs) + return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{ + "status": model.ActiveSchedulePreviewStatusApplied, + "apply_status": model.ActiveScheduleApplyStatusApplied, + "applied_changes_json": &appliedChangesJSON, + "applied_event_ids_json": &appliedEventIDsJSON, + "apply_error": nil, + "applied_at": &now, + }) +} + +func (s *PreviewConfirmService) markApplyFailed(ctx context.Context, previewID string, applyID string, err error) error { + if previewID == "" { + return nil + } + message := "" + if err != nil { + message = err.Error() + } + updates := map[string]any{ + "apply_status": model.ActiveScheduleApplyStatusFailed, + "apply_error": &message, + } + if applyID != "" { + updates["apply_id"] = applyID + } + return s.activeDAO.UpdatePreviewFields(ctx, previewID, updates) +} + +func (s *PreviewConfirmService) now() time.Time { + if s == nil || s.clock == nil { + return time.Now() + } + return s.clock() +} + +func toAdapterRequest(req activeapply.ApplyActiveScheduleRequest) applyadapter.ApplyActiveScheduleRequest { + changes := make([]applyadapter.ApplyChange, 0, len(req.Changes)) + for _, change := range req.Changes { + changes = append(changes, toAdapterChange(change)) + } + return applyadapter.ApplyActiveScheduleRequest{ + PreviewID: req.PreviewID, + ApplyID: req.ApplyID, + UserID: req.UserID, + CandidateID: req.CandidateID, + Changes: changes, + RequestedAt: req.RequestedAt, + TraceID: req.TraceID, + } +} + +func toAdapterChange(change activeapply.ApplyChange) applyadapter.ApplyChange { + return applyadapter.ApplyChange{ + ChangeID: change.ChangeID, + ChangeType: string(change.Type), + TargetType: change.TargetType, + TargetID: change.TargetID, + ToSlot: toAdapterSlotSpan(change), + DurationSections: change.DurationSections, + Metadata: cloneStringMap(change.Metadata), + } +} + +func toAdapterSlotSpan(change activeapply.ApplyChange) *applyadapter.SlotSpan { + if len(change.Slots) == 0 { + return nil + } + start := change.Slots[0] + end := change.Slots[len(change.Slots)-1] + return &applyadapter.SlotSpan{ + Start: applyadapter.Slot{Week: start.Week, DayOfWeek: start.DayOfWeek, Section: start.Section}, + End: applyadapter.Slot{Week: end.Week, DayOfWeek: end.DayOfWeek, Section: end.Section}, + DurationSections: len(change.Slots), + } +} + +func alreadyAppliedResult(preview model.ActiveSchedulePreview) *activeapply.ConfirmResult { + appliedEventIDs := []int{} + if preview.AppliedEventIDsJSON != nil && *preview.AppliedEventIDsJSON != "" { + _ = json.Unmarshal([]byte(*preview.AppliedEventIDsJSON), &appliedEventIDs) + } + return &activeapply.ConfirmResult{ + PreviewID: preview.ID, + ApplyID: stringValue(preview.ApplyID), + ApplyStatus: activeapply.ApplyStatusApplied, + CandidateID: preview.ApplyCandidateID, + RequestHash: preview.ApplyRequestHash, + ApplyResult: &activeapply.ApplyActiveScheduleResult{ + ApplyID: stringValue(preview.ApplyID), + ApplyStatus: activeapply.ApplyStatusApplied, + AppliedEventIDs: appliedEventIDs, + RequestHash: preview.ApplyRequestHash, + }, + } +} + +func mustJSON(value any) string { + raw, err := json.Marshal(value) + if err != nil { + return "null" + } + return string(raw) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func cloneStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + output := make(map[string]string, len(input)) + for key, value := range input { + output[key] = value + } + return output +} diff --git a/backend/active_scheduler/trigger/types.go b/backend/active_scheduler/trigger/types.go new file mode 100644 index 0000000..4a83c6e --- /dev/null +++ b/backend/active_scheduler/trigger/types.go @@ -0,0 +1,103 @@ +package trigger + +import ( + "errors" + "time" +) + +// TriggerType 是主动调度第一版允许进入 dry-run 主链路的触发类型。 +// +// 职责边界: +// 1. 只表达触发信号分类; +// 2. 不负责判断任务是否真的需要调度; +// 3. 不承载 preview、notification 或 apply 状态。 +type TriggerType string + +const ( + TriggerTypeImportantUrgentTask TriggerType = "important_urgent_task" + TriggerTypeUnfinishedFeedback TriggerType = "unfinished_feedback" +) + +// Source 表示触发信号来源;dry-run 第一版只消费该字段用于审计和 mock_now 校验。 +type Source string + +const ( + SourceWorkerDueJob Source = "worker_due_job" + SourceAPITrigger Source = "api_trigger" + SourceAPIDryRun Source = "api_dry_run" + SourceUserFeedback Source = "user_feedback" +) + +// TargetType 表示触发信号指向的业务对象类型。 +type TargetType string + +const ( + TargetTypeTaskPool TargetType = "task_pool" + TargetTypeScheduleEvent TargetType = "schedule_event" + TargetTypeTaskItem TargetType = "task_item" +) + +// ActiveScheduleTrigger 是主动调度主链路的统一输入。 +// +// 职责边界: +// 1. 负责承载 API dry-run、正式 trigger、worker 与用户反馈归一后的输入; +// 2. 不负责读取任务、日程或反馈事实; +// 3. TargetID 在 unfinished_feedback 且反馈目标未知时允许为 0,由观察链路转成 ask_user。 +type ActiveScheduleTrigger struct { + TriggerID string + UserID int + TriggerType TriggerType + Source Source + TargetType TargetType + TargetID int + FeedbackID string + IdempotencyKey string + MockNow *time.Time + IsMockTime bool + RequestedAt time.Time + TraceID string +} + +// Validate 校验触发信号是否能进入主动调度 dry-run 主链路。 +// +// 职责边界: +// 1. 只做枚举、归属与 mock_now 入口级校验; +// 2. 不判断目标是否存在,也不判断是否应生成候选; +// 3. 返回 nil 表示可以继续构造上下文,error 表示调用方应直接拒绝请求。 +func (t ActiveScheduleTrigger) Validate() error { + if t.UserID <= 0 { + return errors.New("user_id 必须大于 0") + } + if t.TriggerType != TriggerTypeImportantUrgentTask && t.TriggerType != TriggerTypeUnfinishedFeedback { + return errors.New("trigger_type 不受支持") + } + if t.Source != SourceWorkerDueJob && t.Source != SourceAPITrigger && t.Source != SourceAPIDryRun && t.Source != SourceUserFeedback { + return errors.New("source 不受支持") + } + if t.TargetType != TargetTypeTaskPool && t.TargetType != TargetTypeScheduleEvent && t.TargetType != TargetTypeTaskItem { + return errors.New("target_type 不受支持") + } + if t.TargetID <= 0 && t.TriggerType != TriggerTypeUnfinishedFeedback { + return errors.New("target_id 必须大于 0") + } + if t.MockNow != nil && t.Source != SourceAPIDryRun && t.Source != SourceAPITrigger { + return errors.New("mock_now 只允许 API dry-run 或 API trigger 使用") + } + if t.MockNow != nil && !t.IsMockTime { + return errors.New("传入 mock_now 时必须显式标记 is_mock_time") + } + return nil +} + +// EffectiveNow 返回主动调度本次运行应使用的业务当前时间。 +// +// 职责边界: +// 1. dry-run / 测试 trigger 可使用 MockNow; +// 2. 后台 worker 使用调用方传入的真实 now; +// 3. 不负责时区转换,调用方应保证 now 与用户时区语义一致。 +func (t ActiveScheduleTrigger) EffectiveNow(realNow time.Time) time.Time { + if t.MockNow != nil { + return *t.MockNow + } + return realNow +} diff --git a/backend/api/active_schedule.go b/backend/api/active_schedule.go new file mode 100644 index 0000000..73e244a --- /dev/null +++ b/backend/api/active_schedule.go @@ -0,0 +1,181 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply" + activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview" + activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service" + "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/gin-gonic/gin" +) + +// ActiveScheduleAPI 承载主动调度开发期和验收期 API。 +// +// 职责边界: +// 1. 只负责鉴权用户、绑定请求和调用主动调度 service; +// 2. 不直接读取 DAO、不生成候选、不写 preview; +// 3. 阶段 1-2 只开放 dry-run,正式 trigger/preview/confirm 后续阶段再接入。 +type ActiveScheduleAPI struct { + dryRunService *activesvc.DryRunService + previewConfirmService *activesvc.PreviewConfirmService +} + +func NewActiveScheduleAPI(dryRunService *activesvc.DryRunService, previewConfirmService *activesvc.PreviewConfirmService) *ActiveScheduleAPI { + return &ActiveScheduleAPI{ + dryRunService: dryRunService, + previewConfirmService: previewConfirmService, + } +} + +type ActiveScheduleDryRunRequest struct { + TriggerType string `json:"trigger_type" binding:"required"` + TargetType string `json:"target_type" binding:"required"` + TargetID int `json:"target_id"` + FeedbackID string `json:"feedback_id"` + IdempotencyKey string `json:"idempotency_key"` + MockNow *time.Time `json:"mock_now"` +} + +// DryRun 同步执行主动调度诊断,不写 preview、不发通知、不修改正式日程。 +func (api *ActiveScheduleAPI) DryRun(c *gin.Context) { + if api == nil || api.dryRunService == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 dry-run service 未初始化"))) + return + } + + var req ActiveScheduleDryRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + userID := c.GetInt("user_id") + now := time.Now() + isMockTime := req.MockNow != nil + trig := trigger.ActiveScheduleTrigger{ + UserID: userID, + TriggerType: trigger.TriggerType(req.TriggerType), + Source: trigger.SourceAPIDryRun, + TargetType: trigger.TargetType(req.TargetType), + TargetID: req.TargetID, + FeedbackID: req.FeedbackID, + IdempotencyKey: req.IdempotencyKey, + MockNow: req.MockNow, + IsMockTime: isMockTime, + RequestedAt: now, + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + result, err := api.dryRunService.DryRun(ctx, trig) + if err != nil { + respond.DealWithError(c, err) + return + } + + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result)) +} + +// CreatePreview 先同步 dry-run,再把 top1 候选固化为待确认预览。 +func (api *ActiveScheduleAPI) CreatePreview(c *gin.Context) { + if api == nil || api.dryRunService == nil || api.previewConfirmService == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 preview service 未初始化"))) + return + } + + var req ActiveScheduleDryRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + userID := c.GetInt("user_id") + now := time.Now() + trig := trigger.ActiveScheduleTrigger{ + TriggerID: fmt.Sprintf("ast_api_%d_%d", userID, now.UnixNano()), + UserID: userID, + TriggerType: trigger.TriggerType(req.TriggerType), + Source: trigger.SourceAPIDryRun, + TargetType: trigger.TargetType(req.TargetType), + TargetID: req.TargetID, + FeedbackID: req.FeedbackID, + IdempotencyKey: req.IdempotencyKey, + MockNow: req.MockNow, + IsMockTime: req.MockNow != nil, + RequestedAt: now, + TraceID: fmt.Sprintf("trace_api_preview_%d_%d", userID, now.UnixNano()), + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + dryRunResult, err := api.dryRunService.DryRun(ctx, trig) + if err != nil { + respond.DealWithError(c, err) + return + } + previewResp, err := api.previewConfirmService.CreatePreviewFromDryRun(ctx, activepreview.CreatePreviewRequest{ + ActiveContext: dryRunResult.Context, + Observation: dryRunResult.Observation, + Candidates: dryRunResult.Candidates, + TriggerID: trig.TriggerID, + GeneratedAt: now, + }) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, previewResp.Detail)) +} + +// GetPreview 查询主动调度预览详情。 +func (api *ActiveScheduleAPI) GetPreview(c *gin.Context) { + if api == nil || api.previewConfirmService == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 preview service 未初始化"))) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + detail, err := api.previewConfirmService.GetPreview(ctx, c.GetInt("user_id"), c.Param("preview_id")) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail)) +} + +// ConfirmPreview 同步确认并正式应用主动调度预览。 +func (api *ActiveScheduleAPI) ConfirmPreview(c *gin.Context) { + if api == nil || api.previewConfirmService == nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 confirm service 未初始化"))) + return + } + var req activeapply.ConfirmRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + req.PreviewID = c.Param("preview_id") + req.UserID = c.GetInt("user_id") + if req.RequestedAt.IsZero() { + req.RequestedAt = time.Now() + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + result, err := api.previewConfirmService.ConfirmPreview(ctx, req) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result)) +} + +type nilServiceError string + +func (e nilServiceError) Error() string { + return string(e) +} diff --git a/backend/api/container.go b/backend/api/container.go index db1b3cb..f119c77 100644 --- a/backend/api/container.go +++ b/backend/api/container.go @@ -8,4 +8,5 @@ type ApiHandlers struct { ScheduleHandler *ScheduleAPI AgentHandler *AgentHandler MemoryHandler *MemoryHandler + ActiveSchedule *ActiveScheduleAPI } diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 7a3f863..05a8e00 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -10,6 +10,10 @@ import ( "syscall" "time" + activeadapters "github.com/LoveLosita/smartflow/backend/active_scheduler/adapters" + "github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter" + activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview" + activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service" "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" @@ -198,6 +202,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { // Service 层初始化。 userService := service.NewUserService(userRepo, cacheRepo) taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) + taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(courseRepo, scheduleRepo) taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo) @@ -215,7 +220,15 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { memoryCfg, ) - handlers := buildAPIHandlers(userService, taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule) + activeScheduleDryRun, err := buildActiveScheduleDryRunService(db) + if err != nil { + return nil, err + } + activeSchedulePreviewConfirm, err := buildActiveSchedulePreviewConfirmService(db, manager.ActiveSchedule, activeScheduleDryRun) + if err != nil { + return nil, err + } + handlers := buildAPIHandlers(userService, taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule, activeScheduleDryRun, activeSchedulePreviewConfirm) return &appRuntime{ db: db, @@ -289,6 +302,19 @@ func buildCourseService(courseRepo *dao.CourseDAO, scheduleRepo *dao.ScheduleDAO ) } +func buildActiveScheduleDryRunService(db *gorm.DB) (*activesvc.DryRunService, error) { + readers := activeadapters.NewGormReaders(db) + return activesvc.NewDryRunService(activeadapters.ReadersFromGorm(readers)) +} + +func buildActiveSchedulePreviewConfirmService(db *gorm.DB, activeDAO *dao.ActiveScheduleDAO, dryRun *activesvc.DryRunService) (*activesvc.PreviewConfirmService, error) { + previewService, err := activepreview.NewService(activeDAO) + if err != nil { + return nil, err + } + return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db)) +} + func configureAgentService( agentService *service.AgentService, ragRuntime infrarag.Runtime, @@ -477,6 +503,8 @@ func buildAPIHandlers( scheduleService *service.ScheduleService, agentService *service.AgentService, memoryModule *memory.Module, + activeScheduleDryRun *activesvc.DryRunService, + activeSchedulePreviewConfirm *activesvc.PreviewConfirmService, ) *api.ApiHandlers { return &api.ApiHandlers{ UserHandler: api.NewUserHandler(userService), @@ -486,6 +514,7 @@ func buildAPIHandlers( ScheduleHandler: api.NewScheduleAPI(scheduleService), AgentHandler: api.NewAgentHandler(agentService), MemoryHandler: api.NewMemoryHandler(memoryModule), + ActiveSchedule: api.NewActiveScheduleAPI(activeScheduleDryRun, activeSchedulePreviewConfirm), } } diff --git a/backend/dao/active_schedule.go b/backend/dao/active_schedule.go new file mode 100644 index 0000000..19115ad --- /dev/null +++ b/backend/dao/active_schedule.go @@ -0,0 +1,395 @@ +package dao + +import ( + "context" + "errors" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ActiveScheduleDAO 管理主动调度阶段 1 的自有表。 +// +// 职责边界: +// 1. 只负责 active_schedule_jobs / triggers / previews / notification_records 的基础读写; +// 2. 不负责构造候选、调用 LLM、投递 provider 或写正式日程; +// 3. 幂等查询只按持久化键读取事实,是否复用结果由上层状态机判断。 +type ActiveScheduleDAO struct { + db *gorm.DB +} + +func NewActiveScheduleDAO(db *gorm.DB) *ActiveScheduleDAO { + return &ActiveScheduleDAO{db: db} +} + +func (d *ActiveScheduleDAO) WithTx(tx *gorm.DB) *ActiveScheduleDAO { + return &ActiveScheduleDAO{db: tx} +} + +func (d *ActiveScheduleDAO) ensureDB() error { + if d == nil || d.db == nil { + return errors.New("active schedule dao 未初始化") + } + return nil +} + +// CreateOrUpdateJob 按 job.id 幂等创建或覆盖主动调度 job。 +// +// 职责边界: +// 1. 只按主键 upsert 当前传入的 job 快照; +// 2. 不判断 task 是否仍满足主动调度条件,该判断由 job scanner 读取 task 真值后完成; +// 3. 调用方需要保证 ID 稳定,例如按 task_id 当前有效 job 或生成 asj_*。 +func (d *ActiveScheduleDAO) CreateOrUpdateJob(ctx context.Context, job *model.ActiveScheduleJob) error { + if err := d.ensureDB(); err != nil { + return err + } + if job == nil || job.ID == "" { + return errors.New("active schedule job 不能为空且必须包含 id") + } + return d.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }). + Create(job).Error +} + +// UpdateJobFields 按 job_id 更新指定字段。 +// +// 职责边界: +// 1. 只执行局部字段更新,不隐式改变其它状态; +// 2. updates 为空时直接返回 nil,方便上层按条件拼装更新; +// 3. 不做状态机合法性校验,状态流转由 active_scheduler/job 负责。 +func (d *ActiveScheduleDAO) UpdateJobFields(ctx context.Context, jobID string, updates map[string]any) error { + if err := d.ensureDB(); err != nil { + return err + } + if jobID == "" { + return errors.New("active schedule job id 不能为空") + } + if len(updates) == 0 { + return nil + } + return d.db.WithContext(ctx). + Model(&model.ActiveScheduleJob{}). + Where("id = ?", jobID). + Updates(updates).Error +} + +func (d *ActiveScheduleDAO) GetJobByID(ctx context.Context, jobID string) (*model.ActiveScheduleJob, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if jobID == "" { + return nil, gorm.ErrRecordNotFound + } + var job model.ActiveScheduleJob + err := d.db.WithContext(ctx).Where("id = ?", jobID).First(&job).Error + if err != nil { + return nil, err + } + return &job, nil +} + +// FindPendingJobByTask 查询某个 task 当前待触发 job。 +// +// 说明: +// 1. 用于 task 创建/更新时决定复用还是覆盖当前有效 job; +// 2. 只查 pending,已 triggered/canceled/skipped 的历史 job 保留审计,不再被覆盖。 +func (d *ActiveScheduleDAO) FindPendingJobByTask(ctx context.Context, userID int, taskID int) (*model.ActiveScheduleJob, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if userID <= 0 || taskID <= 0 { + return nil, gorm.ErrRecordNotFound + } + var job model.ActiveScheduleJob + err := d.db.WithContext(ctx). + Where("user_id = ? AND task_id = ? AND status = ?", userID, taskID, model.ActiveScheduleJobStatusPending). + Order("trigger_at ASC, created_at ASC"). + First(&job).Error + if err != nil { + return nil, err + } + return &job, nil +} + +// ListDueJobs 读取到期且仍待触发的 job。 +// +// 失败处理: +// 1. 参数非法时返回空列表,避免 worker 因配置抖动误扫全表; +// 2. 数据库错误直接返回,让上层按扫描器策略记录并重试。 +func (d *ActiveScheduleDAO) ListDueJobs(ctx context.Context, now time.Time, limit int) ([]model.ActiveScheduleJob, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if limit <= 0 || now.IsZero() { + return []model.ActiveScheduleJob{}, nil + } + var jobs []model.ActiveScheduleJob + err := d.db.WithContext(ctx). + Where("status = ? AND trigger_at <= ?", model.ActiveScheduleJobStatusPending, now). + Order("trigger_at ASC, id ASC"). + Limit(limit). + Find(&jobs).Error + if err != nil { + return nil, err + } + return jobs, nil +} + +func (d *ActiveScheduleDAO) CreateTrigger(ctx context.Context, trigger *model.ActiveScheduleTrigger) error { + if err := d.ensureDB(); err != nil { + return err + } + if trigger == nil || trigger.ID == "" { + return errors.New("active schedule trigger 不能为空且必须包含 id") + } + return d.db.WithContext(ctx).Create(trigger).Error +} + +// UpdateTriggerFields 按 trigger_id 局部更新触发状态。 +// +// 职责边界: +// 1. 只提供字段更新能力,不判断 pending -> processing -> preview_generated 是否合规; +// 2. 上层若需要 CAS 状态流转,应在 updates 外自行加 where 条件或后续扩展专用方法; +// 3. updates 为空时直接返回 nil。 +func (d *ActiveScheduleDAO) UpdateTriggerFields(ctx context.Context, triggerID string, updates map[string]any) error { + if err := d.ensureDB(); err != nil { + return err + } + if triggerID == "" { + return errors.New("active schedule trigger id 不能为空") + } + if len(updates) == 0 { + return nil + } + return d.db.WithContext(ctx). + Model(&model.ActiveScheduleTrigger{}). + Where("id = ?", triggerID). + Updates(updates).Error +} + +func (d *ActiveScheduleDAO) GetTriggerByID(ctx context.Context, triggerID string) (*model.ActiveScheduleTrigger, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if triggerID == "" { + return nil, gorm.ErrRecordNotFound + } + var trigger model.ActiveScheduleTrigger + err := d.db.WithContext(ctx).Where("id = ?", triggerID).First(&trigger).Error + if err != nil { + return nil, err + } + return &trigger, nil +} + +// FindTriggerByDedupeKey 查询触发去重键对应的最近 trigger。 +// +// 说明: +// 1. important_urgent_task 使用 user_id + trigger_type + target + 30 分钟窗口构造 dedupe_key; +// 2. unfinished_feedback 可把反馈幂等键放入 dedupe_key; +// 3. statuses 为空时读取所有状态,方便调用方按场景选择是否复用 failed 记录。 +func (d *ActiveScheduleDAO) FindTriggerByDedupeKey(ctx context.Context, dedupeKey string, statuses []string) (*model.ActiveScheduleTrigger, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if dedupeKey == "" { + return nil, gorm.ErrRecordNotFound + } + query := d.db.WithContext(ctx). + Where("dedupe_key = ?", dedupeKey) + if len(statuses) > 0 { + query = query.Where("status IN ?", statuses) + } + var trigger model.ActiveScheduleTrigger + err := query.Order("created_at DESC, id DESC").First(&trigger).Error + if err != nil { + return nil, err + } + return &trigger, nil +} + +// FindTriggerByIdempotencyKey 查询 API/用户反馈幂等键对应的 trigger。 +func (d *ActiveScheduleDAO) FindTriggerByIdempotencyKey(ctx context.Context, userID int, triggerType string, idempotencyKey string) (*model.ActiveScheduleTrigger, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if userID <= 0 || triggerType == "" || idempotencyKey == "" { + return nil, gorm.ErrRecordNotFound + } + var trigger model.ActiveScheduleTrigger + err := d.db.WithContext(ctx). + Where("user_id = ? AND trigger_type = ? AND idempotency_key = ?", userID, triggerType, idempotencyKey). + Order("created_at DESC, id DESC"). + First(&trigger).Error + if err != nil { + return nil, err + } + return &trigger, nil +} + +func (d *ActiveScheduleDAO) CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error { + if err := d.ensureDB(); err != nil { + return err + } + if preview == nil || preview.ID == "" { + return errors.New("active schedule preview 不能为空且必须包含 preview_id") + } + return d.db.WithContext(ctx).Create(preview).Error +} + +func (d *ActiveScheduleDAO) UpdatePreviewFields(ctx context.Context, previewID string, updates map[string]any) error { + if err := d.ensureDB(); err != nil { + return err + } + if previewID == "" { + return errors.New("active schedule preview id 不能为空") + } + if len(updates) == 0 { + return nil + } + return d.db.WithContext(ctx). + Model(&model.ActiveSchedulePreview{}). + Where("preview_id = ?", previewID). + Updates(updates).Error +} + +func (d *ActiveScheduleDAO) GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if previewID == "" { + return nil, gorm.ErrRecordNotFound + } + var preview model.ActiveSchedulePreview + err := d.db.WithContext(ctx).Where("preview_id = ?", previewID).First(&preview).Error + if err != nil { + return nil, err + } + return &preview, nil +} + +func (d *ActiveScheduleDAO) GetPreviewByTriggerID(ctx context.Context, triggerID string) (*model.ActiveSchedulePreview, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if triggerID == "" { + return nil, gorm.ErrRecordNotFound + } + var preview model.ActiveSchedulePreview + err := d.db.WithContext(ctx). + Where("trigger_id = ?", triggerID). + Order("created_at DESC"). + First(&preview).Error + if err != nil { + return nil, err + } + return &preview, nil +} + +// FindPreviewByApplyIdempotencyKey 查询 confirm 重试时的预览应用状态。 +func (d *ActiveScheduleDAO) FindPreviewByApplyIdempotencyKey(ctx context.Context, previewID string, idempotencyKey string) (*model.ActiveSchedulePreview, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if previewID == "" || idempotencyKey == "" { + return nil, gorm.ErrRecordNotFound + } + var preview model.ActiveSchedulePreview + err := d.db.WithContext(ctx). + Where("preview_id = ? AND apply_idempotency_key = ?", previewID, idempotencyKey). + First(&preview).Error + if err != nil { + return nil, err + } + return &preview, nil +} + +func (d *ActiveScheduleDAO) CreateNotificationRecord(ctx context.Context, record *model.NotificationRecord) error { + if err := d.ensureDB(); err != nil { + return err + } + if record == nil { + return errors.New("notification record 不能为空") + } + return d.db.WithContext(ctx).Create(record).Error +} + +func (d *ActiveScheduleDAO) UpdateNotificationRecordFields(ctx context.Context, notificationID int64, updates map[string]any) error { + if err := d.ensureDB(); err != nil { + return err + } + if notificationID <= 0 { + return errors.New("notification record id 不能为空") + } + if len(updates) == 0 { + return nil + } + return d.db.WithContext(ctx). + Model(&model.NotificationRecord{}). + Where("id = ?", notificationID). + Updates(updates).Error +} + +func (d *ActiveScheduleDAO) GetNotificationRecordByID(ctx context.Context, notificationID int64) (*model.NotificationRecord, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if notificationID <= 0 { + return nil, gorm.ErrRecordNotFound + } + var record model.NotificationRecord + err := d.db.WithContext(ctx).Where("id = ?", notificationID).First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// FindNotificationRecordByDedupeKey 查询通知去重记录。 +// +// 说明: +// 1. notification 第一版按 channel + dedupe_key 聚合去重; +// 2. 若返回 pending/sending/sent,上层应避免重复投递; +// 3. 若返回 failed,上层可以复用同一条记录进入 provider retry。 +func (d *ActiveScheduleDAO) FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*model.NotificationRecord, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if channel == "" || dedupeKey == "" { + return nil, gorm.ErrRecordNotFound + } + var record model.NotificationRecord + err := d.db.WithContext(ctx). + Where("channel = ? AND dedupe_key = ?", channel, dedupeKey). + Order("created_at DESC, id DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// ListRetryableNotificationRecords 查询到达重试时间的通知记录。 +func (d *ActiveScheduleDAO) ListRetryableNotificationRecords(ctx context.Context, now time.Time, limit int) ([]model.NotificationRecord, error) { + if err := d.ensureDB(); err != nil { + return nil, err + } + if limit <= 0 || now.IsZero() { + return []model.NotificationRecord{}, nil + } + var records []model.NotificationRecord + err := d.db.WithContext(ctx). + Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", model.NotificationRecordStatusFailed, now). + Order("next_retry_at ASC, id ASC"). + Limit(limit). + Find(&records).Error + if err != nil { + return nil, err + } + return records, nil +} diff --git a/backend/dao/base.go b/backend/dao/base.go index 1020bdf..798b088 100644 --- a/backend/dao/base.go +++ b/backend/dao/base.go @@ -8,24 +8,26 @@ import ( // RepoManager 聚合所有 DAO,供服务层做跨仓储事务编排。 type RepoManager struct { - db *gorm.DB - Schedule *ScheduleDAO - Task *TaskDAO - Course *CourseDAO - TaskClass *TaskClassDAO - User *UserDAO - Agent *AgentDAO + db *gorm.DB + Schedule *ScheduleDAO + Task *TaskDAO + Course *CourseDAO + TaskClass *TaskClassDAO + User *UserDAO + Agent *AgentDAO + ActiveSchedule *ActiveScheduleDAO } func NewManager(db *gorm.DB) *RepoManager { return &RepoManager{ - db: db, - Schedule: NewScheduleDAO(db), - Task: NewTaskDAO(db), - Course: NewCourseDAO(db), - TaskClass: NewTaskClassDAO(db), - User: NewUserDAO(db), - Agent: NewAgentDAO(db), + db: db, + Schedule: NewScheduleDAO(db), + Task: NewTaskDAO(db), + Course: NewCourseDAO(db), + TaskClass: NewTaskClassDAO(db), + User: NewUserDAO(db), + Agent: NewAgentDAO(db), + ActiveSchedule: NewActiveScheduleDAO(db), } } @@ -37,13 +39,14 @@ func NewManager(db *gorm.DB) *RepoManager { // 3. 适用于 outbox 消费处理器这类“基础设施事务 + 业务事务合并”的场景。 func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager { return &RepoManager{ - db: tx, - Schedule: m.Schedule.WithTx(tx), - Task: m.Task.WithTx(tx), - TaskClass: m.TaskClass.WithTx(tx), - Course: m.Course.WithTx(tx), - User: m.User.WithTx(tx), - Agent: m.Agent.WithTx(tx), + db: tx, + Schedule: m.Schedule.WithTx(tx), + Task: m.Task.WithTx(tx), + TaskClass: m.TaskClass.WithTx(tx), + Course: m.Course.WithTx(tx), + User: m.User.WithTx(tx), + Agent: m.Agent.WithTx(tx), + ActiveSchedule: m.ActiveSchedule.WithTx(tx), } } diff --git a/backend/inits/mysql.go b/backend/inits/mysql.go index 576ad93..6f27a88 100644 --- a/backend/inits/mysql.go +++ b/backend/inits/mysql.go @@ -21,6 +21,10 @@ func autoMigrateModels(db *gorm.DB) error { &model.TaskClassItem{}, &model.ScheduleEvent{}, &model.Schedule{}, + &model.ActiveScheduleJob{}, + &model.ActiveScheduleTrigger{}, + &model.ActiveSchedulePreview{}, + &model.NotificationRecord{}, &model.AgentOutboxMessage{}, &model.AgentScheduleState{}, &model.AgentStateSnapshotRecord{}, @@ -35,6 +39,30 @@ func autoMigrateModels(db *gorm.DB) error { return fmt.Errorf("auto migrate failed for %T: %w", m, err) } } + if err := backfillAutoMigrateData(db); err != nil { + return err + } + return nil +} + +// backfillAutoMigrateData 补齐 AutoMigrate 无法表达的条件回填。 +// +// 职责边界: +// 1. 只处理新增列上线后的兼容数据修复,不替代业务迁移系统; +// 2. 当前仅回填历史动态任务日程来源,确保旧的 type=task 记录按 task_item 解释; +// 3. 失败时直接返回错误,避免服务在 schema 半迁移状态下继续启动。 +func backfillAutoMigrateData(db *gorm.DB) error { + // 1. AutoMigrate 只能新增列和默认值,不能表达"仅 type=task 时回填"。 + // 2. 这里把历史任务日程显式标记为 task_item,避免后续主动调度读取 rel_id 时误判来源。 + // 3. 新增 task_pool 正式落库仍必须由 apply 链路显式写 task_source_type=task_pool。 + result := db.Exec( + "UPDATE schedule_events SET task_source_type = ? WHERE type = ? AND (task_source_type IS NULL OR task_source_type = '')", + "task_item", + "task", + ) + if result.Error != nil { + return fmt.Errorf("backfill schedule_events.task_source_type failed: %w", result.Error) + } return nil } diff --git a/backend/model/active_schedule.go b/backend/model/active_schedule.go new file mode 100644 index 0000000..ff1dbe9 --- /dev/null +++ b/backend/model/active_schedule.go @@ -0,0 +1,264 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + // ActiveScheduleJobStatusPending 表示 job 已创建,等待到达 trigger_at 后扫描。 + ActiveScheduleJobStatusPending = "pending" + // ActiveScheduleJobStatusTriggered 表示 job 已生成正式 trigger,后续由 trigger 串联状态。 + ActiveScheduleJobStatusTriggered = "triggered" + // ActiveScheduleJobStatusCanceled 表示任务已完成或被取消,job 不再触发。 + ActiveScheduleJobStatusCanceled = "canceled" + // ActiveScheduleJobStatusSkipped 表示扫描时发现已无需主动调度。 + ActiveScheduleJobStatusSkipped = "skipped" + // ActiveScheduleJobStatusFailed 表示扫描或触发写入失败,保留错误供重试/排障。 + ActiveScheduleJobStatusFailed = "failed" +) + +const ( + // ActiveScheduleTriggerStatusPending 表示触发信号已持久化,等待 worker 消费。 + ActiveScheduleTriggerStatusPending = "pending" + // ActiveScheduleTriggerStatusProcessing 表示 worker 正在处理该触发信号。 + ActiveScheduleTriggerStatusProcessing = "processing" + // ActiveScheduleTriggerStatusPreviewGenerated 表示已生成可查询的预览。 + ActiveScheduleTriggerStatusPreviewGenerated = "preview_generated" + // ActiveScheduleTriggerStatusSkipped 表示本次触发被判定无需继续处理。 + ActiveScheduleTriggerStatusSkipped = "skipped" + // ActiveScheduleTriggerStatusClosed 表示主动观测结论为关闭,不生成预览。 + ActiveScheduleTriggerStatusClosed = "closed" + // ActiveScheduleTriggerStatusFailed 表示链路处理失败,可根据错误分类决定是否重试。 + ActiveScheduleTriggerStatusFailed = "failed" + // ActiveScheduleTriggerStatusRejected 表示参数或归属校验失败,不进入 pipeline。 + ActiveScheduleTriggerStatusRejected = "rejected" +) + +const ( + // ActiveSchedulePreviewStatusPending 表示预览正在组装,不应展示为可确认。 + ActiveSchedulePreviewStatusPending = "pending" + // ActiveSchedulePreviewStatusReady 表示预览可查看、可确认。 + ActiveSchedulePreviewStatusReady = "ready" + // ActiveSchedulePreviewStatusApplied 表示用户已确认并成功应用。 + ActiveSchedulePreviewStatusApplied = "applied" + // ActiveSchedulePreviewStatusIgnored 表示用户明确忽略本次建议。 + ActiveSchedulePreviewStatusIgnored = "ignored" + // ActiveSchedulePreviewStatusExpired 表示预览已过期,不再允许确认。 + ActiveSchedulePreviewStatusExpired = "expired" + // ActiveSchedulePreviewStatusFailed 表示预览生成或回写失败。 + ActiveSchedulePreviewStatusFailed = "failed" +) + +const ( + // ActiveScheduleApplyStatusNone 表示尚未发起确认应用。 + ActiveScheduleApplyStatusNone = "none" + // ActiveScheduleApplyStatusApplying 表示确认请求正在事务应用中。 + ActiveScheduleApplyStatusApplying = "applying" + // ActiveScheduleApplyStatusApplied 表示确认应用成功。 + ActiveScheduleApplyStatusApplied = "applied" + // ActiveScheduleApplyStatusFailed 表示应用失败,正式日程不应产生半写状态。 + ActiveScheduleApplyStatusFailed = "failed" + // ActiveScheduleApplyStatusRejected 表示请求因过期、幂等冲突等业务规则被拒绝。 + ActiveScheduleApplyStatusRejected = "rejected" + // ActiveScheduleApplyStatusExpired 表示预览过期导致不可应用。 + ActiveScheduleApplyStatusExpired = "expired" +) + +const ( + // NotificationRecordStatusPending 表示通知记录已落库,等待投递。 + NotificationRecordStatusPending = "pending" + // NotificationRecordStatusSending 表示当前 worker 正在调用 provider。 + NotificationRecordStatusSending = "sending" + // NotificationRecordStatusSent 表示 provider 明确返回成功。 + NotificationRecordStatusSent = "sent" + // NotificationRecordStatusFailed 表示本次投递失败,但仍可重试。 + NotificationRecordStatusFailed = "failed" + // NotificationRecordStatusDead 表示达到重试上限或不可恢复错误。 + NotificationRecordStatusDead = "dead" + // NotificationRecordStatusSkipped 表示命中去重或配置关闭,本次不投递。 + NotificationRecordStatusSkipped = "skipped" +) + +const ( + // ActiveScheduleTriggerTypeImportantUrgentTask 是重要且紧急任务到线触发。 + ActiveScheduleTriggerTypeImportantUrgentTask = "important_urgent_task" + // ActiveScheduleTriggerTypeUnfinishedFeedback 是用户明确反馈已排任务未完成触发。 + ActiveScheduleTriggerTypeUnfinishedFeedback = "unfinished_feedback" + + // ActiveScheduleSourceWorkerDueJob 表示后台到期 job 扫描触发。 + ActiveScheduleSourceWorkerDueJob = "worker_due_job" + // ActiveScheduleSourceAPITrigger 表示测试/开发 API 正式触发。 + ActiveScheduleSourceAPITrigger = "api_trigger" + // ActiveScheduleSourceAPIDryRun 表示测试/开发 API dry-run,不应发布正式事件。 + ActiveScheduleSourceAPIDryRun = "api_dry_run" + // ActiveScheduleSourceUserFeedback 表示用户反馈入口触发。 + ActiveScheduleSourceUserFeedback = "user_feedback" + + // ActiveScheduleTargetTypeTaskPool 表示 target_id 指向 tasks.id。 + ActiveScheduleTargetTypeTaskPool = "task_pool" + // ActiveScheduleTargetTypeScheduleEvent 表示 target_id 指向 schedule_events.id。 + ActiveScheduleTargetTypeScheduleEvent = "schedule_event" + // ActiveScheduleTargetTypeTaskItem 表示 target_id 指向 task_items.id。 + ActiveScheduleTargetTypeTaskItem = "task_item" +) + +// ActiveScheduleJob 是主动调度 due job 表模型。 +// +// 职责边界: +// 1. 负责记录 task 到达 urgency_threshold_at 后是否需要生成主动调度触发; +// 2. 不负责判断 task 当前是否仍重要且紧急,该判断由 worker 扫描时重新读取真实任务状态; +// 3. 不负责发布 outbox 事件,只保存扫描和排障所需状态。 +type ActiveScheduleJob struct { + ID string `gorm:"column:id;type:varchar(64);primaryKey"` + + UserID int `gorm:"column:user_id;not null;index:idx_active_jobs_user_status_trigger,priority:1;index:idx_active_jobs_task_status,priority:1"` + TaskID int `gorm:"column:task_id;not null;index:idx_active_jobs_task_status,priority:2;comment:对应 tasks.id"` + TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null;default:'important_urgent_task';comment:触发类型"` + Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_active_jobs_user_status_trigger,priority:2;index:idx_active_jobs_task_status,priority:3;comment:pending/triggered/canceled/skipped/failed"` + TriggerAt time.Time `gorm:"column:trigger_at;not null;index:idx_active_jobs_user_status_trigger,priority:3;comment:到期触发时间"` + + DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);index:idx_active_jobs_dedupe;comment:触发去重窗口键"` + LastTriggerID *string `gorm:"column:last_trigger_id;type:varchar(64);index:idx_active_jobs_last_trigger;comment:最近一次生成的 trigger_id"` + LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64);comment:最近一次扫描错误码"` + LastError *string `gorm:"column:last_error;type:text;comment:最近一次扫描错误详情"` + LastScannedAt *time.Time `gorm:"column:last_scanned_at;comment:最近一次被 worker 扫描时间"` + TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_jobs_trace_id"` + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} + +func (ActiveScheduleJob) TableName() string { return "active_schedule_jobs" } + +// ActiveScheduleTrigger 是主动调度统一触发信号表模型。 +// +// 职责边界: +// 1. 负责持久化 worker/API/用户反馈归一后的触发事实; +// 2. 负责串联 trigger -> preview -> notification -> apply 的审计主线; +// 3. 不承载候选生成、LLM 选择或通知投递的业务实现。 +type ActiveScheduleTrigger struct { + ID string `gorm:"column:id;type:varchar(64);primaryKey"` + + UserID int `gorm:"column:user_id;not null;index:idx_active_triggers_user_created,priority:1"` + TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null;index:idx_active_triggers_dedupe,priority:2"` + Source string `gorm:"column:source;type:varchar(64);not null;comment:worker_due_job/api_trigger/api_dry_run/user_feedback"` + TargetType string `gorm:"column:target_type;type:varchar(64);not null;index:idx_active_triggers_target,priority:1"` + TargetID int `gorm:"column:target_id;not null;index:idx_active_triggers_target,priority:2"` + FeedbackID string `gorm:"column:feedback_id;type:varchar(128);index:idx_active_triggers_feedback;comment:用户反馈来源ID,可为空"` + JobID *string `gorm:"column:job_id;type:varchar(64);index:idx_active_triggers_job_id"` + IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);index:idx_active_triggers_idempotency;comment:API/用户反馈幂等键"` + DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);index:idx_active_triggers_dedupe,priority:1;comment:触发去重窗口键"` + Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_active_triggers_status_updated,priority:1"` + MockNow *time.Time `gorm:"column:mock_now;comment:测试触发模拟时间"` + IsMockTime bool `gorm:"column:is_mock_time;not null;default:false;comment:是否使用模拟时间"` + RequestedAt time.Time `gorm:"column:requested_at;not null;comment:触发请求时间"` + PayloadJSON *string `gorm:"column:payload_json;type:json;comment:触发来源补充信息"` + PreviewID *string `gorm:"column:preview_id;type:varchar(64);index:idx_active_triggers_preview_id"` + LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64);comment:链路错误码"` + LastError *string `gorm:"column:last_error;type:text;comment:链路错误详情"` + ProcessedAt *time.Time `gorm:"column:processed_at;comment:worker 开始处理时间"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:本触发进入终态时间"` + TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_triggers_trace_id"` + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_active_triggers_user_created,priority:2"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index:idx_active_triggers_status_updated,priority:2"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} + +func (ActiveScheduleTrigger) TableName() string { return "active_schedule_triggers" } + +// ActiveSchedulePreview 是主动调度可确认预览表模型。 +// +// 职责边界: +// 1. 负责保存主动调度生成的候选、解释、before/after 摘要与过期时间; +// 2. 负责保存一次确认应用的轻量状态,不新增 apply request 表; +// 3. 不负责正式日程写入,正式写入仍由后续 apply/service port 完成。 +type ActiveSchedulePreview struct { + ID string `gorm:"column:preview_id;type:varchar(64);primaryKey;uniqueIndex:uk_active_previews_apply_idempotency,priority:1"` + + UserID int `gorm:"column:user_id;not null;index:idx_active_previews_user_created_at,priority:1"` + TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_active_previews_trigger_id"` + TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null"` + TargetType string `gorm:"column:target_type;type:varchar(64);not null"` + TargetID int `gorm:"column:target_id;not null"` + Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';comment:pending/ready/applied/ignored/expired/failed"` + + SelectedCandidateID string `gorm:"column:selected_candidate_id;type:varchar(64);comment:LLM 或后端 fallback 选中的候选ID"` + CandidateCount int `gorm:"column:candidate_count;not null;default:0"` + SelectedCandidateJSON *string `gorm:"column:selected_candidate_json;type:json"` + CandidatesJSON *string `gorm:"column:candidates_json;type:json"` + DecisionJSON *string `gorm:"column:decision_json;type:json"` + MetricsJSON *string `gorm:"column:metrics_json;type:json"` + IssuesJSON *string `gorm:"column:issues_json;type:json"` + ContextSummaryJSON *string `gorm:"column:context_summary_json;type:json"` + BeforeSummaryJSON *string `gorm:"column:before_summary_json;type:json"` + PreviewChangesJSON *string `gorm:"column:preview_changes_json;type:json"` + AfterSummaryJSON *string `gorm:"column:after_summary_json;type:json"` + RiskJSON *string `gorm:"column:risk_json;type:json"` + ExplanationText string `gorm:"column:explanation_text;type:text"` + NotificationSummary string `gorm:"column:notification_summary;type:text"` + BaseVersion string `gorm:"column:base_version;type:varchar(128);not null;comment:确认前重校验基准版本"` + ExpiresAt time.Time `gorm:"column:expires_at;not null;index:idx_active_previews_expires_at"` + GeneratedAt time.Time `gorm:"column:generated_at;not null"` + + ApplyID *string `gorm:"column:apply_id;type:varchar(64);index:idx_active_previews_apply_id"` + ApplyStatus string `gorm:"column:apply_status;type:varchar(32);not null;default:'none';comment:none/applying/applied/failed/rejected/expired"` + ApplyCandidateID string `gorm:"column:apply_candidate_id;type:varchar(64)"` + ApplyIdempotencyKey string `gorm:"column:apply_idempotency_key;type:varchar(191);uniqueIndex:uk_active_previews_apply_idempotency,priority:2"` + ApplyRequestHash string `gorm:"column:apply_request_hash;type:varchar(128);comment:确认请求体摘要"` + AppliedChangesJSON *string `gorm:"column:applied_changes_json;type:json"` + AppliedEventIDsJSON *string `gorm:"column:applied_event_ids_json;type:json"` + ApplyError *string `gorm:"column:apply_error;type:text"` + AppliedAt *time.Time `gorm:"column:applied_at"` + TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_previews_trace_id"` + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_active_previews_user_created_at,priority:2"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} + +func (ActiveSchedulePreview) TableName() string { return "active_schedule_previews" } + +// NotificationRecord 是通知投递记录表模型。 +// +// 职责边界: +// 1. 负责记录飞书等通知渠道的幂等、状态流转和 provider 返回; +// 2. 不负责决定是否生成调度预览,也不负责 apply 状态; +// 3. 重试时复用同一条记录,避免短时间重复打扰用户。 +type NotificationRecord struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + + Channel string `gorm:"column:channel;type:varchar(32);not null;uniqueIndex:uk_notification_dedupe,priority:1;comment:通知渠道"` + UserID int `gorm:"column:user_id;not null;index:idx_notification_user_created,priority:1"` + TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_notification_trigger"` + PreviewID string `gorm:"column:preview_id;type:varchar(64);not null;index:idx_notification_preview"` + TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null"` + TargetType string `gorm:"column:target_type;type:varchar(64);not null"` + TargetID int `gorm:"column:target_id;not null"` + DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);not null;uniqueIndex:uk_notification_dedupe,priority:2"` + TargetURL string `gorm:"column:target_url;type:varchar(255);not null;comment:站内预览链接"` + SummaryText string `gorm:"column:summary_text;type:text"` + FallbackText string `gorm:"column:fallback_text;type:text"` + FallbackUsed bool `gorm:"column:fallback_used;not null;default:false"` + Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_notification_status_retry,priority:1;comment:pending/sending/sent/failed/dead/skipped"` + AttemptCount int `gorm:"column:attempt_count;not null;default:0"` + MaxAttempts int `gorm:"column:max_attempts;not null;default:5"` + NextRetryAt *time.Time `gorm:"column:next_retry_at;index:idx_notification_status_retry,priority:2"` + LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64)"` + LastError *string `gorm:"column:last_error;type:text"` + + ProviderMessageID *string `gorm:"column:provider_message_id;type:varchar(128)"` + ProviderRequestJSON *string `gorm:"column:provider_request_json;type:json"` + ProviderResponseJSON *string `gorm:"column:provider_response_json;type:json"` + SentAt *time.Time `gorm:"column:sent_at"` + TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_notification_trace_id"` + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_notification_user_created,priority:2"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} + +func (NotificationRecord) TableName() string { return "notification_records" } diff --git a/backend/model/schedule.go b/backend/model/schedule.go index 6a9e4fd..2dc302c 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -3,15 +3,35 @@ package model import "time" type ScheduleEvent struct { - ID int `gorm:"primaryKey;autoIncrement" json:"id"` - UserID int `gorm:"column:user_id;index:idx_user_events;not null" json:"user_id"` - Name string `gorm:"column:name;type:varchar(255);not null;comment:课程或任务名称" json:"name"` - Location *string `gorm:"column:location;type:varchar(255);default:'';comment:地点 (教学楼/会议室)" json:"location"` - Type string `gorm:"column:type;type:enum('course','task');not null;comment:日程类型" json:"type"` - RelID *int `gorm:"column:rel_id;comment:关联原始数据ID (如教务系统的课程ID)" json:"rel_id"` - CanBeEmbedded bool `gorm:"column:can_be_embedded;not null;default:0;comment:是否允许在此时段嵌入其他任务" json:"can_be_embedded"` - StartTime time.Time `gorm:"column:start_time;type:time;comment:开始时间" json:"start_time"` - EndTime time.Time `gorm:"column:end_time;type:time;comment:结束时间" json:"end_time"` + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + UserID int `gorm:"column:user_id;index:idx_user_events;not null" json:"user_id"` + Name string `gorm:"column:name;type:varchar(255);not null;comment:课程或任务名称" json:"name"` + Location *string `gorm:"column:location;type:varchar(255);default:'';comment:地点 (教学楼/会议室)" json:"location"` + Type string `gorm:"column:type;type:enum('course','task');not null;comment:日程类型" json:"type"` + RelID *int `gorm:"column:rel_id;comment:关联原始数据ID (如教务系统的课程ID)" json:"rel_id"` + // TaskSourceType 标记 type=task 时 rel_id 指向哪个任务来源。 + // + // 职责边界: + // 1. 只表达任务日程块的数据来源,不改变 Type 的 course/task 展示语义; + // 2. task_item 表示 rel_id 指向 task_items.id,task_pool 表示 rel_id 指向 tasks.id; + // 3. 课程事件保持空值,由迁移回填历史 task 事件,避免影响现有课程逻辑。 + TaskSourceType string `gorm:"column:task_source_type;type:varchar(32);not null;default:'';index:idx_schedule_event_task_source;comment:任务来源 task_item/task_pool" json:"task_source_type,omitempty"` + // MakeupForEventID 记录补做块来源事件,用于用户反馈未完成后的审计串联。 + // + // 说明: + // 1. 只有主动调度生成补做块时写入; + // 2. 不负责校验目标事件是否仍存在,正式 apply 链路需要在事务内重校验; + // 3. 为空表示普通日程块或非补做块。 + MakeupForEventID *int `gorm:"column:makeup_for_event_id;index:idx_schedule_event_makeup_for;comment:补做块对应的原 schedule_event.id" json:"makeup_for_event_id,omitempty"` + // ActivePreviewID 记录主动调度预览来源,方便从正式日程反查触发链路。 + // + // 说明: + // 1. 该字段只做审计与排障,不作为正式日程主键; + // 2. preview 详情仍归 active_schedule_previews 表所有。 + ActivePreviewID *string `gorm:"column:active_preview_id;type:varchar(64);index:idx_schedule_event_active_preview;comment:主动调度预览ID" json:"active_preview_id,omitempty"` + CanBeEmbedded bool `gorm:"column:can_be_embedded;not null;default:0;comment:是否允许在此时段嵌入其他任务" json:"can_be_embedded"` + StartTime time.Time `gorm:"column:start_time;type:time;comment:开始时间" json:"start_time"` + EndTime time.Time `gorm:"column:end_time;type:time;comment:结束时间" json:"end_time"` } type Schedule struct { diff --git a/backend/model/task.go b/backend/model/task.go index f347355..5aaf517 100644 --- a/backend/model/task.go +++ b/backend/model/task.go @@ -39,6 +39,13 @@ type Task struct { // 7.3 为空表示该任务不参与自动平移; // 7.4 该字段参与"懒触发平移"复合索引。 UrgencyThresholdAt *time.Time `gorm:"column:urgency_threshold_at;index:idx_user_done_threshold_priority,priority:3"` + // 8. 任务预计占用节数。 + // + // 说明: + // 8.1 主动调度只消费该字段,不在调度阶段重新推断任务复杂度; + // 8.2 MVP 约定有效范围为 1~4,模型层仅提供默认值,具体截断由主动调度上下文构造负责; + // 8.3 默认 1 节,兼容历史任务与未显式填写的任务。 + EstimatedSections int `gorm:"column:estimated_sections;not null;default:1"` } type UserAddTaskResponse struct { diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 77cd13d..23c0320 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -112,6 +112,14 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d memoryGroup.DELETE("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.DeleteItem) memoryGroup.POST("/items/:id/restore", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.RestoreItem) } + activeScheduleGroup := apiGroup.Group("/active-schedule") + { + activeScheduleGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + activeScheduleGroup.POST("/dry-run", handlers.ActiveSchedule.DryRun) + activeScheduleGroup.POST("/preview", handlers.ActiveSchedule.CreatePreview) + activeScheduleGroup.GET("/preview/:preview_id", handlers.ActiveSchedule.GetPreview) + activeScheduleGroup.POST("/preview/:preview_id/confirm", handlers.ActiveSchedule.ConfirmPreview) + } } // 初始化Gin引擎 log.Println("Routes setup completed") diff --git a/backend/service/task.go b/backend/service/task.go index f05c7e6..d105b5f 100644 --- a/backend/service/task.go +++ b/backend/service/task.go @@ -38,6 +38,8 @@ type TaskService struct { cache *dao.CacheDAO // eventPublisher 负责发布 outbox 事件(可能为空:例如未启用 Kafka/总线时)。 eventPublisher outboxinfra.EventPublisher + // activeScheduleDAO 负责维护主动调度 due job;为空时保持旧任务链路兼容。 + activeScheduleDAO *dao.ActiveScheduleDAO } // NewTaskService 创建 TaskService 实例。 @@ -53,6 +55,17 @@ func NewTaskService(taskDAO *dao.TaskDAO, cacheDAO *dao.CacheDAO, eventPublisher } } +// SetActiveScheduleDAO 注入主动调度自有表仓储。 +// +// 职责边界: +// 1. 只负责迁移期依赖接线,避免扩大 TaskService 构造函数调用面; +// 2. 不改变任务主流程语义,未注入时主动调度 job 同步自动降级为 no-op。 +func (ts *TaskService) SetActiveScheduleDAO(activeScheduleDAO *dao.ActiveScheduleDAO) { + if ts != nil { + ts.activeScheduleDAO = activeScheduleDAO + } +} + // AddTask 新增任务。 // // 职责边界: @@ -70,6 +83,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques if err != nil { return nil, err } + ts.syncActiveScheduleJobBestEffort(ctx, createdTask) // 4. 返回对外响应 DTO。 response := conv.ModelToUserAddTaskResponse(createdTask) return response, nil @@ -112,6 +126,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete AlreadyCompleted: alreadyCompleted, Status: "completed", } + ts.cancelActiveScheduleJobBestEffort(ctx, updatedTask.UserID, updatedTask.ID, "task_completed") return resp, nil } @@ -488,6 +503,7 @@ func (ts *TaskService) UpdateTask(ctx context.Context, req *model.UserUpdateTask } return model.GetUserTaskResp{}, err } + ts.syncActiveScheduleJobBestEffort(ctx, updatedTask) // 5. 转换为响应 DTO。 return conv.ModelToGetUserTaskResp(updatedTask), nil @@ -515,6 +531,7 @@ func (ts *TaskService) DeleteTask(ctx context.Context, req *model.UserCompleteTa } return 0, err } + ts.cancelActiveScheduleJobBestEffort(ctx, deletedTask.UserID, deletedTask.ID, "task_deleted") return deletedTask.ID, nil } diff --git a/backend/service/task_active_schedule.go b/backend/service/task_active_schedule.go new file mode 100644 index 0000000..413c98e --- /dev/null +++ b/backend/service/task_active_schedule.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" +) + +// syncActiveScheduleJobBestEffort 在任务变更后同步主动调度 due job。 +// +// 职责边界: +// 1. 只维护 important_urgent_task 的 job,不直接触发主动调度主链路; +// 2. 任务未完成且存在 urgency_threshold_at 时 upsert pending job; +// 3. 任务已完成或阈值为空时取消当前 pending job; +// 4. 当前任务接口尚未整体事务化,job 同步失败只记日志,避免任务主写入出现“已落库但接口失败”的更差体验。 +func (ts *TaskService) syncActiveScheduleJobBestEffort(ctx context.Context, task *model.Task) { + if ts == nil || ts.activeScheduleDAO == nil || task == nil { + return + } + if task.IsCompleted || task.UrgencyThresholdAt == nil { + ts.cancelActiveScheduleJobBestEffort(ctx, task.UserID, task.ID, "task_not_schedulable") + return + } + + job := &model.ActiveScheduleJob{ + ID: activeScheduleJobID(task.UserID, task.ID), + UserID: task.UserID, + TaskID: task.ID, + TriggerType: model.ActiveScheduleTriggerTypeImportantUrgentTask, + Status: model.ActiveScheduleJobStatusPending, + TriggerAt: *task.UrgencyThresholdAt, + DedupeKey: activeScheduleTriggerDedupeKey(task.UserID, task.ID, *task.UrgencyThresholdAt), + TraceID: activeScheduleTraceID(task.UserID, task.ID), + } + if err := ts.activeScheduleDAO.CreateOrUpdateJob(ctx, job); err != nil { + log.Printf("主动调度 job upsert 失败: user_id=%d task_id=%d err=%v", task.UserID, task.ID, err) + } +} + +// cancelActiveScheduleJobBestEffort 取消任务当前待触发 job。 +// +// 职责边界: +// 1. 只取消 pending job,历史 triggered/skipped/failed 记录保留审计; +// 2. 找不到 pending job 属于正常幂等场景; +// 3. reason 只进入 last_error_code,方便后续排障知道取消来源。 +func (ts *TaskService) cancelActiveScheduleJobBestEffort(ctx context.Context, userID int, taskID int, reason string) { + if ts == nil || ts.activeScheduleDAO == nil || userID <= 0 || taskID <= 0 { + return + } + job, err := ts.activeScheduleDAO.FindPendingJobByTask(ctx, userID, taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return + } + log.Printf("主动调度 pending job 查询失败: user_id=%d task_id=%d err=%v", userID, taskID, err) + return + } + now := time.Now() + updates := map[string]any{ + "status": model.ActiveScheduleJobStatusCanceled, + "last_error_code": reason, + "last_scanned_at": &now, + } + if err = ts.activeScheduleDAO.UpdateJobFields(ctx, job.ID, updates); err != nil { + log.Printf("主动调度 pending job 取消失败: user_id=%d task_id=%d job_id=%s err=%v", userID, taskID, job.ID, err) + } +} + +func activeScheduleJobID(userID int, taskID int) string { + return fmt.Sprintf("asj_task_%d_%d", userID, taskID) +} + +func activeScheduleTraceID(userID int, taskID int) string { + return fmt.Sprintf("trace_active_task_%d_%d", userID, taskID) +} + +func activeScheduleTriggerDedupeKey(userID int, taskID int, triggerAt time.Time) string { + windowStart := triggerAt.Truncate(30 * time.Minute) + return fmt.Sprintf("%d:%s:%s:%d:%s", + userID, + model.ActiveScheduleTriggerTypeImportantUrgentTask, + model.ActiveScheduleTargetTypeTaskPool, + taskID, + windowStart.Format(time.RFC3339), + ) +} diff --git a/backend/shared/events/active_schedule.go b/backend/shared/events/active_schedule.go new file mode 100644 index 0000000..7610516 --- /dev/null +++ b/backend/shared/events/active_schedule.go @@ -0,0 +1,136 @@ +package events + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + "time" +) + +const ( + ActiveScheduleTriggeredEventType = "active_schedule.triggered" + ActiveScheduleTriggeredEventVersion = "1" +) + +const ( + ActiveScheduleTriggerTypeImportantUrgentTask = "important_urgent_task" + ActiveScheduleTriggerTypeUnfinishedFeedback = "unfinished_feedback" + + ActiveScheduleSourceWorkerDueJob = "worker_due_job" + ActiveScheduleSourceAPITrigger = "api_trigger" + ActiveScheduleSourceAPIDryRun = "api_dry_run" + ActiveScheduleSourceUserFeedback = "user_feedback" + + ActiveScheduleTargetTypeTaskPool = "task_pool" + ActiveScheduleTargetTypeScheduleEvent = "schedule_event" + ActiveScheduleTargetTypeTaskItem = "task_item" +) + +// ActiveScheduleTriggeredPayload 是 active_schedule.triggered 的事件载荷。 +// +// 职责边界: +// 1. 只描述“主动调度链路需要处理一个触发信号”这一事件事实; +// 2. 不复用 GORM model,也不承载候选生成、预览写入或通知投递逻辑; +// 3. Payload 只保留触发源补充 JSON,消费方需要按自身 DTO 再解析。 +type ActiveScheduleTriggeredPayload struct { + TriggerID string `json:"trigger_id"` + UserID int `json:"user_id"` + TriggerType string `json:"trigger_type"` + Source string `json:"source"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + FeedbackID string `json:"feedback_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + DedupeKey string `json:"dedupe_key,omitempty"` + MockNow *time.Time `json:"mock_now,omitempty"` + IsMockTime bool `json:"is_mock_time"` + RequestedAt time.Time `json:"requested_at"` + Payload json.RawMessage `json:"payload,omitempty"` + TraceID string `json:"trace_id,omitempty"` +} + +// Validate 校验事件契约必填字段与第一版枚举范围。 +// +// 职责边界: +// 1. 只做协议级基础校验,避免无效事件进入 worker; +// 2. 不检查 target 是否存在、是否归属用户,这些属于业务读模型责任; +// 3. dry-run 不应发布该事件,因此 source=api_dry_run 会被拒绝。 +func (p ActiveScheduleTriggeredPayload) Validate() error { + if strings.TrimSpace(p.TriggerID) == "" { + return errors.New("trigger_id 不能为空") + } + if p.UserID <= 0 { + return errors.New("user_id 必须大于 0") + } + if !isAllowedActiveScheduleTriggerType(p.TriggerType) { + return errors.New("trigger_type 不在主动调度第一版允许范围内") + } + if !isAllowedActiveScheduleSource(p.Source) { + return errors.New("source 不在主动调度第一版允许范围内") + } + if p.Source == ActiveScheduleSourceAPIDryRun { + return errors.New("api_dry_run 不允许发布 active_schedule.triggered") + } + if !isAllowedActiveScheduleTargetType(p.TargetType) { + return errors.New("target_type 不在主动调度第一版允许范围内") + } + if p.TargetID <= 0 { + return errors.New("target_id 必须大于 0") + } + if p.RequestedAt.IsZero() { + return errors.New("requested_at 不能为空") + } + if p.MockNow != nil && !p.IsMockTime { + return errors.New("mock_now 非空时必须标记 is_mock_time=true") + } + return nil +} + +// MessageKey 返回 outbox/Kafka 消息键。 +// +// 说明: +// 1. 按文档约定使用 user_id,便于同一用户事件在消费侧保持局部有序; +// 2. 只做字符串构造,不访问数据库。 +func (p ActiveScheduleTriggeredPayload) MessageKey() string { + if p.UserID <= 0 { + return "" + } + return strconv.Itoa(p.UserID) +} + +// AggregateID 返回事件聚合 ID。 +// +// 说明: +// 1. active_schedule.triggered 的聚合主键是 trigger_id; +// 2. 若 trigger_id 为空,返回空字符串,由发布方在 Validate 前发现问题。 +func (p ActiveScheduleTriggeredPayload) AggregateID() string { + return strings.TrimSpace(p.TriggerID) +} + +func isAllowedActiveScheduleTriggerType(value string) bool { + switch strings.TrimSpace(value) { + case ActiveScheduleTriggerTypeImportantUrgentTask, ActiveScheduleTriggerTypeUnfinishedFeedback: + return true + default: + return false + } +} + +func isAllowedActiveScheduleSource(value string) bool { + switch strings.TrimSpace(value) { + case ActiveScheduleSourceWorkerDueJob, ActiveScheduleSourceAPITrigger, ActiveScheduleSourceAPIDryRun, ActiveScheduleSourceUserFeedback: + return true + default: + return false + } +} + +func isAllowedActiveScheduleTargetType(value string) bool { + switch strings.TrimSpace(value) { + case ActiveScheduleTargetTypeTaskPool, ActiveScheduleTargetTypeScheduleEvent, ActiveScheduleTargetTypeTaskItem: + return true + default: + return false + } +} diff --git a/backend/shared/events/notification.go b/backend/shared/events/notification.go new file mode 100644 index 0000000..bac4d1e --- /dev/null +++ b/backend/shared/events/notification.go @@ -0,0 +1,82 @@ +package events + +import ( + "errors" + "strconv" + "strings" + "time" +) + +const ( + NotificationFeishuRequestedEventType = "notification.feishu.requested" + NotificationFeishuRequestedEventVersion = "1" +) + +// FeishuNotificationRequestedPayload 是飞书通知请求事件载荷。 +// +// 职责边界: +// 1. 只描述“需要尝试发送一条飞书提醒”的跨模块协议; +// 2. 不包含 provider SDK 参数,也不复用 notification_records 的 GORM model; +// 3. 不决定是否真正投递,去重、配置关闭和重试由 notification 模块处理。 +type FeishuNotificationRequestedPayload struct { + NotificationID int64 `json:"notification_id,omitempty"` + UserID int `json:"user_id"` + TriggerID string `json:"trigger_id"` + PreviewID string `json:"preview_id"` + TriggerType string `json:"trigger_type"` + TargetType string `json:"target_type"` + TargetID int `json:"target_id"` + DedupeKey string `json:"dedupe_key"` + TargetURL string `json:"target_url"` + SummaryText string `json:"summary_text,omitempty"` + FallbackText string `json:"fallback_text,omitempty"` + TraceID string `json:"trace_id,omitempty"` + RequestedAt time.Time `json:"requested_at"` +} + +// Validate 校验飞书通知事件的协议级必填字段。 +// +// 职责边界: +// 1. 只保证通知 handler 能定位用户、预览和去重键; +// 2. 不校验用户是否绑定飞书,也不调用 provider; +// 3. target_url 必须是站内相对路径,避免事件载荷携带任意外部跳转。 +func (p FeishuNotificationRequestedPayload) Validate() error { + if p.UserID <= 0 { + return errors.New("user_id 必须大于 0") + } + if strings.TrimSpace(p.PreviewID) == "" { + return errors.New("preview_id 不能为空") + } + if strings.TrimSpace(p.DedupeKey) == "" { + return errors.New("dedupe_key 不能为空") + } + targetURL := strings.TrimSpace(p.TargetURL) + if targetURL == "" { + return errors.New("target_url 不能为空") + } + if !strings.HasPrefix(targetURL, "/schedule-adjust/") { + return errors.New("target_url 必须是 /schedule-adjust/{preview_id} 站内相对路径") + } + if strings.Contains(targetURL, "://") || strings.HasPrefix(targetURL, "//") { + return errors.New("target_url 不允许携带外部链接") + } + if p.RequestedAt.IsZero() { + return errors.New("requested_at 不能为空") + } + return nil +} + +// MessageKey 返回 outbox/Kafka 消息键。 +func (p FeishuNotificationRequestedPayload) MessageKey() string { + if p.UserID <= 0 { + return "" + } + return strconv.Itoa(p.UserID) +} + +// AggregateID 返回事件聚合 ID。 +// +// 说明:notification.feishu.requested 使用 preview_id 串联通知与预览。 +func (p FeishuNotificationRequestedPayload) AggregateID() string { + return strings.TrimSpace(p.PreviewID) +} diff --git a/backend/shared/events/schedule_apply.go b/backend/shared/events/schedule_apply.go new file mode 100644 index 0000000..1becd73 --- /dev/null +++ b/backend/shared/events/schedule_apply.go @@ -0,0 +1,76 @@ +package events + +import ( + "errors" + "strconv" + "strings" +) + +const ( + ScheduleApplySucceededEventType = "schedule.apply.succeeded" + ScheduleApplyFailedEventType = "schedule.apply.failed" + ScheduleApplyResultEventVersion = "1" +) + +// ScheduleApplyResultPayload 是主动调度确认应用的结果事件载荷。 +// +// 职责边界: +// 1. 只描述 apply 成功或失败的结果事实,不表达 apply 请求; +// 2. 不复用 preview DB model,也不携带完整变更明细; +// 3. MVP 第一版 confirm 同步执行,是否发布该事件由后续接入点决定。 +type ScheduleApplyResultPayload struct { + PreviewID string `json:"preview_id"` + ApplyID string `json:"apply_id"` + UserID int `json:"user_id"` + TriggerID string `json:"trigger_id,omitempty"` + CandidateID string `json:"candidate_id,omitempty"` + AppliedEventIDs []int `json:"applied_event_ids,omitempty"` + ApplyStatus string `json:"apply_status"` + ErrorCode string `json:"error_code,omitempty"` + TraceID string `json:"trace_id,omitempty"` +} + +// ValidateForEvent 按具体事件类型校验 apply 结果载荷。 +// +// 职责边界: +// 1. succeeded 要求至少包含一个正式日程 event id; +// 2. failed 要求 error_code,方便排障和前端提示映射; +// 3. 不校验 event id 是否存在,正式落库事务负责保证。 +func (p ScheduleApplyResultPayload) ValidateForEvent(eventType string) error { + if strings.TrimSpace(p.PreviewID) == "" { + return errors.New("preview_id 不能为空") + } + if strings.TrimSpace(p.ApplyID) == "" { + return errors.New("apply_id 不能为空") + } + if p.UserID <= 0 { + return errors.New("user_id 必须大于 0") + } + switch strings.TrimSpace(eventType) { + case ScheduleApplySucceededEventType: + if len(p.AppliedEventIDs) == 0 { + return errors.New("schedule.apply.succeeded 必须包含 applied_event_ids") + } + case ScheduleApplyFailedEventType: + if strings.TrimSpace(p.ErrorCode) == "" { + return errors.New("schedule.apply.failed 必须包含 error_code") + } + default: + return errors.New("未知的 schedule apply 结果事件类型") + } + return nil +} + +func (p ScheduleApplyResultPayload) MessageKey() string { + if p.UserID <= 0 { + return "" + } + return strconv.Itoa(p.UserID) +} + +func (p ScheduleApplyResultPayload) AggregateID() string { + if strings.TrimSpace(p.ApplyID) != "" { + return strings.TrimSpace(p.ApplyID) + } + return strings.TrimSpace(p.PreviewID) +} diff --git a/docs/backend/第二阶段主动调度MVP实现方案.md b/docs/backend/第二阶段主动调度MVP实现方案.md index 0e87bde..41cf2f1 100644 --- a/docs/backend/第二阶段主动调度MVP实现方案.md +++ b/docs/backend/第二阶段主动调度MVP实现方案.md @@ -2,7 +2,7 @@ ## 0. Handoff 说明 -本文档仍在讨论中,尚未进入代码实现阶段。接手者请优先阅读本节和第 12 / 13 节,再继续补齐未拍板问题。 +本文档已收口为第二阶段主动调度 MVP 的最终实施版。截至 2026-04-30,后端第一至第三阶段已实现并通过本地 API + DB 验收;接手者请优先阅读本节、第 10 章装配边界和第 14 章验证 checklist,再从第四阶段继续推进。 当前核心共识: @@ -20,42 +20,159 @@ 10. 用户确认入口走主动调度详情页和确认 API,不走 Agent resume;详情页采用助手卡片式体验,支持拖动 after 方案后确认。 11. 预览有效期 1 小时。 12. 未完成补救第一版只生成新补做块,不直接移动原已排任务。 +13. `schedule.apply.requested` 第一版不走 outbox 异步消费,确认 API 内同步完成重校验和正式应用;成功 / 失败直接回写预览状态。 +14. 应用幂等使用独立 `apply_id + idempotency_key`,`preview_id + candidate_id` 只用于定位候选,不作为一次确认尝试的幂等键。 +15. 飞书通知必须包含唯一预览链接 `/schedule-adjust/{preview_id}`;通知文案优先由 LLM 生成摘要,固定模板仅作为失败兜底。 +16. 飞书通知幂等按 `user_id + trigger_type + time_window` 聚合,不按 `preview_id`;第一版落 `notification_records` 表支撑可观测与失败重试。 +17. `api / worker / all` 启动边界第一阶段已完成;主动调度 MVP 可直接挂到 worker / 事件链路,不需要等待启动边界拆分。 +18. 主动调度第一版采用“准独立模块”策略:不放进 `backend/service/active_scheduler`,而是放在 `backend/active_scheduler`;MVP 暂不拆独立 Go module / 独立进程。 +19. 事件契约第一版提前放入 `backend/shared/events`,只承载 event type、event version、payload DTO 和基础校验,不放业务逻辑。 +20. 主动调度采用 port / adapter 依赖边界:主链路不散落依赖其它领域 DAO;自有表用自有 repo;读取外部事实走 reader port;正式写入走 apply/service port。 +21. 主动调度验收以“后端链路可观测 + 动作-预期 checklist”为准,覆盖 dry-run、trigger、worker、preview、notification、confirm apply、幂等、过期和失败回写。 +22. 本轮给 `tasks` 新增 `estimated_sections`,默认 1,MVP 允许 1~4 节;主动调度只消费该字段,不在调度阶段重新推断任务复杂度。 +23. 本轮给 `schedule_events` 新增来源与审计字段:`task_source_type / makeup_for_event_id / active_preview_id`。 +24. `compress_with_next_dynamic_task` 第一轮实现先关闭,不生成该候选;保留 schema 和文档口径,待新增补做块主链路稳定后再打开。 +25. 飞书第一版使用 mock / webhook 跑通主动触达闭环,不阻塞在用户 open_id 绑定体系上。 +26. notification 去重窗口第一版固定为 30 分钟。 -当前仍需拍板的问题: +### 0.1 多阶段推进计划 -1. `schedule.apply.requested` 第一版到底如何切分同步 / 异步: - - 已讨论倾向:确认接口同步写 Redis 状态和轻量校验;MySQL 正式写入可异步,因为重校验可能需要几百毫秒。 - - 待继续明确:是否必须通过 outbox / apply request 表保证异步 apply 请求可恢复,还是 MVP 先同步调用 service。 -2. 应用幂等键: - - 已解释:`preview_id + candidate_id` 只能定位候选;若支持拖动,实际 apply 内容可能不同。 - - 待拍板:是否使用独立 `apply_id`,并用 `idempotency_key` 绑定一次确认尝试。 -3. 飞书通知: - - 固定文案是否足够。 - - 跳转 URL 规则。 - - 通知幂等键按 `preview_id` 还是其它组合。 - - 第一版是否落 `notification_records` 表,还是先只记录日志 / outbox 状态。 -4. 主动调度代码目录和迁移边界: - - 第一版放 `backend/service/active_scheduler` 还是新建更独立的 `backend/active_scheduler`。 - - 事件契约是否提前放到 shared/events 风格目录。 -5. `active_schedule_jobs / active_schedule_triggers / active_schedule_previews / apply request` 的具体表结构和状态机。 -6. task_pool 任务预计节数字段是否本轮加到 `tasks`,还是 MVP 先默认 1 节并在预览中要求用户确认。 +第一阶段:数据结构与事件契约。(已完成) -建议下一轮继续顺序: +1. 新增迁移:`tasks.estimated_sections`、`schedule_events.task_source_type / makeup_for_event_id / active_preview_id`、`active_schedule_jobs`、`active_schedule_triggers`、`active_schedule_previews`、`notification_records`。 +2. 新增 `backend/shared/events` 下的主动调度、通知、apply 结果事件契约。 +3. 先补 repo / model / validate,不接 LLM、不接 provider。 -1. 先拍板 apply 同步 / 异步和 `apply_id`。 -2. 再拍板主动调度相关表结构与状态机。 -3. 再拍板飞书通知最小实现。 -4. 最后补详细执行计划:目录、DTO、迁移 SQL、API、worker handler、测试。 +第二阶段:主动调度 dry-run 主链路。(已完成) + +1. 落 `backend/active_scheduler` 目录骨架、ports、adapters。 +2. 实现 `BuildContext -> Observe -> GenerateCandidates`。 +3. 先只支持 `important_urgent_task` 的 `add_task_pool_to_schedule` 和 `unfinished_feedback` 的 `create_makeup / ask_user / notify_only`。 +4. `compress_with_next_dynamic_task` 首轮关闭,不生成候选。 + +第三阶段:预览与确认。(已完成) + +1. 实现 `active_schedule_previews` 写入与详情查询。 +2. 实现 confirm API:`apply_id + idempotency_key`、过期校验、`edited_changes` 重校验、同步 apply。 +3. task_pool 正式落库写 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)`。 +4. 补做块新增 event,不移动原已排任务。 + +第四阶段:worker 与 notification。(待实施) + +1. 接入 `active_schedule.triggered` worker handler 和 due job scanner。 +2. 接入 `notification.feishu.requested` handler。 +3. 先使用 mock provider,再接测试 webhook。 +4. `notification_records` 支持幂等、状态流转和 provider retry。 + +第五阶段:端到端验收与收口。(待实施) + +1. 跑通 `api / worker / all` 三种启动模式。 +2. 按第 14 章 checklist 验证 dry-run、trigger、preview、notification、confirm apply、失败注入。 +3. 根据日志和测试结果补齐 trace 字段与错误码。 +4. 主链路稳定后再评估是否打开压缩融合候选。 + +### 0.2 子代理并行推进计划 + +可在实现阶段使用 3 到 5 个子代理并行推进,但必须按文件所有权拆分,避免互相覆盖。 + +1. 子代理 A:数据与契约。 + - 负责 migrations、model、repo、`backend/shared/events`。 + - 不改 API handler、不改 active_scheduler pipeline。 +2. 子代理 B:主动调度核心。 + - 负责 `backend/active_scheduler/context / observe / candidate / selection / timegrid / ports`。 + - 不改正式 apply、不改 notification provider。 +3. 子代理 C:预览与 apply。 + - 负责 `backend/active_scheduler/preview / apply / apply/convert` 和 confirm 相关服务。 + - 不改 worker handler、不改 notification。 +4. 子代理 D:worker 与 notification。 + - 负责 `backend/service/events` 中主动调度与通知 handler、`backend/notification`、retry scanner。 + - 不改 active_scheduler 核心候选逻辑。 +5. 子代理 E:API 与验证。 + - 负责 `backend/api/active_schedule.go`、路由接入、端到端测试脚本 / checklist 验证。 + - 不改底层 repo 和 provider。 + +并行规则: + +1. 每个子代理只改自己负责的目录;跨目录依赖通过接口或临时占位实现对齐。 +2. 先由子代理 A 完成事件契约和表结构,其他子代理基于契约开发。 +3. 合并顺序建议:A -> B -> C -> D -> E。 +4. 每轮集成后运行相关 Go 测试;按项目规则测试后清理 `.gocache`。 +5. 若发现公共能力第三次复制,暂停并抽公共 helper,不让并行开发制造长期重复实现。 + +### 0.3 当前实现状态与接力记录 + +本节用于新对话接手后快速对齐当前代码状态,避免重新翻历史讨论。 + +已完成阶段: + +1. 第一阶段:数据结构与事件契约。 + - 已新增 `tasks.estimated_sections`,默认 1。 + - 已新增 `schedule_events.task_source_type / makeup_for_event_id / active_preview_id`。 + - 已新增主动调度相关 model / DAO / 事件契约:`backend/model/active_schedule.go`、`backend/dao/active_schedule.go`、`backend/shared/events`。 + - AutoMigrate 已接入,并对历史 `schedule_events.type=task` 做 `task_source_type=task_item` 回填。 +2. 第二阶段:主动调度 dry-run 主链路。 + - 已落 `backend/active_scheduler` 准独立模块骨架。 + - 已实现 `BuildContext -> Observe -> GenerateCandidates`。 + - 已开放 `POST /api/v1/active-schedule/dry-run`。 + - task 创建 / 更新会 upsert `active_schedule_jobs`;task 完成 / 删除会取消 pending job。 +3. 第三阶段:预览与确认。 + - 已开放: + ```text + POST /api/v1/active-schedule/preview + GET /api/v1/active-schedule/preview/:preview_id + POST /api/v1/active-schedule/preview/:preview_id/confirm + ``` + - 已实现 preview 写入、详情查询、`apply_id + idempotency_key`、候选转换、同步 apply adapter。 + - `add_task_pool_to_schedule` 已能正式写入 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)` 和对应 `schedules`。 + - `create_makeup` 转换与 adapter 已预留并实现基本写入路径,但尚需在第四 / 第五阶段结合正式 unfinished feedback worker 场景补端到端验收。 + +本轮实测结果: + +1. 测试账号:`test0430 / 123456`,当前本地环境 user_id 为 3。 +2. API 验证链路: + - 创建测试任务:`task_id=19`。 + - 生成 preview:`preview_id=asp_3bb18dcf-bd3a-433d-99ca-7ffadc1d6368`。 + - 后端候选:`candidate_id=add_task_pool_to_schedule:19:9:4:3`,`candidate_type=add_task_pool_to_schedule`。 + - confirm 成功:`apply_id=asap_19a3c6ae1cd7d308dc6b4fe2`。 +3. DB 验证结果: + - `active_schedule_previews.status=applied`,`apply_status=applied`,`applied_event_ids_json=[423]`。 + - `schedule_events.id=423`,`user_id=3`,`type=task`,`task_source_type=task_pool`,`rel_id=19`,`active_preview_id=asp_3bb18dcf-bd3a-433d-99ca-7ffadc1d6368`。 + - `schedules.id=877`,`event_id=423`,`week=9`,`day_of_week=4`,`section=3`,`status=normal`。 +4. 幂等验证: + - 使用同一 `preview_id + idempotency_key` 重复 confirm,返回同一 `apply_id` 和同一 `event_id=423`。 + - DB 中该 `active_preview_id` 只对应 1 条 `schedule_events` 和 1 条 `schedules`。 +5. 测试命令: + - 已在 `backend` 目录执行 `go test ./...` 并通过。 + - 已按项目规则清理根目录 `.gocache`。 + +下一阶段入口: + +1. 第四阶段从 worker 与 notification 开始,不需要重做 dry-run / preview / confirm 主链路。 +2. 重点实现: + - `active_schedule.triggered` worker handler。 + - due job scanner:扫描到期 `active_schedule_jobs`,生成正式 trigger。 + - `notification_records` 状态机与 repo。 + - `notification.feishu.requested` handler。 + - 飞书 mock / webhook provider,通知链接固定为 `/schedule-adjust/{preview_id}`。 + - LLM summary 优先,固定模板 fallback。 + - 通知去重窗口固定 30 分钟,按 `user_id + trigger_type + time_window` 聚合。 +3. 第五阶段再做完整端到端收口: + - `api / worker / all` 三种启动方式。 + - `important_urgent_task` 与 `unfinished_feedback` 两条主触发。 + - notification 成功 / 失败 / 重试。 + - confirm apply 成功、冲突失败、过期拒绝、重复提交幂等。 +4. 工作区注意: + - 另一个前端对话可能在改前端;后端阶段不要碰 `frontend` 相关改动。 + - 当前允许单个 Go 文件 700 行以内;超过 700 再评估拆分。 + - 每次执行 `go test` 后必须清理根目录 `.gocache`。 + - 后续阶段必须优先自动化验收:能由代码、API、DB 查询、日志查询验证的内容,由实现者自己跑完并记录结果。 + - 如果受限于外部账号、真实飞书环境、浏览器人工交互、权限或本地环境,导致某项验收无法完成,不能默认为通过,也不能在报告中省略;必须明确写出未验收项、阻塞原因、建议由用户执行的操作和预期结果。 ## 1. 文档目的 本文档承接《第二阶段主动调度 MVP 功能预期》和《微服务四步迁移与第二阶段并行开发计划》,用于把产品预期逐步落成可执行的工程方案。 -本轮讨论采用“先业务逻辑,后执行计划”的方式推进: - -1. 先按模块说明业务实现逻辑,确认这件事在产品上到底怎么流转。 -2. 再列出需要拍板的问题,避免工程方案提前固化错误边界。 -3. 等业务逻辑讨论完成后,再把详细执行计划、文件改动、测试方式补进对应模块。 +本文档已经完成业务逻辑、工程边界、执行计划和验证流程收口。实现时按第 0.1 节阶段推进,遇到未覆盖细节时优先遵循第 2 章总体原则和第 10 章迁移边界。 ## 2. 总体实现原则 @@ -80,8 +197,8 @@ -> 发布 schedule.preview.generated -> 发布 notification.feishu.requested -> 用户回系统查看并按候选确认 - -> schedule.apply.requested - -> 复用正式应用链路 + -> 确认 API 生成 apply_id 并同步重校验 + -> 复用正式应用链路写入 MySQL -> schedule.apply.succeeded / schedule.apply.failed ``` @@ -97,7 +214,7 @@ 三类入口最终都归一成同一个 `ActiveScheduleTrigger`,再进入同一条观测链路。 -### 4.2 需要拍板的问题 +### 4.2 已拍板结论 1. 第一版触发源是否只做两个:`important_urgent_task` 和 `unfinished_feedback`? - 已确认:第一版先做这两类主触发。`fatigue_feedback` 可作为用户反馈类的后续扩展,不抢第一轮主链路。 @@ -108,15 +225,295 @@ 4. 同一用户短时间多次触发的去重窗口设多长? - 已确认:`important_urgent_task` 按 `user_id + trigger_type + target_task_id` 做 30 分钟去重;`unfinished_feedback` 按用户反馈的 `feedback_id / idempotency_key` 防重复提交,不做固定时间窗强去重。 -### 4.3 待补执行计划 +### 4.3 执行计划:触发入口与事件契约 -业务逻辑确认后补充: +本模块只负责把各类入口统一归一成 `ActiveScheduleTrigger`,并决定同步 dry-run、正式 trigger、worker due job 和用户反馈如何进入同一条主动调度 pipeline。上下文构造、候选生成、预览写入和通知投递的内部 schema 分别在后续模块细化。 -1. DTO 字段定义。 -2. 事件名、event_version、payload 示例。 -3. API trigger / dry-run 路由设计。 -4. worker handler 注册位置。 -5. 单元测试与集成测试方案。 +#### 4.3.1 代码落点 + +1. 事件契约: + ```text + backend/shared/events/active_schedule.go + ``` + 只放 event type、event version、payload DTO、基础校验和消息键构造。 +2. 主动调度触发入口: + ```text + backend/active_scheduler/trigger + ``` + 负责 trigger DTO、幂等判断、trigger 记录写入和正式 pipeline 入口编排。 +3. API handler: + ```text + backend/api/active_schedule.go + ``` + 只负责鉴权用户、绑定请求、调用 active_scheduler service,不直接构造候选。 +4. 路由注册: + ```text + backend/routers + ``` + 按现有鉴权路由风格挂载 dry-run、trigger、preview 查询和 confirm;本节只补 dry-run / trigger。 +5. worker handler: + ```text + backend/service/events/active_schedule_triggered.go + ``` + 只负责消费事件、解析 payload、调用 active_scheduler trigger service。 +6. due job 扫描器: + ```text + backend/active_scheduler/job + ``` + 负责扫描到期 `active_schedule_jobs`,重新读取 task 真值后生成 trigger。 + +#### 4.3.2 DTO 字段定义 + +`ActiveScheduleTrigger` 是内部统一输入,建议字段如下: + +```text +trigger_id # active_schedule_triggers.id;dry-run 可为空 +user_id +trigger_type # important_urgent_task / unfinished_feedback +source # worker_due_job / api_trigger / api_dry_run / user_feedback +target_type # task_pool / schedule_event / task_item +target_id +feedback_id # unfinished_feedback 场景使用,可为空 +idempotency_key # API / 用户反馈幂等键 +dedupe_key # important_urgent_task 30 分钟去重键,或 feedback 幂等键 +mock_now +is_mock_time +requested_at +payload # 触发源补充信息,JSON DTO,不塞任意 map +trace_id +``` + +`trigger_type` 第一版只允许: + +```text +important_urgent_task +unfinished_feedback +``` + +`source` 第一版只允许: + +```text +worker_due_job +api_trigger +api_dry_run +user_feedback +``` + +`target_type` 第一版建议允许: + +```text +task_pool # rel_id / target_id 指向 tasks.id +schedule_event # 用户反馈“某个已排日程没完成” +task_item # 后续补救或明确定位 task_item 时使用 +``` + +#### 4.3.3 事件契约 + +事件名: + +```text +active_schedule.triggered +``` + +版本: + +```text +event_version = 1 +``` + +payload 示例: + +```json +{ + "trigger_id": "ast_123", + "user_id": 10001, + "trigger_type": "important_urgent_task", + "source": "worker_due_job", + "target_type": "task_pool", + "target_id": 345, + "idempotency_key": "", + "dedupe_key": "10001:important_urgent_task:task_pool:345:2026-04-30T10:00", + "mock_now": null, + "is_mock_time": false, + "requested_at": "2026-04-30T10:00:00+08:00", + "payload": { + "job_id": "asj_789", + "urgency_threshold_at": "2026-04-30T10:00:00+08:00" + }, + "trace_id": "trace_xxx" +} +``` + +消息键建议: + +```text +message_key = user_id +aggregate_id = trigger_id +``` + +规则: + +1. `active_schedule.triggered` 只表示“主动调度链路需要处理一个触发信号”,不表示已经生成 preview。 +2. payload 必须带 `trigger_id`,方便后续串联 `trigger -> preview -> notification -> apply`。 +3. dry-run 不发布该事件。 +4. API trigger、worker due job、用户反馈正式触发都可以发布该事件。 +5. 消费者必须按 `event_type + event_version` 解析,不直接依赖 active_scheduler 内部 struct。 + +#### 4.3.4 API 路由设计 + +建议新增鉴权接口: + +```text +POST /active-schedule/dry-run +POST /active-schedule/trigger +``` + +`dry-run` 请求: + +```json +{ + "trigger_type": "important_urgent_task", + "target_type": "task_pool", + "target_id": 345, + "mock_now": "2026-04-30T10:00:00+08:00", + "payload": {} +} +``` + +`dry-run` 响应: + +```json +{ + "trigger": {}, + "context_summary": {}, + "issues": [], + "decision": {}, + "candidates": [] +} +``` + +`trigger` 请求: + +```json +{ + "trigger_type": "important_urgent_task", + "target_type": "task_pool", + "target_id": 345, + "mock_now": "2026-04-30T10:00:00+08:00", + "idempotency_key": "client-generated-key", + "payload": {} +} +``` + +`trigger` 响应: + +```json +{ + "trigger_id": "ast_123", + "status": "pending", + "deduped": false +} +``` + +接口语义: + +1. `dry-run` 同步执行到 decision / candidates,绝不写 `active_schedule_triggers / active_schedule_previews / notification_records`。 +2. `dry-run` 允许 `mock_now`,但必须在返回 trace 中标记 `is_mock_time=true`。 +3. `trigger` 走正式链路,先写 trigger,再发布 `active_schedule.triggered`,由 worker 消费生成 preview 和 notification。 +4. `trigger` 允许 `mock_now`,但必须持久化 `is_mock_time=true`,避免排障误判。 +5. 后台 worker due job 不允许 `mock_now`,必须使用真实当前时间。 + +#### 4.3.5 幂等与去重 + +`important_urgent_task`: + +```text +dedupe_key = user_id + trigger_type + target_type + target_id + 30分钟窗口 +``` + +预期行为: + +1. 30 分钟内命中相同 dedupe key 时,不重复写新 preview,不重复发飞书。 +2. 若已有 trigger 仍在 `pending / processing / preview_generated`,直接返回已有 trigger 状态。 +3. 若上一轮 `failed`,是否允许重新触发由表结构状态机阶段细化;MVP 倾向允许人工测试 trigger 重新触发,但必须生成新的 trace。 + +`unfinished_feedback`: + +```text +dedupe_key = user_id + trigger_type + feedback_id/idempotency_key +``` + +预期行为: + +1. 不做固定 30 分钟窗口强去重。 +2. 同一 `feedback_id / idempotency_key` 重复提交时返回已有 trigger。 +3. 用户连续表达“还是没做完”时,只要反馈 ID 或幂等键不同,就允许进入新的补救链路。 + +#### 4.3.6 worker handler 流程 + +worker 消费 `active_schedule.triggered`: + +```text +1. 解析 shared/events payload。 +2. 校验 trigger_id / user_id / trigger_type / target_type / target_id。 +3. 查询 active_schedule_triggers 当前状态。 +4. 若状态已完成或已跳过,直接幂等返回。 +5. 将 trigger 标记为 processing。 +6. 调用 active_scheduler pipeline: + BuildContext -> Observe -> GenerateCandidates -> LLMSelectAndExplain -> WritePreview -> Notify +7. 成功写 preview 后,将 trigger 标记为 preview_generated。 +8. 若无 issue 或后端裁决 close,将 trigger 标记为 skipped/closed,并记录 reason。 +9. 失败则标记 failed,写 error,保留 outbox 重试语义。 +``` + +due job 扫描器流程: + +```text +1. 扫描 due 且未完成的 active_schedule_jobs。 +2. 重新读取 task 真值。 +3. task 已完成 -> job 标记 canceled/skipped。 +4. task 不再满足重要且紧急 -> job 标记 skipped。 +5. task 已进入 schedule -> job 标记 skipped。 +6. 仍需主动调度 -> 写 trigger 并发布 active_schedule.triggered。 +``` + +#### 4.3.7 错误处理与可观测 + +1. payload 解析失败:outbox 标记 dead,记录解析错误。 +2. 参数非法:trigger 标记 failed 或 rejected,记录原因,不进入 pipeline。 +3. 幂等命中:不视为错误,返回已有 trigger / preview 状态。 +4. pipeline 失败:trigger 标记 failed,保留 error message 和 trace。 +5. preview 写入失败:不发布 notification。 +6. notification 发布失败:preview 保留,trigger 可标记 preview_generated,但 notification 状态由 notification 模块记录。 +7. 所有正式 trigger 必须能通过 `trace_id / trigger_id / target_id` 查到链路日志。 + +#### 4.3.8 测试方案 + +单元测试: + +1. `trigger_type / source / target_type` 枚举校验。 +2. `mock_now` 只在 `api_dry_run / api_trigger` 允许。 +3. `important_urgent_task` dedupe key 生成。 +4. `unfinished_feedback` idempotency key 生成。 +5. `active_schedule.triggered` payload validate。 +6. dry-run 不写 trigger / preview / notification。 + +集成测试: + +1. API `dry-run` 返回 diagnosis / candidates,不落库。 +2. API `trigger` 写 `active_schedule_triggers` 并发布 `active_schedule.triggered`。 +3. worker 消费事件后推进 trigger 状态到 `processing -> preview_generated`。 +4. 30 分钟内重复 `important_urgent_task` 触发命中去重。 +5. 相同 `unfinished_feedback.idempotency_key` 重复提交命中幂等。 +6. due job 到期但 task 已完成时标记 skipped/canceled,不写 preview。 +7. payload 非法时 outbox dead 或 trigger failed,错误可查询。 + +人工验收: + +1. 使用 dry-run 验证某个 task_pool 任务能生成候选。 +2. 使用 trigger 验证 worker 能写 preview。 +3. 重复点击 trigger,确认不重复生成多条 preview 和飞书通知。 +4. 修改 task 为 completed 后触发 due job,确认不会进入主动调度链路。 ## 5. 模块二:ActiveScheduleContext 构造 @@ -126,7 +523,7 @@ 上下文构造阶段需要先触发或复用四象限紧急性派生,避免后台读到懒加载前的旧任务池。 -### 5.2 需要拍板的问题 +### 5.2 已拍板结论 1. 滚动 24 小时如何映射到当前“周 + 星期 + 节次”模型?是否第一版只按节次粒度处理? - 已确认:候选窗口按任务 DDL / 当前滚动 24 小时映射到现有相对时间坐标(week/day_of_week/section),正式写入仍同时维护 schedule 现有的绝对时间与相对时间字段。 @@ -138,14 +535,404 @@ 4. 近期用户反馈是否第一版只作为 trigger payload,不落数据库状态? - 已确认:用户反馈类触发信号需要持久化,但不面向前端展示;主要用于幂等、审计、排障和串联 trigger -> preview -> notification -> apply 链路。 -### 5.3 待补执行计划 +### 5.3 执行计划:ActiveScheduleContext 构造 -业务逻辑确认后补充: +本模块只负责把触发信号转换成主动观测所需的事实快照,不负责生成候选、不调用 LLM、不写 preview。上下文构造必须尽量确定性、可测试、可排障。 -1. `ActiveScheduleContext` 结构。 -2. 任务、日程、偏好、反馈的读取入口。 -3. 四象限刷新复用方案。 -4. 时间窗转换与边界兜底。 +#### 5.3.1 代码落点 + +1. Context DTO: + ```text + backend/active_scheduler/context + ``` +2. 读取端口定义: + ```text + backend/active_scheduler/ports + ``` +3. 本地 adapter: + ```text + backend/active_scheduler/adapters + ``` +4. 时间窗与节次转换辅助: + ```text + backend/active_scheduler/timegrid + ``` +5. 与既有公共能力复用: + - 优先复用 `conv.RealDateToRelativeDate`、`conv.RelativeTimeToRealTime` 等现有时间转换能力。 + - 若需要新的滚动窗口到节次格转换,放入 `timegrid`,避免散落在 observe / candidate 里。 + +#### 5.3.2 ActiveScheduleContext 结构 + +建议结构方向: + +```text +ActiveScheduleContext + Trigger + User + Now + Window + Target + TaskPoolFacts + ScheduleFacts + TaskClassFacts + PreferenceFacts + FeedbackFacts + DerivedFacts + Trace +``` + +字段语义: + +```text +Trigger + trigger_id + trigger_type + source + target_type + target_id + is_mock_time + payload + +User + user_id + timezone + +Now + real_now # 后台真实当前时间 + effective_now # dry-run / trigger 可使用 mock_now + +Window + start_at + end_at + relative_slots # week/day_of_week/section 原子格列表 + window_reason # rolling_24h / task_deadline / task_class_end_date + +Target + source_type # task_pool / schedule_event / task_item + task_id + schedule_event_id + task_item_id + title + estimated_sections + deadline_at + urgency_threshold_at + priority + status + +TaskPoolFacts + target_task + urgent_unscheduled_tasks + +ScheduleFacts + events + occupied_slots + free_slots + next_dynamic_task + +TaskClassFacts + task_class + affected_items + constraints + +PreferenceFacts + memory_context_text + memory_items + task_class_constraints + preference_source + +FeedbackFacts + feedback_id + feedback_text + feedback_target + +DerivedFacts + target_already_scheduled + target_completed + available_capacity + missing_info + +Trace + trace_id + build_steps + warnings +``` + +约束: + +1. `ActiveScheduleContext` 是只读快照,不包含 DAO / service 实例。 +2. context 中的时间统一使用带时区的绝对时间,同时保留相对节次格。 +3. context 中只放主动观测需要的事实,不塞完整数据库 model。 +4. `missing_info` 是正常输出,用于后续裁决 `ask_user / notify_only`,不是构造失败。 + +#### 5.3.3 读取端口 + +主动调度 pipeline 只依赖 port,不直接 import 其它领域 DAO。 + +建议端口: + +```go +type TaskReader interface { + GetTaskForActiveSchedule(...) + ListUrgentUnscheduledTasks(...) + IsTaskScheduled(...) +} + +type ScheduleReader interface { + GetScheduleFactsByWindow(...) + GetFreeSlots(...) + GetNextDynamicTask(...) + HasSlotConflict(...) +} + +type TaskClassReader interface { + GetTaskItemWithClass(...) + ListAffectedTaskItems(...) + GetTaskClassConstraints(...) +} + +type MemoryContextReader interface { + LoadScheduleMemoryContext(...) +} + +type FeedbackReader interface { + GetFeedbackSignal(...) +} + +type UrgencyRefresher interface { + RefreshTaskUrgency(...) +} +``` + +`MemoryContextReader` 的语义建议: + +```go +type ScheduleMemoryContextRequest struct { + UserID int + Query string + TargetTitle string + TriggerType string + WindowStart time.Time + WindowEnd time.Time + Now time.Time +} + +type ScheduleMemoryContextFacts struct { + RenderedText string + Items []ScheduleMemoryItem + Source string + Warnings []string +} +``` + +说明: + +1. port 命名为 `MemoryContextReader`,不命名为 `PreferenceReader`,避免暗示 memory 模块已经提供结构化日程偏好。 +2. `RenderedText` 对齐 newAgent 的 `memory_context`:给 LLM 参考,但不作为硬规则。 +3. `Items` 只保留排障需要的轻量字段,例如 `id / memory_type / title / content / confidence / importance`,不把 memory 模块内部 model 泄漏到主动调度主链路。 + +MVP adapter 规则: + +1. 优先复用现有 service。 +2. 若现有 service 无合适读模型,adapter 内部可调用 DAO 组装事实。 +3. DAO 调用不能出现在 `BuildContext / Observe / GenerateCandidates` 主链路中。 +4. adapter 返回 active_scheduler 自己的轻量事实 DTO,不直接返回 GORM model。 +5. memory 侧不新增 `GetMemorySchedulePreferences` 这类结构化偏好 DAO;第一版复用现有 `memoryReader.Retrieve(ctx, memorymodel.RetrieveRequest)` 召回能力。 +6. active_scheduler 不 import `backend/newAgent/node/execute`,也不依赖 `ConversationContext` / pinned block;只复用“召回 + 渲染为 memory context 文本”的底层能力。 +7. 若实现时发现 memory 渲染逻辑只能从 `agentsvc` 访问,应先抽到 `backend/memory` 或 `backend/shared` 下的公共渲染 helper,再让 `agentsvc` 和 active_scheduler adapter 共同复用,避免复制第三份 prompt 拼装逻辑。 + +#### 5.3.4 构造顺序 + +建议固定顺序: + +```text +1. NormalizeTrigger +2. ResolveEffectiveNow +3. RefreshUrgencyIfNeeded +4. ResolveTarget +5. BuildWindow +6. LoadScheduleFacts +7. LoadPreferenceFacts +8. LoadFeedbackFacts +9. DeriveFacts +10. ValidateContextForObserve +``` + +步骤说明: + +1. `NormalizeTrigger` + - 校验 trigger 枚举、target 枚举、用户归属。 + - 失败时直接返回构造错误,不进入观测。 +2. `ResolveEffectiveNow` + - API dry-run / trigger 可使用 `mock_now`。 + - worker due job 必须使用真实 `time.Now()`。 + - 写入 `is_mock_time` 到 trace。 +3. `RefreshUrgencyIfNeeded` + - 对 `important_urgent_task` 先刷新或复用四象限紧急性派生。 + - 目的是避免读到懒平移之前的旧优先级。 +4. `ResolveTarget` + - `task_pool`:读取 `tasks`。 + - `schedule_event`:读取对应日程块,并根据 `task_source_type` 判断来源。 + - `task_item`:读取 task_item 及 task_class。 +5. `BuildWindow` + - 默认窗口为 `[effective_now, effective_now + 24h]`。 + - 未完成补救场景若目标属于 task_class,可扩展到 `task_class.end_date`,供局部补救使用。 + - 所有窗口必须映射到 `week / day_of_week / section`。 +6. `LoadScheduleFacts` + - 读取窗口内课程、已排任务、可嵌入课程、空闲槽。 + - 生成 `occupied_slots / free_slots`。 +7. `LoadPreferenceFacts` + - target 是 `task_pool`:通过 `MemoryContextReader` 召回与排程相关的 memory context,作为软偏好输入。 + - target 是 `task_item`:读 task_class 约束。 +8. `LoadFeedbackFacts` + - `unfinished_feedback` 必须加载反馈目标和文本摘要。 + - 若无法定位反馈目标,写入 `missing_info`,由后续裁决 `ask_user`。 +9. `DeriveFacts` + - 判断目标是否已完成、是否已进入日程、窗口容量是否足够。 + - 这些是后续 observe 的确定性输入。 +10. `ValidateContextForObserve` + - 只校验能否进入 observe。 + - 信息不全但仍可 ask_user 的场景,不应直接失败。 + +#### 5.3.5 四象限刷新复用方案 + +规则: + +1. `important_urgent_task` 构造 context 前必须调用 `UrgencyRefresher`。 +2. 刷新以数据库真实时间或 `effective_now` 为准: + - API dry-run / trigger:可使用 `mock_now`。 + - worker due job:使用真实当前时间。 +3. 刷新结果不要求本次一定更新 task;如果 task 已不满足平移条件,后续 `DerivedFacts` 会标记 skipped/close。 +4. 刷新失败: + - dry-run:返回错误,便于开发发现问题。 + - 正式 trigger:trigger 标记 failed,记录 error,不继续生成 preview。 +5. 不在 context 构造中重新实现四象限推导算法;复用现有 task urgency 能力或其 adapter。 + +#### 5.3.6 时间窗转换与边界兜底 + +时间窗默认: + +```text +start_at = effective_now +end_at = effective_now + 24h +``` + +兜底规则: + +1. 如果 `deadline_at` 早于 `effective_now`: + - 仍构造 24 小时窗口。 + - `DerivedFacts` 标记 `deadline_already_passed=true`。 +2. 如果 `deadline_at` 位于 24 小时内: + - `window_reason` 标记包含 `task_deadline`。 + - 候选生成时优先考虑 deadline 前的槽位。 +3. 如果窗口跨天 / 跨周: + - 拆成多个相对时间格,不能只取当天。 +4. 如果某段绝对时间无法映射到节次: + - 丢弃该格,并在 `Trace.warnings` 记录。 + - 若全部无法映射,则 context 标记 `missing_info=invalid_time_window`,后续裁决为 `ask_user / notify_only`。 +5. 第一版统一 1 节粒度: + - `estimated_sections` 为空时默认 1。 + - 非法值小于 1 时按 1 兜底。 + - 非法值大于 4 时按 4 截断,并记录 warning。 + +#### 5.3.7 偏好来源 + +与当前 `execute.go` 链路的关系: + +1. `backend/newAgent/node/execute.go` 本身只是转发壳,真正的 memory 注入发生在 graph/service 边界。 +2. `agentsvc.injectMemoryContext` 会先读 Redis 预取缓存,再启动后台检索;检索结果来自 `memoryReader.Retrieve(ctx, memorymodel.RetrieveRequest)`。 +3. `agent_nodes.ensureFreshMemory` 只负责等待 `MemoryFuture`,并把已渲染文本写入 `ConversationContext` 的 `memory_context` pinned block。 +4. `execute` prompt 只通过 `renderUnifiedMemoryContext(ctx)` 消费该 pinned block,不直接读取 memory DAO。 +5. 因此主动调度应复用 memory 的“Retrieve + 渲染”能力,不复用 execute node / ConversationContext;主动调度没有对话轮次,也不需要引入 pinned block。 + +task_pool: + +1. 不读取 task_class 约束。 +2. 通过 `MemoryContextReader.LoadScheduleMemoryContext` 读取排程相关 memory。 +3. adapter 内部使用现有 memory 模块的 `Retrieve`: + - `UserID=user_id` + - `Query` 由目标任务标题、触发类型、当前窗口意图拼成,例如“为 X 安排未来 24 小时的执行时间,参考用户的时间偏好和约束” + - `MemoryTypes` 优先限制为 `constraint / preference / fact` + - `Limit` 沿用 newAgent 注入预算或 active_scheduler 独立配置 + - `Now=effective_now` +4. adapter 返回 active_scheduler 自己的 `ScheduleMemoryContextFacts`,至少包含: + - `items`:memory item 的轻量快照,用于排障和 trace。 + - `rendered_text`:复用公共 memory 渲染 helper 后得到的文本,用于 LLM 选择和解释。 + - `source=memory_retrieve` + - `warnings` +5. memory 缺失时继续构造 context,`PreferenceFacts.preference_source=none`。 +6. memory 查询失败不阻断主动调度,只记录 warning;这与 execute 链路“记忆检索失败不阻断主链路”的策略保持一致。 +7. memory 中的偏好不能作为硬约束,只作为候选排序和解释输入;真正的硬冲突仍以后端 schedule facts / task_class constraints 为准。 + +task_item: + +1. 必须读取所属 task_class。 +2. 使用 task_class 的周几、时段、结束日期等约束。 +3. 未完成补救场景中,这些约束后续可被局部重排模块软化,但 context 中仍保留原始约束。 + +unfinished_feedback: + +1. 优先从 trigger payload 中解析 `feedback_id / feedback_text / target_id`。 +2. 如果 payload 只有自然语言文本但无法定位目标,context 不失败,写入 `missing_info=feedback_target_unknown`。 +3. 若能定位 `schedule_event`,需要读取该 event 的来源: + - `task_source_type=task_pool`:关联 tasks。 + - `task_source_type=task_item` 或空:兼容旧数据,关联 task_items。 + +#### 5.3.8 输出给后续模块的契约 + +context 构造成功后,后续 observe 可依赖以下事实已经可用: + +1. `Trigger` 已标准化。 +2. `effective_now` 已确定。 +3. `Window.relative_slots` 已生成。 +4. 目标归属已校验。 +5. schedule facts 已加载,至少包含空切片而不是 nil。 +6. preference facts 已按 target 类型分流。 +7. feedback facts 已持久化并能串联 trigger。 +8. `DerivedFacts` 至少包含: + - `target_completed` + - `target_already_scheduled` + - `available_capacity` + - `missing_info` + +#### 5.3.9 错误处理与可观测 + +1. 用户无权访问 target:构造失败,trigger 标记 failed/rejected。 +2. target 不存在:构造失败,trigger 标记 failed/rejected。 +3. schedule 查询失败:构造失败,trigger 标记 failed,可重试。 +4. memory 查询失败:不阻断,写 warning,偏好来源置为 none。 +5. task_class 查询失败: + - target 是 task_item:阻断。 + - target 是 task_pool:不应查询 task_class,若发生说明 adapter 边界错误。 +6. 时间窗部分映射失败:不阻断,写 warning。 +7. 时间窗完全不可用:构造成功但 `missing_info=invalid_time_window`,交给 observe 裁决。 + +#### 5.3.10 测试方案 + +单元测试: + +1. `mock_now` 与真实时间的 `effective_now` 选择。 +2. 24 小时窗口跨天 / 跨周映射到相对节次。 +3. `estimated_sections` 默认值、截断和 warning。 +4. task_pool 偏好来源为 memory。 +5. task_item 偏好来源为 task_class。 +6. memory 读取失败不阻断 context。 +7. feedback 无法定位目标时写入 missing_info。 +8. target 已完成 / 已安排时写入 DerivedFacts。 + +集成测试: + +1. API dry-run 触发 context 构造,返回 context summary。 +2. 正式 trigger 通过 worker 构造 context,并推进 trigger 状态。 +3. due job 触发前刷新四象限紧急性。 +4. schedule 窗口存在冲突和空闲槽时,context 同时包含 `occupied_slots / free_slots`。 +5. task_pool 不读取 task_class,task_item 必须读取 task_class。 + +人工验收: + +1. 构造一个 24 小时内有空闲节次的 task_pool,dry-run 能看到可用窗口。 +2. 构造一个 memory 偏好,例如“晚上更适合写作”,dry-run context summary 能显示偏好来源。 +3. 构造一个已排 task_item 的 unfinished feedback,context 能定位到 schedule_event 和 task_item。 +4. 构造无法定位的“刚才那个没做完”,context 不崩溃,后续裁决应进入 ask_user。 ## 6. 模块三:主动观测与候选生成 @@ -159,12 +946,13 @@ 2. 未完成补救预览。 3. 后继挤压重排预览。 4. 延后结束询问。 -5. 压缩融合预览。 -6. 询问用户。 -7. 仅提醒。 -8. 收口。 +5. 询问用户。 +6. 仅提醒。 +7. 收口。 -### 6.2 需要拍板的问题 +压缩融合候选第一轮只保留 schema 和文档口径,不进入候选生成动作范围。 + +### 6.2 已拍板结论 1. 主动观测最终是 Agent 工具,还是 worker 内部 service?第一版是否同时提供内部 service 和工具壳? - 已确认:主动观测不作为 ReAct 工具进入工具循环,而是串进固定 graph / service pipeline。LLM 直接消费观测与候选结果,负责选择和表达。 @@ -173,20 +961,472 @@ 3. 未完成补救里,局部重排第一版复用现有粗排算法到什么程度? - 已确认:第一版做“偏好软化版局部粗排”。输入时间窗为当前时刻到任务类结束日期,只传受影响的部分 item;周几偏好和时段偏好从硬约束降级为优先级,优先排偏好范围内,排不下再打破偏好追加进去,最后恢复这些任务的原有顺序语义。 - 工程倾向:不直接污染现有粗排主函数,新增一条局部重排实现;底层时间格、可用槽位、冲突判断等公共能力优先抽公共层复用,避免复制第三份逻辑。 -4. 压缩融合候选第一版是否固定只找“下一个动态任务”,并默认 50% / 50%? - - 已确认:第一版固定只找下一个动态任务作为融合对象,并默认按 50% / 50% 压缩;该候选只作为兜底预览,不自动执行。 +4. 压缩融合候选第一轮是否打开? + - 已确认:第一轮先关闭,不生成 `compress_with_next_dynamic_task` 候选;保留 schema 和实现预留,待新增补做块主链路稳定后再评估打开。 5. close / ask_user / notify_only 的判定阈值由后端固定,还是允许 LLM 结合上下文选择? - 已确认:参考 `analyze_health` 的裁决模式,由后端确定 `close / ask_user / notify_only / select_candidate`。LLM 不决定能不能调度,只在 `select_candidate` 时选择候选;其它场景只负责解释后端理由。 -### 6.3 待补执行计划 +### 6.3 执行计划:主动观测与候选生成 -业务逻辑确认后补充: +本模块负责把 `ActiveScheduleContext` 转成结构化诊断结果,并生成 1 到 3 个后端已校验的候选。它不写 preview、不发通知、不正式改日程;LLM 只在 `decision.action=select_candidate` 时从候选中选择和解释,不负责决定是否允许调度。 -1. metrics / issues / decision / candidates schema。 -2. 候选合法性校验规则。 -3. 候选排序规则。 -4. 与现有 `analyze_health` 的复用和隔离边界。 -5. 单元测试覆盖场景。 +#### 6.3.1 代码落点 + +1. 主动观测: + ```text + backend/active_scheduler/observe + ``` + 负责 metrics / issues / decision 的确定性计算。 +2. 候选生成: + ```text + backend/active_scheduler/candidate + ``` + 负责枚举、模拟、校验、排序和截断候选。 +3. LLM 选择与解释: + ```text + backend/active_scheduler/selection + ``` + 只负责把后端候选喂给 LLM,让 LLM 返回 `selected_candidate_id / summary / reason / risk_text`。 +4. 与 schedule 公共能力复用: + ```text + backend/active_scheduler/scheduleutil + ``` + 或后续下沉到更公共目录,用于放时间格、冲突判断、before/after 摘要转换等可复用能力。 +5. 本模块输出 DTO: + ```text + backend/active_scheduler/model + ``` + 放 `ObservationResult / ActiveScheduleDecision / ActiveScheduleCandidate` 等主动调度内部结构。 + +#### 6.3.2 Pipeline 输入输出 + +输入: + +```text +ActiveScheduleContext +``` + +输出: + +```text +ActiveScheduleObservationResult + metrics + issues + decision + candidates + trace +``` + +处理顺序: + +```text +1. BuildMetrics +2. DetectIssues +3. DecideAction +4. GenerateCandidates +5. ValidateCandidates +6. RankAndTrimCandidates +7. SelectAndExplainByLLM +``` + +说明: + +1. `BuildMetrics / DetectIssues / DecideAction / GenerateCandidates / ValidateCandidates / RankAndTrimCandidates` 全部由后端确定性完成。 +2. `SelectAndExplainByLLM` 只在 `decision.action=select_candidate` 且候选数大于 0 时执行。 +3. LLM 返回的 `candidate_id` 必须存在于后端候选列表;不存在或格式非法时,先进行一次受限重试。 +4. 受限重试仍失败时不影响 preview 生成,使用后端 top1 和固定解释 fallback。 + +#### 6.3.3 Metrics schema + +建议第一版 metrics 只保留能驱动裁决和排障的指标: + +```text +ActiveScheduleMetrics + target + completed + already_scheduled + deadline_already_passed + minutes_to_deadline + estimated_sections + + window + total_slots + free_slots + occupied_slots + usable_slots_before_deadline + capacity_gap + + preference + source # memory / task_class / none + matched_slot_count + unmatched_reason + + feedback + has_feedback + feedback_target_known + unfinished_elapsed_minutes + + risk + conflict_count + affected_event_count + affected_task_count + requires_reorder +``` + +指标语义: + +1. `capacity_gap = estimated_sections - usable_slots_before_deadline`。 +2. `matched_slot_count` 只表示满足软偏好的可用槽数量,不表示硬可排容量。 +3. `requires_reorder=true` 表示候选可能涉及局部补救或压缩融合,不表示已经修改正式日程。 +4. metrics 只描述事实,不夹带最终动作文案。 + +#### 6.3.4 Issues schema + +issue 是后端观察到的问题或阻断点: + +```text +ActiveScheduleIssue + issue_id + code + severity # critical / warning / info + target_type + target_id + reason + evidence + can_generate_candidate +``` + +第一版 issue code: + +```text +target_completed +target_already_scheduled +deadline_passed +no_valid_time_window +capacity_insufficient +no_free_slot +preference_not_satisfied +feedback_target_unknown +need_makeup_block +need_local_reorder +can_add_task_pool_to_schedule +can_compress_with_next_dynamic_task # 预留,第一轮不生成 +``` + +生成规则: + +1. `target_completed`:目标已完成,后续 `decision.action=close`。 +2. `target_already_scheduled`:任务已进入正式日程,后续 `decision.action=close` 或 `notify_only`。 +3. `feedback_target_unknown`:无法定位用户说的“没完成”是哪一个日程块,后续 `decision.action=ask_user`。 +4. `no_valid_time_window`:窗口无法映射成任何节次,后续 `decision.action=ask_user`。 +5. `capacity_insufficient`:可用容量不足但存在补救可能,第一轮优先生成询问或仅提醒;压缩融合只保留预留 code,不生成候选。 +6. `can_add_task_pool_to_schedule`:task_pool 任务可直接加入日程,是 `important_urgent_task` 的主路径。 +7. `need_makeup_block / need_local_reorder`:未完成反馈需要生成补做块或局部补救候选。 + +#### 6.3.5 Decision schema + +后端裁决结构: + +```text +ActiveScheduleDecision + action # close / ask_user / notify_only / select_candidate + reason_code + primary_issue_code + should_notify + should_write_preview + llm_selection_required + fallback_candidate_id +``` + +裁决优先级: + +```text +1. close +2. ask_user +3. notify_only +4. select_candidate +``` + +规则: + +1. `close` + - 目标已完成。 + - 目标已进入日程且无需补救。 + - 没有观察到需要用户处理的问题。 +2. `ask_user` + - 反馈目标无法定位。 + - 时间窗完全不可用。 + - 任务缺少必要信息,且后端无法安全生成候选。 +3. `notify_only` + - 有风险或状态变化需要告知用户,但不适合自动生成可确认变更。 + - 例如 deadline 已过且没有合理补救窗口。 +4. `select_candidate` + - 至少存在 1 个后端合法候选。 + - `should_write_preview=true`。 + - `llm_selection_required=true`,让 LLM 在候选内选择并生成解释。 + +兜底: + +1. `select_candidate` 但 LLM 输出非法:先受限重试一次,仍失败再使用 `fallback_candidate_id`。 +2. `select_candidate` 但候选列表最终为空:降级为 `ask_user` 或 `notify_only`,不能写空 preview。 +3. `ask_user / notify_only / close` 不调用 LLM 选择;是否需要 LLM 解释文案可在通知模块单独生成 summary,但不改变 decision。 + +#### 6.3.6 Candidate schema + +候选结构: + +```text +ActiveScheduleCandidate + candidate_id + candidate_type + title + summary + target + changes + before_summary + after_summary + risk + score + validation + source +``` + +`candidate_type` 第一版: + +```text +add_task_pool_to_schedule +makeup_block +local_reorder_makeup +ask_delay_end +compress_with_next_dynamic_task # 预留,第一轮关闭 +notify_only +close +``` + +`changes` 使用预览模块可消费的统一变更项: + +```text +ActiveScheduleChangeItem + change_type # add / move / compress / create_makeup / ask_user / none + target_type # task_pool / task_item / schedule_event + target_id + from_slot + to_slot + duration_sections + affected_event_ids + edited_allowed + metadata +``` + +约束: + +1. 候选必须能转成预览模块的 `preview_changes`。 +2. 候选不能直接携带 DAO model。 +3. `close / notify_only / ask_delay_end` 可以没有正式日程变更,但仍要有明确 `candidate_type` 和解释。 +4. `edited_allowed=true` 只表示详情页可以让用户拖动 after 方案;confirm 时仍必须重校验。 + +#### 6.3.7 候选生成规则 + +`important_urgent_task` 主路径: + +1. 若 `target_completed=true`:生成 `close`。 +2. 若 `target_already_scheduled=true`:生成 `close` 或 `notify_only`。 +3. 若存在满足容量的 free slot: + - 生成 `add_task_pool_to_schedule`。 + - 优先使用 deadline 前槽位。 + - 优先使用 memory 偏好匹配槽位,但 memory 只能影响排序,不能覆盖硬冲突。 +4. 若没有完整 free slot: + - 第一轮不生成 `compress_with_next_dynamic_task`。 + - 记录 `capacity_insufficient`,生成 `notify_only / ask_user`,提示用户重新选择时间或缩短任务。 +5. 若无候选但信息完整: + - 生成 `notify_only`,说明无法安全安排。 + +`unfinished_feedback` 主路径: + +1. 若无法定位 feedback target:生成 `ask_user`。 +2. 若能定位 schedule_event: + - 第一版优先生成 `makeup_block`,只新增补做块,不移动原任务。 + - 若补做块挤压后续动态任务,第一轮不生成压缩融合候选,降级为 `ask_user / notify_only`。 +3. 若目标属于 task_item 且需要局部补救: + - 调用“偏好软化版局部粗排”生成 `local_reorder_makeup`。 + - 输入范围为当前时刻到 task_class.end_date。 + - 只传受影响的 item,不重排整张大表。 +4. 若 deadline / end_date 已过且无可用窗口: + - 生成 `ask_delay_end` 或 `notify_only`,不强行安排到无效时间。 + +#### 6.3.8 合法性校验规则 + +候选写入 preview 前必须通过后端校验: + +1. 用户归属: + - candidate 中所有 target / affected event 必须属于同一 user。 +2. 时间窗: + - 所有 `to_slot` 必须在 context window 或局部补救窗口内。 + - `to_slot` 必须能映射到正式 `schedules` 原子节次。 +3. 冲突: + - `add / create_makeup` 不得覆盖课程、固定日程、已确认任务。 + - `compress` 第一版不依赖“是否允许压缩”的显式配置项;只允许作用在后端识别出的 `next_dynamic_task` 上,且必须排除课程、固定日程、已锁定任务、已完成任务和无法缩短的任务块。 +4. 时长: + - `duration_sections` 必须等于目标预计节数,或明确记录压缩比例。 + - task_pool 第一版限制 1 到 4 节。 +5. 来源: + - task_pool 候选必须保持 `task_source_type=task_pool`。 + - task_item 候选必须保留 task_class 归属与顺序语义。 +6. 局部重排: + - 只能移动局部补救输入集合内的 item。 + - 不得打乱同一 task_class 内必须保持的前后顺序。 +7. 幂等: + - 同一 context 内候选用 `candidate_type + target_id + normalized_changes_hash` 去重。 +8. 可解释性: + - 候选必须有 `before_summary / after_summary / risk`,否则不能进入 LLM 选择。 + +校验失败处理: + +1. 单个候选失败:丢弃该候选并写入 trace warning。 +2. 全部候选失败:decision 降级为 `ask_user / notify_only`。 +3. 校验失败不能交给 LLM 自行判断。 + +#### 6.3.9 候选排序规则 + +排序因子建议: + +```text +score = + deadline_score + + capacity_score + + preference_score + + minimal_change_score + + risk_penalty + + disruption_penalty +``` + +排序原则: + +1. deadline 前候选优先于 deadline 后候选。 +2. 不移动已有任务优先于移动 / 压缩已有任务。 +3. 满足 memory 偏好的候选优先于不满足偏好的候选。 +4. 影响事件数量少的候选优先。 +5. 同分时选择更早可执行的槽位。 +6. 第一版最多保留 top3 给 LLM。 +7. 必须保留一个后端 fallback top1,LLM 受限重试后仍失败时使用。 + +候选数量: + +```text +min = 1 +max = 3 +``` + +但 `close / ask_user / notify_only` 场景允许没有可应用候选。 + +#### 6.3.10 LLM 选择与解释边界 + +LLM 输入: + +```text +context_summary +metrics +issues +decision +candidates(top3) +memory_context_text +``` + +LLM 输出: + +```text +selected_candidate_id +summary +reason +risk_text +notification_summary +``` + +约束: + +1. LLM 不能新增候选。 +2. LLM 不能修改 `changes`。 +3. LLM 不能把 `ask_user / notify_only / close` 改成 `select_candidate`。 +4. LLM 返回的 `selected_candidate_id` 不存在或格式非法时,不立刻采纳 top1,而是进行一次受限重试;重试 prompt 只允许从现有 candidate_id 列表中选择,不能新增候选或修改 changes。 +5. 受限重试仍失败时,使用后端 top1 作为推荐候选写入 preview,并记录 `llm_fallback_used=true`;该候选仍需用户确认后才会正式应用。 +6. LLM 文案需要做长度和空值校验;失败时使用固定 fallback: + ```text + 我为你生成了一份日程调整建议,请回到系统确认是否应用。 + ``` +7. `notification_summary` 可传给通知模块,但通知模块仍保留自己的模板 fallback。 + +#### 6.3.11 与 `analyze_health` 的复用和隔离边界 + +可复用思想: + +1. 后端先算 metrics / issues。 +2. 后端先做 decision。 +3. 候选由后端枚举并校验。 +4. 候选需要模拟 after,并保留 before/after 摘要。 +5. LLM 只在合法候选里选择,不做开放式搜索。 + +不直接复用的部分: + +1. 不把主动调度做成 `analyze_health` 工具。 +2. 不进入 Execute ReAct 循环。 +3. 不直接复用 `analyze_health` 的 `move / swap` 候选类型,因为主动调度第一版候选包含 task_pool 加入日程、补做块、通知和询问;压缩融合只作为后续预留候选。 +4. 不复用 `ScheduleState` 作为唯一输入;主动调度输入是 `ActiveScheduleContext`,其中包含 trigger、memory、feedback、task_pool facts 和 schedule facts。 + +建议抽公共层: + +1. 时间格和节次合法性。 +2. 冲突判断。 +3. 局部 before/after 摘要。 +4. 候选模拟后的收益 / 风险评分框架。 + +这些公共能力若已经在 `backend/newAgent/tools/schedule` 中存在,迁移时按并行迁移策略抽出小 helper;不要本轮直接大搬整个 schedule tool 包。 + +#### 6.3.12 错误处理与可观测 + +1. observe 失败:trigger 标记 failed,可重试。 +2. candidate 生成失败但 context 可解释:decision 降级为 `notify_only / ask_user`。 +3. LLM 选择输出非法:先受限重试一次,仍失败再使用后端 fallback candidate。 +4. LLM 文案失败:使用固定 fallback 文案。 +5. 每个候选保留 `validation.warnings` 和 `score_breakdown`,便于 dry-run 查看为什么它被保留或丢弃。 +6. trace 至少记录: + - metrics 摘要。 + - issue codes。 + - decision action / reason_code。 + - generated_candidate_count。 + - valid_candidate_count。 + - selected_candidate_id。 + - llm_fallback_used。 + +#### 6.3.13 测试方案 + +单元测试: + +1. target 已完成时 decision 为 `close`。 +2. target 已进入日程时不生成重复 `add_task_pool_to_schedule`。 +3. feedback 目标未知时 decision 为 `ask_user`。 +4. 24 小时窗口内存在 free slot 时生成 `add_task_pool_to_schedule`。 +5. memory 偏好匹配槽位排序高于非匹配槽位。 +6. 无 free slot 但存在 next dynamic task 时不生成 `compress_with_next_dynamic_task`,decision 降级为 `ask_user / notify_only`。 +7. task_pool 候选非法时被校验丢弃。 +8. 局部补救候选不得移动输入集合外的 task_item。 +9. 候选去重基于 normalized changes hash。 +10. LLM 返回非法 candidate_id 时先受限重试一次,重试仍失败再 fallback 到后端 top1。 + +集成测试: + +1. dry-run 返回 metrics / issues / decision / candidates。 +2. 正式 trigger 生成合法候选后进入 LLM selection,并输出 selected candidate。 +3. LLM 超时、失败或受限重试后仍输出非法时,仍能生成 preview fallback。 +4. 与 5.3 context 串联:task_pool 只用 memory 软偏好,task_item 使用 task_class 约束。 +5. 与 7.x preview 串联:候选可转换为 preview_changes。 + +人工验收: + +1. 创建一个 24 小时内有空闲节次的紧急 task,dry-run 能看到 1 到 3 个候选。 +2. 添加“晚上更适合写作”的 memory 后,晚间槽位排序更靠前或 explanation 说明偏好命中。 +3. 制造无空闲但有下一个动态任务的场景,看不到压缩融合候选,并返回 `ask_user / notify_only`。 +4. 制造“刚才那个没做完”但无法定位目标的反馈,返回 ask_user,不生成危险候选。 +5. 关闭 LLM 或模拟 LLM 两次输出非法,后端仍能用 top1 fallback 生成 preview。 ## 7. 模块四:预览、前后对比与确认 @@ -196,7 +1436,7 @@ 确认粒度按候选项确认,不做整版黑盒确认。确认后才进入正式应用链路。 -### 7.2 需要拍板的问题 +### 7.2 已拍板结论 1. 预览复用 `agent_schedule_states`,还是新增 `active_schedule_previews`? - 已确认:新增 `active_schedule_previews` 承载主动调度预览持久化;不直接塞进 `agent_schedule_states`。展示层可以抽通用 before/after change schema,供现有会话排程预览和主动调度预览复用。 @@ -209,15 +1449,449 @@ 5. 预览过期时间设多久? - 已确认:MVP 预览过期时间为 1 小时;过期后不可确认应用,只能重新触发生成新的预览。 -### 7.3 待补执行计划 +### 7.3 执行计划:预览、前后对比与确认协议 -业务逻辑确认后补充: +本模块负责把 6.3 选出的候选持久化成用户可查看、可确认、可审计的主动调度预览。它不负责重新生成候选,也不在用户未确认前修改正式日程。确认 API 第一版同步调用正式应用链路,但“如何把 candidate 转成正式写库请求”的细节放到 8.3。 -1. 预览表或缓存结构。 -2. `SchedulePreviewVersion` / `ActiveScheduleChangeItem` schema。 -3. 查询预览 API。 -4. 确认 API 或 resume 接入方案。 -5. 幂等键与状态流转。 +#### 7.3.1 代码落点 + +1. 预览领域模型与 DTO: + ```text + backend/active_scheduler/preview + ``` +2. 预览 repo: + ```text + backend/active_scheduler/repo + ``` +3. API handler: + ```text + backend/api/active_schedule.go + ``` +4. 路由注册: + ```text + backend/routers + ``` +5. 与前端共享的展示 DTO: + ```text + backend/active_scheduler/model + ``` + 若现有会话排程预览也要复用 before/after 展示结构,后续可再抽到更公共的 schedule preview DTO 包;MVP 先不大搬旧链路。 + +#### 7.3.2 active_schedule_previews 表结构方向 + +第一版新增 `active_schedule_previews`,不复用 `agent_schedule_states` 和 Redis 会话预览缓存。 + +建议字段: + +```text +preview_id # 建议字符串或雪花 ID +user_id +trigger_id +trigger_type +target_type +target_id +status # pending / ready / applied / ignored / expired / failed +selected_candidate_id +candidate_count +selected_candidate_json +candidates_json +decision_json +metrics_json +issues_json +context_summary_json +before_summary_json +preview_changes_json +after_summary_json +risk_json +explanation_text +notification_summary +base_version +expires_at +generated_at + +apply_id +apply_status # none / applying / applied / failed / rejected / expired +apply_candidate_id +apply_idempotency_key +apply_request_hash +applied_changes_json +applied_event_ids_json +apply_error +applied_at + +trace_id +created_at +updated_at +deleted_at +``` + +索引建议: + +```text +idx_active_previews_user_created_at(user_id, created_at) +idx_active_previews_trigger_id(trigger_id) +idx_active_previews_expires_at(expires_at) +uk_active_previews_apply_idempotency(preview_id, apply_idempotency_key) +``` + +约束: + +1. `preview_id` 是飞书跳转和详情页查询的唯一定位键。 +2. `trigger_id` 用于串联 `trigger -> preview -> notification -> apply`。 +3. `candidates_json` 保存后端合法候选全集,通常最多 3 个。 +4. `selected_candidate_json` 保存 LLM 选择后或 fallback top1 的推荐候选。 +5. `before_summary_json / preview_changes_json / after_summary_json` 是详情页展示和 confirm 重校验的核心输入。 +6. `base_version` 用于确认时判断预览生成后的正式日程是否发生变化;MVP 可先使用受影响范围的更新时间摘要或 schedule 版本摘要,后续再收敛为正式 version 字段。 +7. `apply_*` 字段先放在 preview 表内,MVP 不新增 apply request 表;后续异步化时可平滑迁到 `active_schedule_apply_requests`。 + +#### 7.3.3 Preview 状态机 + +预览主状态: + +```text +pending -> ready +ready -> applied +ready -> ignored +ready -> expired +ready -> failed +pending -> failed +``` + +状态语义: + +1. `pending`:已准备写入或正在组装预览,不应对用户展示为可确认。 +2. `ready`:可查看、可确认,且未过期。 +3. `applied`:用户已确认并成功应用。 +4. `ignored`:用户明确忽略本次建议。 +5. `expired`:超过 `expires_at`,不可确认。 +6. `failed`:预览写入、候选转换或 apply 回写失败。 + +apply 子状态: + +```text +none -> applying -> applied +none -> applying -> failed +none -> rejected +none -> expired +``` + +状态约束: + +1. `status=applied` 时,`apply_status` 必须为 `applied`。 +2. `status=expired` 时,confirm API 必须拒绝确认,并把 `apply_status` 置为 `expired` 或保持不可应用状态。 +3. `status=ignored / applied / expired` 后,默认不允许再次 confirm。 +4. 第一版同一个 preview 只允许成功 apply 一次。 + +#### 7.3.4 Preview 写入流程 + +worker pipeline 在 `LLMSelectAndExplain` 后写 preview: + +```text +1. 接收 observation result、候选列表、selected candidate 和解释文案。 +2. 构造 before_summary: + - 只记录受影响时间窗 / 受影响事件 / 目标任务摘要。 + - 不保存全量日程快照。 +3. 构造 preview_changes: + - 由 selected candidate 的 changes 转换而来。 + - 保留 candidate_id / change_id / target / slot / duration / affected ids。 +4. 构造 after_summary: + - 基于 before_summary + preview_changes 生成用户可读改后视图。 + - 不写正式 schedule 表。 +5. 生成 base_version: + - 记录受影响范围内当前正式日程版本或更新时间摘要。 +6. 写入 active_schedule_previews: + - status=ready + - apply_status=none + - expires_at=generated_at + 1h +7. 回写 trigger 状态为 preview_generated。 +8. 发布 notification.feishu.requested。 +``` + +失败处理: + +1. preview 写入失败:trigger 标记 failed,不发布 notification。 +2. selected candidate 缺少可展示字段:preview 不写入,trigger 标记 failed 或降级 notify_only。 +3. notification 失败不回滚 preview,通知状态由 `notification_records` 承载。 + +#### 7.3.5 展示 DTO + +详情页响应建议: + +```text +ActiveSchedulePreviewDetail + preview_id + status + apply_status + expires_at + expired + trigger + explanation + selected_candidate + candidates + before + after + changes + risk + can_confirm + can_ignore + trace_id +``` + +`before / after` 建议使用轻量展示结构: + +```text +SchedulePreviewVersion + title + window_start + window_end + entries + summary_lines +``` + +`entries`: + +```text +SchedulePreviewEntry + entry_id + source_type # course / schedule_event / task_pool / task_item / virtual + source_id + title + start_at + end_at + week + day_of_week + section_from + section_to + status # unchanged / added / moved / compressed / affected / removed + editable +``` + +`changes`: + +```text +ActiveScheduleChangeItem + change_id + change_type # add / move / compress / create_makeup / ask_user / none + target_type + target_id + from_slot + to_slot + duration_sections + affected_event_ids + edited_allowed + metadata +``` + +展示原则: + +1. 前端展示的是后端持久化的 preview,不重新实时计算候选。 +2. `expired=true` 时仍可查看解释和 before/after,但不能确认。 +3. `editable=true / edited_allowed=true` 只控制前端是否允许拖动 after 方案,不代表后端会信任前端结果。 +4. 前端不要从 URL 中传 `candidate_id`;详情页通过 `preview_id` 读取完整数据。 + +#### 7.3.6 API 设计 + +新增鉴权接口: + +```text +GET /active-schedule/previews/{preview_id} +POST /active-schedule/previews/{preview_id}/confirm +POST /active-schedule/previews/{preview_id}/ignore +``` + +飞书和 Web 路由: + +```text +/schedule-adjust/{preview_id} +``` + +页面打开流程: + +```text +1. Web 路由解析 preview_id。 +2. 前端调用 GET /active-schedule/previews/{preview_id}。 +3. 后端校验 preview 属于当前用户。 +4. 返回详情 DTO。 +5. 前端根据 can_confirm / expired / apply_status 展示确认、忽略或历史状态。 +``` + +`confirm` 请求: + +```json +{ + "candidate_id": "cand_1", + "action": "confirm", + "edited_changes": [ + { + "change_id": "chg_1", + "change_type": "add", + "target_type": "task_pool", + "target_id": 123, + "to_slot": { + "week": 8, + "day_of_week": 4, + "section_from": 8, + "section_to": 8 + }, + "duration_sections": 1 + } + ], + "idempotency_key": "frontend-generated-uuid" +} +``` + +`confirm` 响应: + +```json +{ + "preview_id": "asp_123", + "apply_id": "asa_456", + "apply_status": "applied", + "applied_event_ids": [1001], + "message": "已应用" +} +``` + +`ignore` 请求: + +```json +{ + "reason": "user_dismissed" +} +``` + +`ignore` 语义: + +1. 只把 preview 标记为 `ignored`。 +2. 不修改正式日程。 +3. 不影响同一任务后续在新的时间窗再次触发;触发去重仍按 trigger 模块规则处理。 + +#### 7.3.7 Confirm API 校验流程 + +确认接口固定流程: + +```text +1. 鉴权并读取 preview。 +2. 校验 preview.user_id == 当前用户。 +3. 校验 status=ready 且 apply_status=none;若命中幂等记录,则按幂等规则返回上一轮结果。 +4. 校验 expires_at 未过期。 +5. 校验 candidate_id 属于 preview.candidates。 +6. 读取 idempotency_key,计算 apply_request_hash。 +7. 命中同一 preview_id + idempotency_key: + - hash 相同:返回上一次 apply 结果。 + - hash 不同:拒绝,提示幂等键复用到不同请求。 +8. 生成 apply_id,写 apply_status=applying。 +9. 后端重校验 edited_changes: + - target 归属。 + - slot 合法。 + - 不覆盖课程 / 固定日程 / 已确认任务。 + - base_version 未失效或受影响范围未变化。 +10. 调用 8.3 的正式应用服务。 +11. 成功:写 applied_changes_json / applied_event_ids_json / applied_at,状态变为 applied。 +12. 失败:写 apply_error,apply_status=failed;是否把主状态置为 failed 由失败类型决定,正式写入失败时不允许绕过幂等重复应用。 +``` + +关键约束: + +1. 后端必须允许 `edited_changes` 为空;为空时使用候选原始 changes。 +2. 后端必须允许 `edited_changes` 与候选原始 changes 不同;不同表示用户拖动了 after 方案,但仍要在同一 candidate 的允许编辑范围内。 +3. 后端不能相信前端传来的 title、summary、risk,只信 target 和 slot 等结构化字段。 +4. confirm 成功前不得发布 `schedule.apply.succeeded`。 +5. confirm 失败不得产生半写正式日程。 + +#### 7.3.8 幂等与重复提交 + +幂等键: + +```text +unique(preview_id, idempotency_key) +``` + +请求摘要: + +```text +apply_request_hash = hash(candidate_id + action + normalized_edited_changes) +``` + +规则: + +1. 前端每次点击确认生成一个新的 `idempotency_key`。 +2. 同一次点击的网络重试必须复用同一个 `idempotency_key`。 +3. 同一 key + 同一 hash:返回同一 `apply_id` 和结果。 +4. 同一 key + 不同 hash:拒绝。 +5. 不同 key + 同 preview:如果 preview 已 applied,拒绝重复应用或返回已应用状态。 +6. 第一版不支持一个 preview 成功应用多个候选。 + +#### 7.3.9 过期与重建 + +过期规则: + +```text +expires_at = generated_at + 1h +``` + +处理方式: + +1. GET 详情时若已过期,返回 `expired=true / can_confirm=false`。 +2. confirm 时若已过期,拒绝并更新状态为 `expired`。 +3. 过期 preview 仍可查看历史解释。 +4. 前端提示用户重新生成建议。 +5. 重新生成必须走新的 trigger / dry-run 链路,生成新的 preview_id,不在旧 preview 上覆盖。 + +#### 7.3.10 与现有 Agent 预览的关系 + +复用边界: + +1. 不复用 `agent_schedule_states`。 +2. 不复用 Redis `schedule_preview` key。 +3. 可参考 `SchedulePlanPreviewCache / GetSchedulePlanPreviewResponse` 的展示思想,但主动调度使用独立 DTO。 +4. 可抽通用 `SchedulePreviewVersion / SchedulePreviewEntry / ActiveScheduleChangeItem` 展示结构,供后续会话排程预览复用。 + +隔离原因: + +1. 主动调度 preview 可能来自后台 worker,没有 `conversation_id`。 +2. 主动调度 preview 绑定 `trigger_id / preview_id / expires_at / apply_status`。 +3. 会话排程预览是 Agent state 的派生视图,不适合承载后台通知和 apply 审计。 + +#### 7.3.11 错误处理与可观测 + +1. preview 不存在:返回 not found,不泄漏是否属于其它用户。 +2. preview 不属于当前用户:返回 not found 或 forbidden,产品上建议统一 not found。 +3. preview 已过期:GET 可返回详情,confirm 返回业务错误。 +4. preview 已 applied:confirm 幂等命中则返回原结果;非幂等重复确认则拒绝。 +5. base_version 失效:confirm 失败,不写正式日程,提示重新生成。 +6. edited_changes 非法:confirm 失败,写 apply_error。 +7. 正式应用服务失败:事务回滚,写 apply_status=failed。 +8. 所有 confirm 路径必须能通过 `preview_id / apply_id / idempotency_key / trace_id` 查日志。 + +#### 7.3.12 测试方案 + +单元测试: + +1. candidate 转 `preview_changes`。 +2. before_summary + preview_changes 生成 after_summary。 +3. preview 过期判断。 +4. `preview_id + idempotency_key` 幂等命中。 +5. 同一 idempotency_key 不同 hash 被拒绝。 +6. `edited_changes` 越界、冲突、跨用户 target 被拒绝。 +7. preview 状态机流转合法性。 + +集成测试: + +1. worker 写入 `active_schedule_previews` 后,GET 详情能读取完整 before/after。 +2. 飞书链接 `/schedule-adjust/{preview_id}` 能进入详情页并读取同一 preview。 +3. confirm 原始候选成功,状态变为 `applied`。 +4. confirm 拖动后的 `edited_changes` 成功,应用内容以 edited changes 为准。 +5. preview 过期后 confirm 被拒绝。 +6. base_version 改变后 confirm 被拒绝。 +7. 用户重复点击确认不会重复写正式日程。 + +人工验收: + +1. 打开主动调度详情页,能看到触发原因、推荐调整、改前 / 改后、风险说明。 +2. 拖动 after 方案后确认,后端按拖动后位置应用。 +3. 对过期 preview 点击确认,页面提示重新生成。 +4. 对已应用 preview 再次点击确认,不产生重复日程块。 ## 8. 模块五:正式应用链路 @@ -227,24 +1901,453 @@ 应用成功后发布 `schedule.apply.succeeded`;失败则发布 `schedule.apply.failed`,并把失败原因写回预览状态。 -### 8.2 需要拍板的问题 +### 8.2 已拍板结论 1. 从任务池任务加入日程时,正式写入目标是 `schedule_events(type=task, rel_id=tasks.id)`,还是先转为 `task_items`? - 已确认:不转为 `task_items`。正式写入 `schedule_events.type=task, task_source_type=task_pool, rel_id=tasks.id`,并写入对应 `schedules` 原子节次。 2. 未完成补救涉及已排任务移动时,是否第一版只支持生成新补做块,不支持直接移动原任务? - 已确认:第一版只支持生成新的补做块,不直接移动原已排任务。这样可以降低对既有 schedule / task_item 状态的扰动,后续再扩展移动原任务。 3. `schedule.apply.requested` 第一版是否需要 outbox 异步消费,还是确认接口内同步调用 service? + - 已确认:MVP 确认接口内同步调用正式应用 service,不新增 outbox apply 消费链路,也不强制新增 apply request 表。 + - 已确认:确认接口负责完成预览读取、过期校验、候选归属校验、`edited_changes` 重校验、事务写库和 apply 结果回写。 + - 已确认:后续若 apply 变重,再迁移为 `active_schedule_apply_requests + schedule.apply.requested` 异步消费;MVP 先通过 preview 表内 apply 字段保留迁移空间。 4. 应用幂等键用 `preview_id + candidate_id`,还是单独生成 `apply_id`? + - 已确认:使用独立 `apply_id` 表示一次确认应用尝试。 + - 已确认:使用 `idempotency_key` 绑定一次确认请求,推荐唯一约束为 `preview_id + idempotency_key`。 + - 已确认:`preview_id + candidate_id` 只用于定位用户基于哪一个候选确认,不代表最终应用内容;拖动后的最终内容以 `edited_changes` 为准,并必须重新校验。 -### 8.3 待补执行计划 +### 8.3 执行计划:正式应用链路 -业务逻辑确认后补充: +本模块负责把用户确认后的 `preview_changes / edited_changes` 转成正式日程写入。它必须在事务内完成重校验、写库和结果回写;失败时不能产生半写状态。MVP 确认接口内同步调用本模块,不发布 `schedule.apply.requested` 给异步 worker。 -1. 候选到正式请求的转换器。 -2. 复用 `TaskClassService.BatchApplyPlans` 的条件。 -3. task_pool 任务正式落库策略。 -4. 应用失败回写方案。 -5. 测试场景。 +#### 8.3.1 代码落点 + +1. 正式应用入口: + ```text + backend/active_scheduler/apply + ``` +2. 候选 / change 转换器: + ```text + backend/active_scheduler/apply/convert + ``` +3. 正式写入 adapter: + ```text + backend/active_scheduler/adapters + ``` +4. 复用既有领域 service: + ```text + backend/service/task-class.go + backend/service/schedule.go + backend/dao/schedule.go + ``` +5. apply 结果回写: + ```text + backend/active_scheduler/preview + ``` + +建议定义主动调度自己的 apply port: + +```go +type ScheduleApplyPort interface { + ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error) +} +``` + +说明: + +1. confirm API 只依赖主动调度 apply service,不直接调用 DAO。 +2. apply service 内部按 change 类型分流到现有 service 或本地 adapter。 +3. 所有正式写库都必须走事务。 + +#### 8.3.2 Apply 请求与结果 + +请求结构方向: + +```text +ApplyActiveScheduleRequest + preview_id + apply_id + idempotency_key + user_id + candidate_id + base_version + changes + requested_at + trace_id +``` + +结果结构方向: + +```text +ApplyActiveScheduleResult + apply_id + apply_status + applied_event_ids + applied_schedule_ids + applied_changes + skipped_changes + warning_messages +``` + +约束: + +1. `changes` 来自 `edited_changes`;若前端未编辑,则使用 preview 中的原始 `preview_changes`。 +2. `candidate_id` 只用于定位候选来源,不作为幂等键。 +3. `base_version` 必须参与重校验,避免预览生成后正式日程已变化。 +4. `applied_changes` 必须记录最终真实落库内容,而不是原始候选内容。 + +#### 8.3.3 支持的 change_type + +MVP 正式应用支持: + +```text +add_task_pool_to_schedule +create_makeup +``` + +有条件支持: + +```text +add_task_item_to_schedule +local_reorder_makeup +``` + +预留但第一轮不启用: + +```text +compress_with_next_dynamic_task +``` + +不直接应用: + +```text +ask_user +notify_only +close +``` + +规则: + +1. `ask_user / notify_only / close` 只更新 preview 状态或通知结果,不写正式日程。 +2. `add_task_item_to_schedule` 仅用于未安排过的 task_item,可复用 `TaskClassService.BatchApplyPlans`。 +3. `local_reorder_makeup` 若只包含未安排 task_item 的新增落位,可转成 `BatchApplyPlans`;若包含移动既有已排事件,MVP 不正式应用,应在候选生成阶段过滤或降级为 `ask_user`。 +4. `create_makeup` 表示新增一个补做块,不移动原已排任务。 +5. `compress_with_next_dynamic_task` 第一轮不生成、不应用;后续打开前必须先完成 8.3.9 的事务落库能力和端到端测试。 + +#### 8.3.4 候选到正式请求的转换器 + +转换流程: + +```text +1. 读取 preview 中的 selected_candidate / candidates。 +2. 校验 confirm candidate_id 存在。 +3. 选择 changes: + - edited_changes 非空:使用 edited_changes。 + - edited_changes 为空:使用 candidate 原始 changes。 +4. NormalizeChanges: + - 排序。 + - 填充缺省 duration。 + - 合并连续节次。 + - 生成 normalized hash。 +5. ValidateChangeScope: + - 不允许新增 preview 中不存在的 target。 + - 不允许越过 candidate 的 edited_allowed 范围。 +6. ConvertToApplyRequest: + - 按 change_type 转换为具体写入命令。 +``` + +转换器输出: + +```text +ApplyCommand + command_type # insert_task_pool_event / insert_makeup_event / batch_apply_task_items / split_compress_event + target_type + target_id + slots + source_event_id + metadata +``` + +转换器不做 DB 写入,只生成可校验、可事务执行的命令。 + +#### 8.3.5 重校验规则 + +正式写入前必须重新读取数据库真值: + +1. 预览归属: + - preview 属于当前 user。 + - preview 未过期。 + - preview 未 applied / ignored / expired。 +2. target 归属: + - task_pool 属于当前 user。 + - task_item 属于当前 user 的 task_class。 + - schedule_event 属于当前 user。 +3. target 状态: + - task_pool 未完成。 + - task_pool 未进入日程。 + - task_item 若走 `BatchApplyPlans`,必须未安排。 + - 补做块允许原任务已安排,但不能更新原任务的 embedded_time。 +4. 时间合法: + - week / day_of_week / section 在合法范围内。 + - 相对时间能转换为绝对时间。 + - 节次数量符合 duration。 +5. 冲突合法: + - 不覆盖课程。 + - 不覆盖固定日程。 + - 不覆盖已确认任务。 + - 若嵌入课程,必须满足课程可嵌入规则。 +6. base_version: + - 受影响范围内 schedule 版本或更新时间摘要未变化。 + - 若变化,拒绝 apply,提示重新生成 preview。 + +#### 8.3.6 task_pool 正式落库策略 + +`add_task_pool_to_schedule` 写入: + +```text +schedule_events + user_id = user_id + name = task.title + type = task + task_source_type = task_pool + rel_id = tasks.id + start_time = conv.RelativeTimeToRealTime(...) + end_time = conv.RelativeTimeToRealTime(...) + can_be_embedded = false + +schedules + event_id = schedule_events.id + user_id = user_id + week = week + day_of_week = day_of_week + section = each section + status = normal + embedded_task_id = null +``` + +事务步骤: + +```text +1. 读取 task,校验 user_id / completed / status。 +2. 校验 task 未已有 task_pool schedule_event。 +3. 构造 schedules 原子节次。 +4. 调用冲突检查。 +5. 插入 schedule_events。 +6. 回填 event_id 后插入 schedules。 +7. 返回 applied_event_ids。 +``` + +说明: + +1. 不创建 task_item。 +2. 不更新 task_class。 +3. 是否把 task 标记为“已安排”需要在 task 表结构阶段决定;MVP 可先通过 `schedule_events.task_source_type=task_pool + rel_id` 判断是否已进入日程。 +4. 若后续要在 task 上加 `scheduled_event_id / scheduled_at`,也应在同一事务内更新。 + +#### 8.3.7 task_item 正式落库策略 + +`add_task_item_to_schedule` / 可转换的 `local_reorder_makeup`: + +1. 若所有 change 都是未安排 task_item 的新增落位: + - 转成 `model.UserInsertTaskClassItemToScheduleRequestBatch`。 + - 调用 `TaskClassService.BatchApplyPlans(ctx, taskClassID, userID, batch)`。 +2. 使用 `BatchApplyPlans` 的条件: + - 所有 task_item 属于同一个 task_class。 + - task_class mode 为 `auto`。 + - task_item 当前未安排。 + - 不包含移动既有 schedule_event。 + - 不包含 task_pool。 +3. 不满足以上条件: + - 不调用 `BatchApplyPlans`。 + - 若是移动已排任务,MVP 拒绝 apply 或在候选生成阶段不生成该候选。 + +原因: + +1. `BatchApplyPlans` 已经包含 task_class 归属校验、时间范围校验、课程嵌入校验、冲突校验和 task_item embedded_time 更新。 +2. 但它会校验 task_item 未安排,因此不能拿它处理“已排任务的补做块”。 + +#### 8.3.8 补做块正式落库策略 + +`create_makeup` 用于未完成反馈后的新增补做块。 + +写入原则: + +1. 不移动原 schedule_event。 +2. 不更新原 task_item 的 `embedded_time`。 +3. 新增一个独立 schedule_event 和对应 schedules。 +4. 必须记录它是补做块,避免后续误认为原任务唯一安排。 + +建议表结构配合: + +```text +schedule_events.task_source_type # task_pool / task_item +schedule_events.makeup_for_event_id # nullable,指向原未完成 schedule_event +schedule_events.active_preview_id # nullable,用于审计来源 +``` + +如果 MVP 不想立刻加 `makeup_for_event_id / active_preview_id`,至少必须在 `active_schedule_previews.applied_changes_json` 中记录: + +```text +makeup_for_event_id +original_target_type +original_target_id +new_event_id +``` + +但工程倾向仍是给 `schedule_events` 加轻量来源字段,因为只存在 preview 审计里会让后续日程列表难以解释“这是补做块”。 + +写入流程: + +```text +1. 读取原 schedule_event,校验归属。 +2. 判断原 event 来源: + - task_source_type=task_pool:rel_id 指向 tasks.id。 + - task_source_type=task_item 或空:兼容旧数据,rel_id 指向 task_items.id。 +3. 构造新 schedule_event: + - type=task + - task_source_type 沿用原来源 + - rel_id 沿用原 target id + - makeup_for_event_id=原 event id +4. 插入新 event 和 schedules。 +5. 返回新 event id。 +``` + +#### 8.3.9 压缩融合正式落库策略(预留) + +`compress_with_next_dynamic_task` 第一轮实现关闭,本节只保留后续打开时的落库边界。任何 confirm 可见的候选都必须能正式应用;在该能力完成前,候选生成阶段不得返回压缩融合。 + +后续打开后的事务步骤: + +```text +1. 读取补做目标和 next_dynamic_task 当前真值。 +2. 校验 next_dynamic_task 仍是同一个 event,且未完成、未锁定、不是课程。 +3. 校验压缩后的两个时间段仍在 preview / edited_changes 允许范围内。 +4. 删除或缩短 next_dynamic_task 原 schedules。 +5. 写入补做块 schedules。 +6. 写入压缩后的 next_dynamic_task schedules。 +7. 更新两个 event 的 start_time / end_time。 +8. 记录 applied_changes_json,标明 compression_ratio=50/50 或用户编辑后的比例。 +``` + +MVP 保守规则: + +1. 只允许压缩动态任务,不允许压缩课程和固定日程。 +2. 只允许处理一个后继动态任务,不做多任务链式压缩。 +3. 压缩后每个任务至少保留 1 节,否则候选不合法。 +4. 若实现成本过高,第一版可在候选生成阶段关闭该候选;不能生成一个 confirm 后无法应用的 preview。 + +#### 8.3.10 事务与回写 + +确认 API 中的状态流: + +```text +1. preview apply_status=none +2. confirm 获取幂等锁或行锁 +3. 写 apply_id / apply_status=applying +4. 执行正式应用事务 +5. 成功: + - preview.status=applied + - preview.apply_status=applied + - 写 applied_changes_json / applied_event_ids_json / applied_at + - 可发布 schedule.apply.succeeded +6. 失败: + - preview.apply_status=failed + - 写 apply_error + - 不写 applied_event_ids + - 可发布 schedule.apply.failed +``` + +事务边界: + +1. 正式 schedule 写入和 preview apply 回写应尽量在同一个数据库事务中完成。 +2. 若 preview 表与 schedule 表未来拆库,MVP 的同步事务需迁移为 apply request + outbox。 +3. 事件发布不能早于事务成功;若使用 outbox,应在同一事务内写 outbox。 + +#### 8.3.11 应用失败分类 + +失败类型: + +```text +expired +idempotency_conflict +base_version_changed +target_not_found +target_completed +target_already_scheduled +slot_conflict +invalid_edited_changes +unsupported_change_type +db_error +``` + +处理规则: + +1. 可预期业务失败:返回明确业务错误,`apply_status=failed/rejected`。 +2. DB 或事务失败:`apply_status=failed`,保留 error code 和 trace。 +3. 幂等冲突:不进入正式写库。 +4. base_version 变化:不进入正式写库,提示重新生成预览。 +5. unsupported change type:说明候选生成和 apply 能力不匹配,视为后端 bug,trigger / preview 需可排障。 + +#### 8.3.12 与事件契约的关系 + +MVP 不发布 `schedule.apply.requested`。 + +可发布: + +```text +schedule.apply.succeeded +schedule.apply.failed +``` + +事件 payload 建议包含: + +```text +preview_id +apply_id +user_id +trigger_id +candidate_id +applied_event_ids +apply_status +error_code +trace_id +``` + +说明: + +1. succeeded / failed 是结果事件,不是请求事件。 +2. 后续异步化时再新增 `schedule.apply.requested`,并把当前 confirm 内同步逻辑迁到 apply worker。 +3. 事件 payload 放 `backend/shared/events`,不直接复用 preview DB model。 + +#### 8.3.13 测试方案 + +单元测试: + +1. `preview_changes` 转 `ApplyCommand`。 +2. `edited_changes` 为空时使用候选原始 changes。 +3. `edited_changes` 越界时拒绝。 +4. task_pool change 转 schedule_event / schedules。 +5. task_item change 转 `BatchApplyPlans` 请求。 +6. create_makeup 不更新原 task_item embedded_time。 +7. compress 后两个任务均至少保留 1 节。 +8. apply_request_hash 稳定生成。 + +集成测试: + +1. task_pool 确认成功后写入 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)`。 +2. task_pool 重复确认不会重复写事件。 +3. task_item 未安排块通过 `BatchApplyPlans` 成功落库。 +4. 已安排 task_item 补做块不调用 `BatchApplyPlans`,而是新增补做 event。 +5. slot 冲突时事务回滚,preview 写 `apply_status=failed`。 +6. base_version 变化时拒绝 apply。 +7. apply 成功后可通过 `applied_event_ids` 查到正式日程。 + +人工验收: + +1. 从详情页确认 task_pool 候选,周视图出现新的任务块。 +2. 从详情页确认补做块,原任务不被移动,新补做块出现。 +3. 制造冲突后确认,页面显示失败,数据库没有半写 event。 +4. 网络重试同一 confirm 请求,只产生一组正式日程。 ## 9. 模块六:通知触达与飞书边界 @@ -254,22 +2357,480 @@ 主动调度只发布 `notification.feishu.requested`,通知 handler/provider 负责具体投递。这样后续可以把 notification 拆成独立 Go module。 -### 9.2 需要拍板的问题 +### 9.2 已拍板结论 1. 第一版飞书通知文案是否只需要固定模板? + - 已确认:不只用固定模板。既然主动调度链路已经调用 LLM,通知文案优先由 LLM 生成简短 summary。 + - 已确认:固定模板作为 fallback,只有 LLM 生成失败、超时或返回空内容时使用,避免通知链路因为文案生成失败而整体中断。 2. 通知是否必须包含跳转链接?如果包含,Web 端预览详情 URL 规则是什么? + - 已确认:必须包含跳转链接。 + - 已确认:URL 规则采用 `/schedule-adjust/{preview_id}`,每个主动调度 preview 对应一个唯一调整链接。 3. 通知幂等键是否按 `preview_id`,还是按 `user_id + trigger_type + time_window`? + - 已确认:按 `user_id + trigger_type + time_window` 聚合去重,不按 `preview_id`。 + - 已确认:MVP 语义是同一用户同一触发类型在同一时间窗口内只推一次飞书,避免短时间重复打扰;具体 time_window 长度在表结构与状态机阶段细化。 4. 飞书 provider 第一版放在 backend worker 内,是否需要同步预留 `notification_records` 表? + - 已确认:需要落 `notification_records` 表。 + - 已确认:飞书 provider 属于不可靠外部服务调用,必须保留可观测、可重试、可排障的投递记录,而不是只写日志。 -### 9.3 待补执行计划 +### 9.3 执行计划:通知触达与飞书边界 -业务逻辑确认后补充: +本模块负责把主动调度预览转成“可观测、可重试、可去重”的飞书提醒。主动调度只发布 `notification.feishu.requested`,不直接调用飞书 provider;notification handler 负责落 `notification_records`、生成/兜底文案、调用 provider、记录结果和安排重试。 -1. `NotificationRequested` DTO。 -2. 简版 provider 接口。 -3. 飞书配置项。 -4. 幂等与失败日志。 -5. 后续迁出到 `backend/services/notification` 的边界。 +#### 9.3.1 代码落点 + +1. 事件契约: + ```text + backend/shared/events/notification.go + ``` + 只放事件类型、版本、payload DTO、基础校验和消息键构造。 +2. notification 模块: + ```text + backend/notification + ``` + 放 service、provider interface、record repo、重试策略。 +3. outbox handler: + ```text + backend/service/events/notification_feishu_requested.go + ``` + 负责注册并消费 `notification.feishu.requested`。 +4. 飞书 provider: + ```text + backend/notification/providers/feishu + ``` +5. mock provider: + ```text + backend/notification/providers/mock + ``` + 用于本地联调和自动化测试,避免真实打扰用户。 +6. 配置加载: + ```text + backend/config.example.yaml + backend/cmd/start.go + ``` + 注入 notification service 和 provider。 + +#### 9.3.2 事件契约 + +事件名: + +```text +notification.feishu.requested +``` + +版本: + +```text +event_version = 1 +``` + +payload: + +```text +FeishuNotificationRequested + notification_id # 可为空;若发布前已创建 record,则携带 + user_id + trigger_id + preview_id + trigger_type + target_type + target_id + dedupe_key + target_url # /schedule-adjust/{preview_id} + summary_text # LLM 已生成摘要,可为空 + fallback_text + trace_id + requested_at +``` + +消息键: + +```text +message_key = user_id +aggregate_id = preview_id +``` + +校验规则: + +1. `user_id / preview_id / target_url / dedupe_key` 必填。 +2. `target_url` 必须是站内相对路径,例如 `/schedule-adjust/{preview_id}`,不允许 provider payload 携带任意外部跳转链接。 +3. `summary_text` 可为空;为空时 handler 使用 fallback 文案。 +4. payload 不直接复用 `active_schedule_previews` DB model。 + +#### 9.3.3 notification_records 表结构方向 + +建议新增 `notification_records`: + +```text +id +channel # feishu +user_id +trigger_id +preview_id +trigger_type +target_type +target_id +dedupe_key +target_url +summary_text +fallback_text +fallback_used +status # pending / sending / sent / failed / dead / skipped +attempt_count +max_attempts +next_retry_at +last_error_code +last_error +provider_message_id +provider_request_json +provider_response_json +sent_at +trace_id +created_at +updated_at +deleted_at +``` + +索引建议: + +```text +uk_notification_dedupe(channel, dedupe_key) +idx_notification_status_retry(status, next_retry_at) +idx_notification_preview(preview_id) +idx_notification_user_created(user_id, created_at) +``` + +状态语义: + +1. `pending`:记录已创建,等待投递。 +2. `sending`:当前 worker 正在调用 provider。 +3. `sent`:provider 明确返回成功。 +4. `failed`:本次投递失败,但仍可重试。 +5. `dead`:达到最大重试次数或不可恢复错误,不再自动重试。 +6. `skipped`:命中去重或配置关闭,本次不投递。 + +#### 9.3.4 Provider 接口 + +notification 模块只依赖 provider interface: + +```go +type Provider interface { + Send(ctx context.Context, req SendRequest) (SendResult, error) +} + +type SendRequest struct { + UserID int + OpenID string + TargetURL string + Title string + Text string + TraceID string +} + +type SendResult struct { + ProviderMessageID string + RawResponse []byte + Retryable bool +} +``` + +职责边界: + +1. provider 只负责和飞书通信。 +2. provider 不做 dedupe。 +3. provider 不读取 preview。 +4. provider 不决定是否通知。 +5. provider 返回错误分类,notification service 决定 retry / dead。 + +MVP provider: + +1. `mock`:打印日志或写入 record,不发真实飞书。 +2. `feishu`:通过配置的 webhook / app token / open_id 发送卡片或文本。 +3. 若用户缺少飞书 open_id:记录 `failed` 或 `dead`,错误码为 `recipient_missing`。 + +#### 9.3.5 飞书配置项 + +建议配置: + +```yaml +notification: + enabled: true + provider: mock # mock / feishu + baseURL: "https://your-web-domain.example.com" + dedupeWindow: 30m + maxRetry: 5 + retryBaseDelay: 30s + retryMaxDelay: 30m + feishu: + enabled: false + webhookURL: "" + appID: "" + appSecret: "" +``` + +说明: + +1. `baseURL` 用于把 `/schedule-adjust/{preview_id}` 拼成飞书可点击链接。 +2. 本地和测试环境默认 `provider=mock`。 +3. `notification.enabled=false` 时不调用 provider,但仍可按需要写 `skipped` record 便于验证链路。 +4. `dedupeWindow` 默认可先与 `important_urgent_task` 的 30 分钟触发去重窗口保持一致。 + +#### 9.3.6 文案生成与 fallback + +文案来源优先级: + +```text +1. payload.summary_text +2. preview.notification_summary +3. 后端固定 fallback_text +``` + +固定 fallback: + +```text +我为你生成了一份日程调整建议,请回到系统确认是否应用。 +``` + +校验规则: + +1. summary 为空:使用 fallback。 +2. summary 过长:截断或使用 fallback,避免飞书卡片超限。 +3. summary 包含不允许的链接:去除链接或使用 fallback。 +4. LLM summary 失败不能阻断通知投递。 +5. `fallback_used=true` 必须记录到 `notification_records`,方便排查 LLM 文案质量。 + +#### 9.3.7 通知处理流程 + +handler 消费 `notification.feishu.requested`: + +```text +1. 解析 shared/events payload。 +2. 校验 user_id / preview_id / target_url / dedupe_key。 +3. 按 channel + dedupe_key 查询 notification_records。 +4. 若已有 pending / sending / sent: + - 标记当前 outbox consumed。 + - 不重复创建记录,不重复发飞书。 +5. 若已有 failed: + - 复用旧 record 进入重试流程,不新建重复通知。 +6. 若不存在 record: + - 创建 pending 记录。 +7. 读取用户飞书身份或 webhook 目标。 +8. 生成最终文案。 +9. 将 record 标记 sending,递增 attempt_count。 +10. 调用 provider.Send。 +11. 成功:status=sent,写 provider_message_id / response / sent_at。 +12. 可重试失败:status=failed,写 last_error / next_retry_at。 +13. 不可恢复失败:status=dead,写 last_error。 +``` + +outbox 语义: + +1. handler 业务处理成功后才把 outbox 标记 consumed。 +2. 对 provider 临时失败,可选择: + - 让 outbox 重试整个 handler。 + - 或 handler 自己写 `notification_records.next_retry_at` 后 consumed,由 notification retry scanner 处理。 +3. MVP 建议采用“record 自己管理 provider 重试,outbox 只保证 notification request 被接收一次”的模式,避免 provider 慢失败阻塞通用 outbox 消费。 + +#### 9.3.8 provider 重试扫描器 + +新增 notification retry worker: + +```text +1. 扫描 status=failed 且 next_retry_at <= now 的 notification_records。 +2. 加行锁或状态 CAS,改为 sending。 +3. 再次调用 provider。 +4. 成功则 sent。 +5. 失败则根据 attempt_count / max_attempts 决定 failed 或 dead。 +``` + +退避策略: + +```text +next_retry_at = now + min(retryBaseDelay * 2^(attempt_count-1), retryMaxDelay) +``` + +不可重试错误: + +```text +recipient_missing +invalid_url +provider_auth_failed +payload_invalid +``` + +可重试错误: + +```text +provider_timeout +provider_rate_limited +provider_5xx +network_error +``` + +#### 9.3.9 幂等与去重 + +通知 dedupe key: + +```text +user_id + trigger_type + time_window +``` + +MVP 窗口: + +```text +time_window = floor(requested_at / 30m) +``` + +规则: + +1. 同一 `channel + dedupe_key` 同一时间只允许一条有效 notification record。 +2. 如果同一 dedupe key 已 sent,不再发送。 +3. 如果同一 dedupe key 已 pending / sending,不再创建。 +4. 如果同一 dedupe key failed,进入重试,不创建第二条。 +5. preview_id 不参与 dedupe 主键,但 record 仍保存 preview_id,用于知道最终跳转到哪份预览。 + +注意: + +1. 如果同一窗口多个 preview 命中同一 dedupe_key,MVP 先以减少打扰为优先,只保留第一条通知。 +2. 后续如需“聚合多条 preview”,可在 record 中增加 `related_preview_ids_json`,但不作为第一版范围。 + +#### 9.3.10 与主动调度的边界 + +active_scheduler 负责: + +1. 决定是否需要通知。 +2. 生成 preview。 +3. 生成 `notification.feishu.requested` payload。 +4. 发布 outbox 事件。 + +notification 负责: + +1. dedupe。 +2. 落 `notification_records`。 +3. 文案 fallback。 +4. provider 调用。 +5. provider retry。 +6. provider 结果观测。 + +notification 不负责: + +1. 生成调度候选。 +2. 修改 preview。 +3. 应用日程。 +4. 判断任务是否紧急。 + +#### 9.3.11 启动与注册 + +接入点: + +1. `cmd/start.go` 初始化 notification service。 +2. `RegisterCoreOutboxHandlers` 增加 `RegisterFeishuNotificationRequestedHandler`。 +3. `worker` 和 `all` 模式启动 notification retry scanner。 +4. `api` 模式只允许发布 outbox,不启动 provider 消费和 retry scanner。 + +依赖注入: + +```text +notification service + -> notification repo + -> provider(mock/feishu) + -> user contact reader + -> config +``` + +若第一版暂时没有用户飞书身份表: + +1. provider 先支持 webhook 模式,用测试群 webhook 完成链路验证。 +2. `user contact reader` 预留接口,后续再接 user profile / feishu binding。 + +#### 9.3.12 迁出边界 + +后续迁出独立 notification 服务时保留: + +```text +backend/shared/events/notification.go +notification_records schema +Provider 接口语义 +dedupe_key 规则 +``` + +迁移方式: + +1. active_scheduler 继续只发布 `notification.feishu.requested`。 +2. notification 服务独立消费同一事件。 +3. 原 backend worker 停止注册 notification handler。 +4. `notification_records` 可按数据所有权迁出,或先保留在同库读写。 + +不能迁出的内容: + +1. active_scheduler 内部候选结构。 +2. preview DB model 的完整字段。 +3. 飞书 provider SDK 细节。 + +#### 9.3.13 错误处理与可观测 + +必须记录: + +```text +notification_id +dedupe_key +preview_id +trigger_id +channel +status +attempt_count +last_error_code +last_error +provider_message_id +trace_id +``` + +日志要求: + +1. 每次 provider 调用记录 `notification_id / preview_id / attempt_count / trace_id`。 +2. provider response 不直接打印敏感 token。 +3. dead 状态必须有明确 error_code。 +4. dedupe 命中不视为错误,但要记录 debug / info 日志。 + +指标建议: + +```text +notification_requested_total +notification_sent_total +notification_failed_total +notification_dead_total +notification_dedupe_hit_total +notification_fallback_used_total +notification_provider_latency_ms +``` + +#### 9.3.14 测试方案 + +单元测试: + +1. `notification.feishu.requested` payload validate。 +2. dedupe key 生成。 +3. summary 为空时使用 fallback。 +4. summary 过长时截断或 fallback。 +5. provider 可重试错误计算 `next_retry_at`。 +6. provider 不可重试错误进入 dead。 +7. 同一 `channel + dedupe_key` 不重复创建 record。 + +集成测试: + +1. preview 生成后发布 `notification.feishu.requested`。 +2. handler 消费事件后写 `notification_records`。 +3. mock provider 成功后 record 变为 sent。 +4. mock provider 临时失败后 record 变为 failed,并写 next_retry_at。 +5. retry scanner 再次投递成功后 record 变为 sent。 +6. 重复消费同一 outbox 不重复发通知。 +7. `notification.enabled=false` 时生成 skipped 或不调用 provider,链路可观测。 + +人工验收: + +1. 使用 mock provider 验证 dry-run 不发通知、正式 trigger 发通知记录。 +2. 使用测试飞书 webhook 收到包含 `/schedule-adjust/{preview_id}` 的消息。 +3. 模拟 provider 失败后能看到 failed / retry / sent 状态变化。 +4. 30 分钟窗口内重复触发,不重复收到飞书。 ## 10. 模块七:与微服务迁移的协作边界 @@ -277,39 +2838,489 @@ 第二阶段开发必须避免阻塞微服务迁移。当前策略是:先在 `backend` 内按服务边界写清楚,等协议稳定后再迁出独立 module。 -API、worker、active scheduler、notification、schedule apply 的边界必须从第一版就分清。 +`api / worker / all` 启动边界第一阶段已经完成。当前剩余工作不是继续拆启动入口,而是在既有 worker / API 边界上接入主动调度、notification 和 schedule apply。 -### 10.2 需要拍板的问题 +API、worker、active scheduler、notification、schedule apply 的职责边界仍必须从第一版就分清。 + +### 10.2 已拍板结论 1. 是否先完成 `api / worker / all` 启动边界拆分,再合入主动调度主链路? + - 已确认:当前已完成第一阶段启动边界拆分,存在 `api / worker / all` 三种启动入口。 + - 已确认:`api` 模式只启动 Gin 和同步 service / DAO 依赖,不启动后台 worker;`worker` 模式只启动 outbox、Kafka consumer、事件 handler、memory worker,不注册 Gin 路由;`all` 模式保留迁移期单体兼容行为。 + - 已确认:主动调度 MVP 可以直接挂到 worker / 事件链路,不需要再等待启动边界拆分。 + - 说明:这里完成的是运行生命周期边界,不是完整微服务拆分;独立 Go module、独立部署配置和数据所有权拆分后续再做。 2. 主动调度代码第一版放在 `backend/service/active_scheduler`,还是 `backend/active_scheduler`? + - 已确认:第一版不放 `backend/service/active_scheduler`,避免继续并入旧 service 单体。 + - 已确认:第一版放 `backend/active_scheduler`,按未来独立 active-scheduler 服务组织目录、DTO、状态机、pipeline 和 handler。 + - 已确认:MVP 暂不拆成独立 Go module / 独立进程,仍复用当前 `backend` 的启动、DAO、outbox、LLM 初始化和事务能力。 + - 已确认:等事件契约、表结构、预览 / apply 协议稳定后,再按并行迁移策略迁出独立 active-scheduler module。 3. 事件契约是否提前放入 `backend/shared/events` 风格目录,即使当前还未多 module? + - 已确认:提前放入 `backend/shared/events`。 + - 已确认:该目录只承载跨模块事件协议,包括 event type、event version、payload DTO、基础校验和少量 normalize。 + - 已确认:该目录不放 DAO、service、handler、provider、LLM prompt、复杂业务判断,避免 shared 目录变成共享业务层。 + - 已确认:主动调度、notification、worker handler、API 依赖 `backend/shared/events`,而不是互相依赖业务包。 + - 已确认:后续微服务切流时,`backend/shared/events` 可迁出为独立 contracts module。 4. 第一版是否允许主动调度 service 直接依赖 DAO,还是通过现有 service 读取? + - 已确认:不允许主动调度主链路散落依赖其它领域 DAO。 + - 已确认:采用 port / adapter 方式组织依赖。`backend/active_scheduler` 内定义读取事实和正式应用所需的接口,MVP adapter 可复用现有 service;若现有 service 缺少合适读模型,允许 adapter 内部调用 DAO 组装,但不能把 DAO 泄漏到主动调度 pipeline。 + - 已确认:主动调度自有表使用 `backend/active_scheduler` 自己的 repo / DAO。 + - 已确认:正式写入 schedule / task_class 必须走现有领域 service 或明确的 apply port,不能在主动调度里绕过既有写入链路。 + - 已确认:notification provider 不归 active_scheduler 管;主动调度只发布 `notification.feishu.requested`。 -### 10.3 待补执行计划 +### 10.3 执行计划:迁移协作边界与装配方案 -业务逻辑确认后补充: +本模块负责把主动调度、notification、API、worker、正式应用链路的代码边界和启动边界固定下来。第一版仍在 `backend` 单体内实现,但目录、事件契约、port / adapter 和启动装配必须按未来独立服务来组织,避免 MVP 写成新的大单体。 -1. 目录结构。 -2. 依赖注入关系。 -3. API / worker 启动装配改动点。 -4. 未来迁出 `active-scheduler` 的文件边界。 +#### 10.3.1 目录总览 -## 11. 建议讨论顺序 +建议目录: -建议按以下顺序逐个讨论: +```text +backend/ + active_scheduler/ + trigger/ + context/ + observe/ + candidate/ + selection/ + preview/ + apply/ + convert/ + job/ + ports/ + adapters/ + repo/ + model/ + timegrid/ + scheduleutil/ -1. 任务池任务如何进入日程视图。 -2. 预览与确认协议。 -3. 主动观测候选 schema。 -4. 触发事件与 worker 链路。 -5. 正式应用链路。 -6. 飞书通知边界。 -7. 目录结构与迁移边界。 + notification/ + service/ + repo/ + model/ + providers/ + mock/ + feishu/ + retry/ + + shared/ + events/ + active_schedule.go + notification.go + schedule_apply.go + + service/ + events/ + active_schedule_triggered.go + notification_feishu_requested.go + schedule_apply_result.go + + api/ + active_schedule.go +``` + +目录职责: + +1. `backend/active_scheduler`:主动调度业务闭环,拥有 job / trigger / preview 自有表。 +2. `backend/notification`:通知投递业务,拥有 `notification_records`。 +3. `backend/shared/events`:跨模块事件契约,只放 DTO / event type / version / validate。 +4. `backend/service/events`:当前单体 worker 的 outbox handler 注册和消费实现。 +5. `backend/api/active_schedule.go`:HTTP 入站,负责鉴权、绑定请求、调用 active_scheduler service。 + +禁止事项: + +1. 不把主动调度放进 `backend/service/active_scheduler`。 +2. 不把 notification provider 放进 active_scheduler。 +3. 不在 `shared/events` 放 DAO、service、provider、LLM prompt。 +4. 不让 active_scheduler 主链路直接 import 其它领域 DAO。 + +#### 10.3.2 active_scheduler 内部分层 + +推荐主链路: + +```text +trigger + -> context + -> observe + -> candidate + -> selection + -> preview + -> notification event +``` + +各层职责: + +1. `trigger`:统一 dry-run / API trigger / worker due job / unfinished feedback 入口,处理去重和 trigger 状态。 +2. `context`:构造 `ActiveScheduleContext`,读取事实快照。 +3. `observe`:生成 metrics / issues / decision。 +4. `candidate`:生成并校验候选。 +5. `selection`:调用 LLM 做候选选择和解释,失败时受限重试,再 fallback。 +6. `preview`:写 `active_schedule_previews`,提供详情查询、confirm 状态回写。 +7. `apply`:确认后同步调用正式应用链路。 +8. `job`:扫描 `active_schedule_jobs` 到期任务并发布 trigger。 +9. `ports`:定义 `TaskReader / ScheduleReader / MemoryContextReader / TaskClassReader / ScheduleApplyPort / NotificationPublisher`。 +10. `adapters`:把 ports 接到当前单体里的 service / DAO / memory / outbox。 + +#### 10.3.3 notification 内部分层 + +推荐主链路: + +```text +notification.feishu.requested + -> service + -> record repo + -> provider + -> retry scanner +``` + +职责: + +1. `service`:处理 dedupe、文案 fallback、provider 调用、状态流转。 +2. `repo`:管理 `notification_records`。 +3. `providers/mock`:本地测试,不发真实飞书。 +4. `providers/feishu`:飞书 webhook / app 调用。 +5. `retry`:扫描 failed 记录,按退避策略重试。 + +notification 不读取 active_scheduler 内部 model,只消费 `shared/events.NotificationFeishuRequested` 和必要的 preview 查询接口。 + +#### 10.3.4 依赖注入关系 + +`cmd/start.go` 的 `buildRuntime` 继续作为单体装配入口。 + +建议新增 runtime 字段: + +```text +activeSchedulerService +activeSchedulerJobRunner +notificationService +notificationRetryRunner +activeScheduleHandler +notificationProvider +``` + +装配顺序: + +```text +1. 初始化 config / db / redis / aiHub / rag / memory。 +2. 初始化 DAO / RepoManager / outboxRepo / eventBus。 +3. 初始化现有 user / task / schedule / taskClass / agent service。 +4. 初始化 active_scheduler repo。 +5. 初始化 active_scheduler adapters: + - TaskReader -> task service / task DAO adapter + - ScheduleReader -> schedule service / schedule DAO adapter + - MemoryContextReader -> memory.Retrieve + 公共渲染 helper + - TaskClassReader -> taskClass service / DAO adapter + - ScheduleApplyPort -> schedule / taskClass apply adapter + - NotificationPublisher -> outbox event publisher +6. 初始化 active_scheduler service / job runner。 +7. 初始化 notification repo / provider / service / retry runner。 +8. 初始化 API handlers。 +``` + +依赖方向: + +```text +api -> active_scheduler service +worker handler -> active_scheduler service +active_scheduler -> ports +ports adapter -> existing service / DAO / memory / outbox +notification handler -> notification service +notification service -> provider +``` + +不允许: + +```text +notification -> active_scheduler internal candidate model +active_scheduler observe/candidate -> dao.ScheduleDAO +api handler -> dao.ActiveSchedulePreviewDAO +shared/events -> active_scheduler repo +``` + +#### 10.3.5 API 接入装配点 + +新增 handler: + +```text +api.NewActiveScheduleHandler(activeSchedulerService) +``` + +`ApiHandlers` 增加: + +```text +ActiveScheduleHandler *ActiveScheduleHandler +``` + +路由: + +```text +POST /active-schedule/dry-run +POST /active-schedule/trigger +GET /active-schedule/previews/{preview_id} +POST /active-schedule/previews/{preview_id}/confirm +POST /active-schedule/previews/{preview_id}/ignore +``` + +API 模式职责: + +1. 可以调用 dry-run。 +2. 可以写 trigger 和 outbox。 +3. 可以查询 preview。 +4. 可以同步 confirm apply。 +5. 不启动 due job scanner。 +6. 不消费 outbox。 +7. 不启动 notification retry scanner。 + +#### 10.3.6 Worker 接入装配点 + +`RegisterCoreOutboxHandlers` 增加: + +```text +RegisterActiveScheduleTriggeredHandler(...) +RegisterFeishuNotificationRequestedHandler(...) +``` + +worker 模式启动: + +```text +1. eventBus.Start(ctx) +2. memoryModule.StartWorker(ctx) +3. activeSchedulerJobRunner.Start(ctx) +4. notificationRetryRunner.Start(ctx) +``` + +worker handler 职责: + +1. `active_schedule.triggered`: + - 解析 shared event。 + - 幂等检查 trigger。 + - 调用 active_scheduler pipeline。 + - 写 preview。 + - 发布 notification event。 +2. `notification.feishu.requested`: + - 写 / 查 notification record。 + - 调 provider。 + - 记录 sent / failed / dead。 + +注意: + +1. worker 不注册 Gin 路由。 +2. worker 不处理用户 confirm HTTP 请求。 +3. confirm 是 API 强交互动作,MVP 同步执行。 + +#### 10.3.7 all 模式接入 + +`all` 模式仍是迁移期兼容入口: + +```text +StartAll: + buildRuntime + startWorkers + startHTTP +``` + +要求: + +1. 行为等于 API + worker 同进程。 +2. 本地开发可优先使用 all 跑全链路。 +3. 生产逐步切到 api / worker 分进程。 +4. 不能在 all 模式写专属业务逻辑。 + +#### 10.3.8 配置项 + +建议新增: + +```yaml +activeScheduler: + enabled: true + jobScanInterval: 30s + jobScanBatch: 100 + triggerDedupeWindow: 30m + previewTTL: 1h + llmSelectionRetry: 1 + dryRunAllowMockNow: true + +notification: + enabled: true + provider: mock + baseURL: "https://your-web-domain.example.com" + dedupeWindow: 30m + maxRetry: 5 + retryBaseDelay: 30s + retryMaxDelay: 30m + feishu: + enabled: false + webhookURL: "" + appID: "" + appSecret: "" +``` + +配置规则: + +1. `activeScheduler.enabled=false` 时不启动 job scanner,不消费主动调度事件;API 可返回功能关闭。 +2. `notification.enabled=false` 时不调用 provider,可写 skipped record。 +3. `provider=mock` 是本地默认。 +4. `previewTTL` 与 7.3 保持一致,默认 1 小时。 +5. `llmSelectionRetry` 默认 1,对齐 6.3 的受限重试。 + +#### 10.3.9 数据所有权 + +active_scheduler 拥有: + +```text +active_schedule_jobs +active_schedule_triggers +active_schedule_previews +``` + +notification 拥有: + +```text +notification_records +``` + +schedule 域拥有: + +```text +schedule_events +schedules +task_items.embedded_time +``` + +task 域拥有: + +```text +tasks +``` + +规则: + +1. active_scheduler 可以写自己的表。 +2. active_scheduler 读取 task / schedule / task_class 事实必须走 port。 +3. active_scheduler 正式写 schedule 必须走 apply port。 +4. notification 只写 notification_records,不写 preview / schedule。 +5. preview 表可以记录 `applied_event_ids`,但不拥有这些 event。 + +#### 10.3.10 未来迁出 active-scheduler 的文件边界 + +未来可整体迁出的目录: + +```text +backend/active_scheduler +backend/shared/events/active_schedule.go +backend/shared/events/schedule_apply.go +``` + +迁出时需要替换的 adapter: + +```text +TaskReader local adapter -> task service RPC / HTTP adapter +ScheduleReader local adapter -> schedule service RPC / read model adapter +TaskClassReader local adapter -> task-class service RPC adapter +MemoryContextReader adapter -> memory service RPC adapter +ScheduleApplyPort local adapter -> schedule apply RPC / event adapter +NotificationPublisher adapter -> Kafka producer / outbox adapter +``` + +不应迁出的内容: + +```text +backend/api/active_schedule.go +backend/service/events/active_schedule_triggered.go +backend/notification +backend/service/task-class.go +backend/service/schedule.go +``` + +说明: + +1. API handler 属于当前 backend API 入口,未来可改成调用 active-scheduler 服务。 +2. outbox handler 是当前单体 worker 的接线层,未来独立服务会自己消费事件。 +3. notification 是独立服务边界,不随 active-scheduler 迁出。 +4. schedule / task / task-class 领域 service 不随 active-scheduler 迁出。 + +#### 10.3.11 未来迁出 notification 的文件边界 + +未来可整体迁出的目录: + +```text +backend/notification +backend/shared/events/notification.go +``` + +当前单体内保留 / 替换: + +```text +backend/service/events/notification_feishu_requested.go +``` + +迁出步骤: + +1. 独立 notification 服务消费 `notification.feishu.requested`。 +2. backend worker 停止注册 `RegisterFeishuNotificationRequestedHandler`。 +3. active_scheduler 继续发布同一个事件。 +4. `notification_records` 按数据所有权迁出,或迁移期继续同库。 + +#### 10.3.12 并行迁移策略 + +遵循并行迁移: + +```text +1. 新目录先落地。 +2. 旧 service / DAO 保持不动。 +3. adapter 调现有能力。 +4. 跑通 API dry-run / trigger / worker / preview / confirm / notification。 +5. 协议稳定后再切 module / 服务边界。 +6. 最后清理旧兼容代码。 +``` + +本轮不做: + +1. 不拆独立 Go module。 +2. 不新增独立部署配置。 +3. 不把 schedule / task 远程化。 +4. 不重命名大范围旧目录。 +5. 不删除现有 Agent 排程预览能力。 + +#### 10.3.13 验收 checklist + +| 动作 | 预期 | +| --- | --- | +| `api` 模式启动 | 注册主动调度 API,不启动 worker / job scanner / notification retry | +| `worker` 模式启动 | 不占用 HTTP 端口,注册主动调度和 notification outbox handler | +| `all` 模式启动 | API + worker 同进程跑通全链路 | +| API dry-run | 不写 trigger / preview / notification | +| API trigger | 写 trigger 并发布 `active_schedule.triggered` | +| worker 消费 trigger | 生成 preview 并发布 `notification.feishu.requested` | +| notification handler 消费事件 | 写 notification_records 并调用 mock / feishu provider | +| confirm API | 同步 apply,并回写 preview apply 状态 | +| 关闭 notification.enabled | 不调用 provider,但链路可观测 | +| 关闭 activeScheduler.enabled | 不启动主动调度后台能力,API 返回功能关闭或明确错误 | + +#### 10.3.14 风险控制 + +1. 若 adapter 需要直接调 DAO,必须只出现在 `backend/active_scheduler/adapters`,并返回主动调度自己的 facts DTO。 +2. 若发现同一公共能力第三次复制,优先抽公共 helper。 +3. 若要修改 `schedule_events / schedules / task_items` 结构,必须配合迁移 SQL 和兼容旧数据。 +4. 若 notification provider 未配置,默认 mock,不阻断主动调度 preview。 +5. 若 outbox 未启用,正式 trigger 应返回明确错误或降级为同步 dry-run,不假装已通知。 +6. 新增 Eino / LLM 能力前必须按项目规则先查官方文档;本节只定义边界,不直接编码。 + +## 11. 实施顺序 + +本章作为开工时的短施工单,详细阶段计划见 0.1。 + +1. 先做迁移 SQL、model、repo、shared events,保证后续模块有稳定契约。 +2. 再做 active_scheduler dry-run:context / observe / candidate,不写 preview、不发通知。 +3. 再做 preview 查询与写入,跑通正式 trigger 后生成待确认预览。 +4. 再做 confirm apply,同步重校验并事务写正式日程。 +5. 再做 notification mock / webhook 和 retry。 +6. 最后接 due job scanner、worker handler、端到端验收。 +7. 第一轮不打开压缩融合;主链路稳定后再单独评估该候选。 ## 12. 本轮决策记录 -后续每轮讨论完成后,在这里追加结论。 +本章保留本轮已经拍板的实施结论,作为编码时遇到细节分歧的裁决依据。 ### 12.1 触发 job 机制 @@ -319,11 +3330,14 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 4. `task` 更新 `deadline_at` 或 `urgency_threshold_at` 时,直接覆盖当前有效 job,并刷新 `updated_at`。 5. schedule 动态任务默认不写定时 job;计划时间过去后按 `assumed_completed` 推进,只有用户明确反馈未完成时才进入主动调度链路。 -### 12.2 待继续讨论 +### 12.2 最终实施拍板 -1. `schedule.apply.requested` 第一版同步调用 service,还是进入 outbox 异步消费。 -2. 应用幂等键使用 `preview_id + candidate_id`,还是单独生成 `apply_id`。 -3. 飞书通知固定文案、跳转 URL、通知幂等键和 `notification_records` 是否第一版落表。 +1. 主动调度相关表和状态机按 4.3 / 7.3 / 9.3 / 10.3 执行。 +2. `tasks` 本轮新增 `estimated_sections`,默认 1,MVP 允许 1~4。 +3. `schedule_events` 本轮新增 `task_source_type / makeup_for_event_id / active_preview_id`。 +4. `compress_with_next_dynamic_task` 第一轮关闭,不生成候选。 +5. 飞书第一轮使用 mock / webhook,不依赖用户 open_id 绑定。 +6. notification 去重窗口第一轮为 30 分钟。 ### 12.3 API 触发、mock_now 与去重 @@ -359,7 +3373,7 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 5. 四象限任务进入日程后使用 `type = task, task_source_type = task_pool`,不创建孤儿 `task_item`。 6. 不扩展 `schedule_events.type` 为 `quadrant_task`,避免把任务来源语义混入日程块展示类型,也避免影响现有按 `event.Type == "task"` 判断的前端、冲突、撤销和预览逻辑。 -执行计划待补:需要评估迁移 SQL、模型字段、schedule 读取映射、task_pool apply 链路以及历史 `type=task` 数据的默认来源回填策略。 +实施要求:迁移 SQL 需要回填历史 `type=task` 数据为 `task_source_type=task_item`,新写入的 task_pool 任务必须显式写 `task_source_type=task_pool`。 ### 12.6 主动观测链路形态 @@ -400,13 +3414,14 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 ### 12.8 压缩融合兜底候选 -1. 压缩融合只作为局部重排和延后结束都不可用时的兜底候选。 -2. 第一版固定选择“下一个动态任务”作为融合对象,不做跨多个后继任务的复杂搜索。 -3. 第一版固定比例为 50% / 50%: +1. 压缩融合只作为局部重排和延后结束都不可用时的后续兜底候选。 +2. 第一轮实现先关闭,不生成 `compress_with_next_dynamic_task`。 +3. 后续打开时固定选择“下一个动态任务”作为融合对象,不做跨多个后继任务的复杂搜索。 +4. 后续打开时默认比例为 50% / 50%: - 未完成任务压缩到融合块的一半时间。 - 下一个动态任务压缩到融合块的一半时间。 -4. 压缩融合必须写清风险说明:两个任务都会被压缩,需要用户接受 rush 模式。 -5. 压缩融合只生成预览,不允许后台自动执行。 +5. 压缩融合必须写清风险说明:两个任务都会被压缩,需要用户接受 rush 模式。 +6. 压缩融合只生成预览,不允许后台自动执行。 ### 12.9 主动调度裁决模式 @@ -459,7 +3474,7 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 2. 第一版必须保存: - `base_version`:生成预览时的日程基准版本,可使用 schedule hash、相关 event 更新时间摘要或等价版本标识。 - `before_summary`:只保存受影响范围的改前信息,例如受影响 event、空闲槽位、原 task_item 落位。 - - `preview_changes`:候选准备做的改动,例如新增 task_pool 日程、移动 task_item、压缩融合预览。 + - `preview_changes`:候选准备做的改动,例如新增 task_pool 日程、创建补做块、移动可安全处理的 task_item;压缩融合字段只保留后续预留。 3. `before_summary + preview_changes` 的用途: - 给用户展示改前 / 改后。 - 用户确认时校验当前日程是否仍符合预览生成时的基准。 @@ -479,9 +3494,9 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 ### 12.12 用户确认入口与聊天增强预留 1. MVP 不走现有 Agent resume 协议,新增主动调度详情页与主动调度确认 API。 -2. 飞书通知只包含详情页链接,默认进入: +2. 飞书通知包含 LLM 生成的简短摘要和详情页链接,默认进入: ```text - /active-schedule/previews/:preview_id + /schedule-adjust/{preview_id} ``` 3. 详情页体验采用“助手卡片式”设计,但后端不依赖完整 Agent Chat: - 顶部展示助手解释文案。 @@ -513,6 +3528,161 @@ API、worker、active scheduler、notification、schedule apply 的边界必须 - 前端提示用户重新生成建议。 4. 确认 API 必须校验过期状态,避免用户对旧日程基准执行过期候选。 +### 12.14 正式应用同步策略与幂等 + +1. `schedule.apply.requested` 第一版不进入 outbox 异步消费,确认 API 内同步调用正式应用 service。 +2. 同步 apply 的职责包括: + - 校验 preview 存在、属于当前用户且未过期。 + - 校验 preview 尚未 `applied / rejected / expired`。 + - 校验 `candidate_id` 属于当前 preview。 + - 校验 `edited_changes` 没有越权改目标、没有越过候选允许范围、没有产生日程冲突。 + - 在事务内写入正式日程。 + - 成功或失败后回写 preview 的 apply 状态。 +3. 第一版不新增独立 `active_schedule_apply_requests` 表;apply 尝试状态先落在 `active_schedule_previews` 的 apply 字段中。 +4. 仍然生成独立 `apply_id`,用于标识一次用户确认应用尝试。 +5. 确认请求必须携带 `idempotency_key`,后端建议按 `preview_id + idempotency_key` 做幂等约束。 +6. `preview_id + candidate_id` 只定位“用户基于哪一个候选确认”,不代表最终应用内容;若用户拖动 after 方案,最终落库内容以 `edited_changes` 为准。 +7. 同一个 preview MVP 只允许成功 apply 一次;apply 成功后再次确认直接返回已应用结果或业务错误,避免重复写入正式日程。 +8. 后续若 apply 变重或需要跨服务恢复,再迁移为 `active_schedule_apply_requests + schedule.apply.requested` 异步消费;迁移时复用当前 `apply_id / idempotency_key / apply_status` 语义。 + +### 12.15 飞书通知最小实现 + +1. 飞书通知第一版不是纯固定模板: + - 主链路已调用 LLM 时,顺手生成一段面向用户的调整摘要。 + - 摘要应短、明确、可行动,避免制造焦虑。 + - 固定模板只作为 fallback,用于 LLM 超时、失败、返回空内容或内容校验不过时。 +2. 飞书通知必须包含跳转链接: + ```text + /schedule-adjust/{preview_id} + ``` + 每个 `preview_id` 对应唯一详情 / 调整页面,用户从飞书点击后回系统查看并确认。 +3. 通知幂等键按 `user_id + trigger_type + time_window` 聚合,而不是按 `preview_id`。 +4. MVP 的去重含义是:同一用户、同一触发类型、同一时间窗口内只发一条飞书,避免主动调度在短时间内重复打扰用户。 +5. 飞书 provider 第一版可以放在 backend worker 内,但必须同步落 `notification_records` 表。 +6. `notification_records` 用于: + - 记录待发送、发送中、成功、失败、死亡状态。 + - 保存 provider 请求摘要、响应摘要、失败原因和重试次数。 + - 支撑后台重试和人工排障。 + - 串联 `trigger_id / preview_id / notification_id`,回答“为什么发了这条飞书”。 +7. `notification.feishu.requested` 事件只表达“需要通知用户回来确认”,不承载飞书内确认、日程应用或聊天回复能力。 + +### 12.16 启动边界拆分状态 + +1. `api / worker / all` 启动边界第一阶段已经完成,不再作为主动调度 MVP 的前置阻塞项。 +2. 当前启动边界语义: + - `api`:只启动 Gin HTTP 与同步 service / DAO 依赖,不启动后台 worker。 + - `worker`:只启动 outbox relay、Kafka consumer、事件 handler、memory worker,不注册 Gin 路由。 + - `all`:保持迁移期兼容模式,同时启动 HTTP 与 worker,适合本地开发和旧启动方式兜底。 +3. 主动调度 MVP 可以直接接入现有 worker / 事件链路: + - 后台触发、outbox 消费、飞书通知投递放在 worker。 + - dry-run、trigger 测试、预览查询、确认 apply 放在 API。 + - all 模式继续用于本地一键联调。 +4. 后续还未完成的是服务边界和模块边界拆分,不是启动生命周期拆分: + - active-scheduler 尚未独立 Go module。 + - notification 尚未独立 Go module。 + - DAO / service 依赖边界已按 port / adapter 策略拍板,后续执行计划需细化具体端口。 + +### 12.17 主动调度代码目录与迁移策略 + +1. 主动调度第一版采用“准独立模块”策略。 +2. 第一版不放在 `backend/service/active_scheduler`: + - 避免主动调度继续长进旧 service 单体。 + - 避免后续迁移时再从既有 service 目录里拆业务边界。 + - 避免把主动调度 graph / pipeline / prompt / 状态机和传统同步 service 混在一起。 +3. 第一版放在: + ```text + backend/active_scheduler + ``` +4. `backend/active_scheduler` 按未来独立 active-scheduler 服务组织代码: + - `pipeline / graph`:固定主动调度链路。 + - `context`:ActiveScheduleContext 构造。 + - `observe`:确定性观测和 issue 生成。 + - `candidate`:候选生成与合法性校验。 + - `preview`:预览构造与写入。 + - `apply`:候选到正式应用请求的转换与确认入口协作。 + - `notification`:发布通知请求,不直接沉淀 provider 细节。 +5. MVP 暂不拆独立 Go module / 独立进程: + - 主动调度仍需读取 task、schedule、memory 等现有数据。 + - 确认 apply 已拍板为 API 内同步事务写库,过早独立进程会放大事务边界复杂度。 + - LLM、outbox、worker 注册和配置初始化当前仍在 `backend` 内,先复用现有装配能更快验证主链路。 +6. 后续迁出条件: + - 事件契约稳定。 + - `active_schedule_*` 表结构和状态机稳定。 + - preview / confirm / apply 协议稳定。 + - notification 与 schedule apply 的边界清楚。 +7. 迁出时优先采用并行迁移: + - 保留 `backend/active_scheduler` 旧模块。 + - 新建独立 active-scheduler Go module。 + - 先迁移事件契约和只读链路。 + - 再迁移 worker handler。 + - 验证后切流,最后删除旧实现。 + +### 12.18 事件契约目录策略 + +1. 事件契约第一版提前放入: + ```text + backend/shared/events + ``` +2. 该目录用于承载异步消息世界里的“IDL”: + - `event_type` 常量。 + - `event_version`。 + - payload DTO。 + - 基础 validate / normalize。 + - 幂等键、消息键、聚合 ID 等协议字段的构造约定。 +3. 该目录禁止承载业务实现: + - 不放 DAO。 + - 不放 service。 + - 不放 worker handler。 + - 不放 notification provider。 + - 不放 LLM prompt。 + - 不放复杂业务判断。 +4. 主动调度、notification、worker handler、API 都依赖 `backend/shared/events` 的事件契约,而不是互相 import 业务模块。 +5. 这样可以避免: + - notification 为了消费通知事件反向依赖 `backend/active_scheduler`。 + - worker handler 为了注册事件依赖具体业务内部 model。 + - 后续 active-scheduler 独立服务时需要大规模重写事件 DTO。 +6. 后续迁出微服务时,`backend/shared/events` 可以按并行迁移策略迁出为独立 contracts module,例如: + ```text + pkg/events + ``` + 或独立的 event contracts Go module。 +7. 事件 payload 不直接复用数据库 model,也不直接复用内部 service request: + - 数据库 model 容易夹带 GORM tag、关联关系和内部字段。 + - service request 往往表达同步调用语义,不等于异步业务事实。 + - 事件 payload 应表达“发生了什么 / 请求了什么异步动作”,并带明确版本。 + +### 12.19 主动调度依赖边界 + +1. 主动调度主链路不直接散落依赖其它领域 DAO。 +2. 第一版采用 port / adapter 方式: + - `backend/active_scheduler` 内定义 `TaskReader / ScheduleReader / MemoryContextReader / ApplyService` 等端口。 + - 主动调度 pipeline 只依赖这些端口,不直接 import `dao.TaskDAO / dao.ScheduleDAO / dao.TaskClassDAO` 等其它领域 DAO。 + - MVP adapter 可以复用现有 service。 + - 如果现有 service 缺少适合后台调度的读模型,允许 adapter 内部调用 DAO 组装事实快照,但 DAO 调用必须封装在 adapter 内。 +3. 主动调度自有表由主动调度自己管理: + ```text + active_schedule_jobs + active_schedule_triggers + active_schedule_previews + ``` + 这些表的数据所有权属于 active-scheduler,后续迁出独立服务时随模块迁移。 +4. 读取其它领域事实时使用 reader port: + - task 池任务读取走 `TaskReader`。 + - schedule 时间窗、冲突和空闲槽读取走 `ScheduleReader`。 + - memory / 用户偏好读取走 `MemoryContextReader`,由 adapter 复用 memory 模块 `Retrieve` 和公共渲染 helper。 + - task_class 约束读取走对应 reader port 或由 schedule/task_class adapter 组合。 +5. 正式写入必须走领域 service 或 apply port: + - task_pool 写入 schedule。 + - task_item 补做块落库。 + - schedule 冲突校验。 + - schedules 原子节次写入。 + - task_class item 状态更新。 +6. 主动调度不能绕过既有 schedule / task_class 写入链路直接改正式业务真值。 +7. notification provider 不归 `backend/active_scheduler` 管: + - 主动调度只发布 `notification.feishu.requested`。 + - `notification_records`、飞书 provider 调用、重试和失败观测属于 notification 模块 / worker handler。 +8. 这样后续迁移时可以把 adapter 从本地 DAO / service 实现替换为 RPC、HTTP 或事件投影实现,主动调度 pipeline 不需要整体重写。 + ## 13. 共识详述与实现备忘 本节用于保存讨论过程中的关键推理,避免后续上下文压缩或换对话后只剩简短结论。 @@ -789,7 +3959,7 @@ LLM 擅长: 1. `close`:重要且紧急 task 已经在 schedule 里,或任务已完成。 2. `ask_user`:用户说“刚才那个没做完”,但系统无法定位是哪条 schedule_event;或容量不足,需要问能否延后结束时间。 -3. `select_candidate`:找到合法的加入日程 / 未完成补救 / 压缩融合候选。 +3. `select_candidate`:找到合法的加入日程 / 未完成补救候选;压缩融合第一轮关闭,后续打开后再纳入该分支。 4. `notify_only`:有风险但没有安全可挪的任务,也没有一个明确问题能继续推进。 ### 13.12 未完成补救的局部重排不是全量粗排 @@ -938,3 +4108,543 @@ expires_at = generated_at + 1h 4. 确认 API 必须拒绝过期 preview。 原因:主动调度候选依赖当时日程基准,时间越久越可能被用户或其它流程改动。1 小时是 MVP 的安全折中。 + +### 13.18 为什么 MVP 确认接口内同步 apply + +第一版正式应用不走 outbox 异步消费,而是在确认 API 内同步调用正式应用 service。 + +原因: + +1. 用户确认是强交互动作,不是后台自然发生的动作。 +2. 用户点击确认后,需要尽快知道应用是否成功;同步返回成功 / 失败更符合详情页体验。 +3. 当前 MVP 的 apply 范围较小,主要是新增 task_pool 日程块、生成未完成补做块和后续少量候选变体,预计重校验与事务写库在可接受延迟内。 +4. 异步 apply 需要新增 apply request 表、恢复扫描、重复消费幂等、前端轮询或 SSE 状态同步,会把第一版链路明显拉长。 +5. 当前项目已有 outbox 能力,但主动调度 apply 还没有跨服务边界;提前异步化会让状态机复杂度先于业务复杂度增长。 + +同步 apply 不等于绕过事件语义。MVP 仍然保留以下状态和事件口径: + +```text +用户确认 + -> confirm API 生成 apply_id + -> 写入 applying 状态 + -> 事务内重校验并调用正式写入 service + -> 成功:apply_status=applied,记录 applied_event_ids + -> 失败:apply_status=failed,记录 apply_error + -> 可按需发布 schedule.apply.succeeded / schedule.apply.failed +``` + +第一版暂不发布 `schedule.apply.requested` 给 outbox 消费;该事件名可作为后续异步化时的协议入口。 + +后续迁移到异步 apply 的触发条件: + +1. apply 需要调用多个外部服务,确认接口延迟不可控。 +2. apply 可能超过普通 HTTP 请求可接受时长。 +3. 需要后台自动重试和失败恢复。 +4. `active-scheduler` 与 schedule 写入服务拆成独立进程,确认 API 不再适合直接持有完整写入事务。 + +届时新增: + +```text +active_schedule_apply_requests +schedule.apply.requested +apply worker +``` + +但 `apply_id / idempotency_key / apply_status / applied_event_ids` 的语义保持不变,避免推翻 MVP 数据模型。 + +### 13.19 为什么 `preview_id + candidate_id` 不能当应用幂等键 + +`preview_id + candidate_id` 只能说明“用户基于哪份预览里的哪个候选确认”,不能说明“这一次最终要应用什么内容”。 + +典型场景: + +```text +preview_id = p1 +candidate_id = c1 + +c1 原建议:把“写实验报告”安排到今天第 7 节。 +用户拖动后:改到今天第 8 节再确认。 +``` + +此时 `p1 + c1` 没变,但最终 apply 内容已经变化。如果把 `preview_id + candidate_id` 当幂等键,会混淆候选身份和执行请求。 + +推荐分层: + +```text +preview_id # 哪一份主动调度预览 +candidate_id # 基于哪一个候选 +edited_changes # 用户最终确认的真实变更 +apply_id # 哪一次确认应用尝试 +idempotency_key # 防止同一次确认动作重复提交 +``` + +确认请求建议: + +```json +{ + "candidate_id": "c1", + "action": "confirm", + "edited_changes": [ + { + "change_id": "chg_1", + "type": "add_task_pool_to_schedule", + "task_id": 123, + "week": 8, + "day_of_week": 4, + "section_from": 8, + "section_to": 8 + } + ], + "idempotency_key": "frontend-generated-uuid" +} +``` + +后端处理规则: + +1. `idempotency_key` 由前端为一次确认动作生成;用户双击、请求超时重试、移动端 WebView 重放时必须复用同一个 key。 +2. 后端建议按 `preview_id + idempotency_key` 做唯一约束或等价幂等查询。 +3. 如果同一个 `preview_id + idempotency_key` 已成功应用,直接返回上一次 `apply_id / applied_event_ids`。 +4. 如果同一个 `preview_id + idempotency_key` 正在处理中,返回 `applying` 或在同步路径内等待本次结果。 +5. 如果同一个 `preview_id + idempotency_key` 对应的请求体摘要与本次不同,应拒绝请求,避免同一个幂等键被复用到另一套变更。 +6. 如果 `idempotency_key` 不同,即使 `candidate_id` 相同,也必须按新的确认尝试处理,并重新校验 preview 是否仍允许 apply。 + +`active_schedule_previews` 第一版可预留以下 apply 字段: + +```text +apply_id +apply_status # none / applying / applied / failed / rejected / expired +apply_candidate_id +apply_idempotency_key +apply_request_hash +applied_changes_json +applied_event_ids_json +apply_error +applied_at +``` + +MVP 状态流转建议: + +```text +none -> applying -> applied +none -> applying -> failed +none -> rejected +none -> expired +``` + +第一版建议同一个 preview 只允许成功 apply 一次。`failed` 后是否允许换一个候选再次确认,先不作为 MVP 主路径;若要支持,应生成新的 `apply_id`,并明确旧失败记录如何保留,避免审计链路被覆盖。 + +### 13.20 飞书通知为什么需要 LLM 摘要、链接和记录表 + +飞书第一版只做“提醒用户回系统确认”,不在飞书内应用日程,也不做复杂聊天。但它仍然是用户会感知到的主动打扰,因此要兼顾表达质量、跳转确定性和投递可观测。 + +通知文案: + +1. 主动调度链路已经让 LLM 参与候选选择和解释,此时让 LLM 生成一段 summary 成本很低。 +2. LLM summary 比固定模板更能解释“为什么现在提醒你”,例如任务即将变紧急、刚才反馈未完成、后续日程被挤压等。 +3. summary 只负责表达,不负责决定是否通知、通知谁、跳到哪里;这些仍由后端结构化字段决定。 +4. 固定模板必须保留为 fallback,避免 LLM 超时、失败、内容为空或内容校验不过时,整条通知链路直接断掉。 + +推荐 fallback 方向: + +```text +我为你生成了一份日程调整建议,请回到系统确认是否应用。 +``` + +链接规则: + +```text +/schedule-adjust/{preview_id} +``` + +原因: + +1. 每个主动调度 preview 都有唯一 `preview_id`,天然适合作为详情页定位键。 +2. 用户从飞书点进来后,只进入系统详情页,不在飞书里直接应用日程,避免外部 IM 承担高风险写操作。 +3. URL 不暴露 `candidate_id / apply_id`,因为用户进入详情页后仍可查看候选、拖动 after 方案并生成新的确认尝试。 +4. 如果后续接入聊天增强,也应由详情页或聊天页读取同一个 `preview_id`,不能另起一套确认协议。 + +通知幂等键按: + +```text +user_id + trigger_type + time_window +``` + +不按 `preview_id` 的原因: + +1. `preview_id` 每次生成都不同,如果按它去重,短时间内重复触发会重复发飞书。 +2. 主动调度通知的产品目标是“提醒用户回来处理一类调整”,不是把每次后台生成都推一遍。 +3. `user_id + trigger_type + time_window` 更符合“同类打扰聚合”的口径。 + +MVP 需要注意: + +1. `important_urgent_task` 的触发本身已按 `user_id + trigger_type + target_task_id` 做 30 分钟去重;通知层再按 `user_id + trigger_type + time_window` 聚合,可以进一步避免多任务同时到线时连续轰炸。 +2. `unfinished_feedback` 触发按反馈幂等键防重复提交;通知层仍可按窗口聚合,避免用户连续表达未完成时收到多条相似飞书。 +3. 具体 `time_window` 长度需要在表结构阶段拍板。MVP 可以先与触发去重窗口保持一致,例如 30 分钟;如果未完成反馈希望更即时,也可以单独设更短窗口。 +4. 如果后续产品要求“同一窗口内多个不同任务都必须分别通知”,再把 `target_id` 纳入幂等键;MVP 当前先以减少打扰为优先。 + +`notification_records` 第一版建议字段方向: + +```text +id +channel # feishu +user_id +trigger_id +preview_id +dedupe_key # user_id + trigger_type + time_window +target_url # /schedule-adjust/{preview_id} +summary_text +fallback_used +status # pending / sending / sent / failed / dead +attempt_count +next_retry_at +last_error +provider_request_json +provider_response_json +sent_at +created_at +updated_at +``` + +状态语义: + +1. `pending`:已生成记录,等待 provider 投递。 +2. `sending`:当前 worker 正在调用飞书 provider。 +3. `sent`:provider 明确返回成功。 +4. `failed`:本次投递失败,但仍可重试。 +5. `dead`:超过最大重试次数或遇到不可恢复错误,不再自动重试。 + +重试原则: + +1. 飞书 provider 属于不可靠外部服务,不能只依赖日志排障。 +2. provider 调用前先落 `notification_records`,避免进程崩溃后丢失“本该通知”的事实。 +3. 重试时必须复用同一条 `notification_records`,递增 `attempt_count`,更新 `last_error / next_retry_at`。 +4. 若同一 `dedupe_key` 已存在 `pending / sending / sent` 记录,应避免重复创建新通知;如果上一条是 `failed`,可按重试策略推进,而不是新建多条相同飞书。 +5. 记录表只负责通知投递状态,不负责 apply 状态;apply 状态仍属于 `active_schedule_previews`。 + +### 13.21 为什么事件契约要提前独立 + +事件契约可以理解为异步消息世界里的 IDL。Thrift / gRPC 描述同步 RPC 的请求、响应和字段语义;事件契约描述某个业务事实或异步动作的事件名、版本、payload、幂等键和消费语义。 + +主动调度里会出现多类跨边界事件: + +```text +active_schedule.triggered +schedule.preview.generated +notification.feishu.requested +schedule.apply.succeeded +schedule.apply.failed +``` + +这些事件会被不同模块使用: + +1. `backend/active_scheduler` 生成 trigger、preview 和 notification request。 +2. worker handler 注册和消费 outbox / Kafka 事件。 +3. notification 投递模块消费 `notification.feishu.requested`。 +4. API 查询 preview、确认 apply 后可能发布 apply 成功 / 失败事件。 + +如果事件 DTO 放在 `backend/active_scheduler` 内部,后续容易形成反向依赖: + +```text +notification -> active_scheduler +worker handler -> active_scheduler +API -> active_scheduler internal model +``` + +这样主动调度就会变成事实上的共享业务包,未来拆独立服务时边界会很难清理。 + +提前放到 `backend/shared/events` 的目的: + +1. 让发布方和消费方只共享协议,不共享实现。 +2. 让 notification 不需要理解主动调度内部 preview 结构,只消费稳定的通知事件 payload。 +3. 让 worker handler 不需要 import 主动调度内部包才能注册事件。 +4. 给后续切到独立 active-scheduler / notification 服务预留 contracts module。 +5. 让事件版本演进有明确入口,避免直接复用内部 Go struct 导致兼容性失控。 + +边界约束: + +1. `backend/shared/events` 只放契约,不放业务。 +2. payload DTO 必须是为事件专门设计的结构,不直接复用 GORM model。 +3. 字段新增优先保持向后兼容;破坏性调整必须提升 `event_version`。 +4. 消费者必须按 `event_type + event_version` 解析,不能依赖生产者内部实现。 +5. 幂等键、消息键、聚合 ID 的构造口径应写在事件契约旁边,避免发布方和消费方各猜一套。 + +### 13.22 为什么主动调度依赖边界采用 port / adapter + +主动调度如果直接依赖一堆其它领域 DAO,后续拆微服务时边界会变得很模糊。 + +风险: + +1. 主动调度会绕过 task、schedule、task_class 现有 service 中的权限校验、冲突判断、时间转换和状态流转。 +2. 主动调度会知道太多表结构,后续 task / schedule 拆服务时需要大改主动调度主链路。 +3. 调度决策会和领域数据所有权混在一起,难以判断哪些调用只是读事实,哪些调用在修改业务真值。 +4. 未来迁移时容易变成“换了目录的单体代码”,不是清晰的 active-scheduler 服务。 + +但也不能简单要求全部走现有 service: + +1. 现有 service 很多是面向 HTTP API 入参和前端响应设计的,不一定适合后台主动调度。 +2. 主动调度需要滚动 24 小时事实快照、局部可用槽、触发上下文等读模型,现有 service 未必已经提供。 +3. 主动调度自有表不属于 task / schedule / task_class,没必要绕到旧 service。 +4. 某些现有 service 太粗,可能带缓存、响应结构或前端 DTO,不适合作为内部调度 pipeline 的稳定边界。 + +因此采用分层策略: + +```text +读事实:优先通过领域 service / query port +写正式业务:必须通过领域 service / apply port +写主动调度自有表:使用 active_scheduler 自己的 repo +``` + +推荐端口方向: + +```go +type TaskReader interface { + GetTaskForActiveSchedule(...) + ListUrgentUnscheduledTasks(...) +} + +type ScheduleReader interface { + GetScheduleFacts(...) + HasSlotConflict(...) +} + +type MemoryContextReader interface { + LoadScheduleMemoryContext(...) +} + +type ScheduleApplyService interface { + ApplyActiveScheduleChanges(...) +} +``` + +MVP 里这些端口的 adapter 可以在 `backend` 内调用现有 service。若现有 service 缺少合适读模型,adapter 内部可以调用 DAO 组装,但主动调度 pipeline 不应该直接依赖 DAO。memory 读取不新造结构化偏好 DAO,先复用 memory 模块 `Retrieve`,并把渲染逻辑抽成公共 helper 供 newAgent 与主动调度共同使用。 + +这样未来迁出时替换的是 adapter: + +```text +本地 service / DAO adapter + -> RPC / HTTP adapter + -> 事件投影 / read model adapter +``` + +主动调度的 `BuildContext / Observe / GenerateCandidates / LLMSelectAndExplain / WritePreview / Notify` 主链路不需要重写。 + +一句话:主动调度可以拥有自己的 repo,但不能把别人的 DAO 当自己的内部能力随便用。 + +## 14. 验证流程与动作-预期 checklist + +### 14.1 验证目标 + +主动调度 MVP 的验收重点在后端闭环,而不是前端页面完成度。前端第一版只需要能打开 `/schedule-adjust/{preview_id}`、展示预览、提交确认即可;核心验证应覆盖: + +1. 触发是否正确:task 到达 `urgency_threshold_at`、用户反馈未完成、API 测试触发都能进入统一链路。 +2. 去重是否正确:同一触发不会重复生成预览、重复通知或重复 apply。 +3. 预览是否正确:只写 `active_schedule_previews`,不提前修改正式日程。 +4. 通知是否正确:写 `notification_records`,失败可观测、可重试。 +5. 确认是否正确:确认后同步重校验并事务写入正式日程,失败不落库。 +6. 状态是否正确:`job -> trigger -> preview -> notification -> apply` 能通过 ID 串起来排障。 +7. 边界是否正确:主动调度不进入 ReAct 工具循环,不绕过 schedule / task_class 正式写入链路。 + +### 14.2 验证环境 + +建议至少准备三种运行方式: + +1. `all` 模式:本地一键联调,验证 API + worker 同进程闭环。 +2. `api + worker` 分进程模式:验证启动边界拆分后,API 发布 outbox、worker 消费事件。 +3. provider mock 模式:飞书 provider 使用 mock 或测试 webhook,避免真实通知影响用户。 + +验证时需要可观察以下数据: + +```text +active_schedule_jobs +active_schedule_triggers +active_schedule_previews +notification_records +outbox / event bus 消费状态 +schedule_events +schedules +tasks +``` + +### 14.3 最小测试数据 + +建议准备一个固定测试用户和以下数据: + +1. 至少 1 个 `important_urgent_task`: + - `is_completed=false` + - `urgency_threshold_at` 可通过 `mock_now` 命中 + - 当前 24 小时内尚未进入 schedule +2. 至少 1 个已完成或不再紧急的 task: + - 用于验证 job 到期后能 `skipped / canceled` +3. 至少 1 个已有 schedule 动态任务: + - 用于模拟用户反馈 `unfinished_feedback` +4. 至少 1 段 24 小时内空闲节次: + - 用于生成 `add_task_pool_to_schedule` 候选 +5. 至少 1 段冲突节次: + - 用于验证候选校验和 confirm 重校验失败 +6. 至少 1 条用户偏好 memory: + - 用于验证 task_pool 候选使用 memory 软偏好 + +### 14.4 API dry-run checklist + +| 动作 | 预期 | +| --- | --- | +| 调用主动调度 `dry-run`,传入 `important_urgent_task` 与 `mock_now` | 同步返回 context / issues / decision / candidates,不写 `active_schedule_previews` | +| dry-run 命中无问题任务 | 返回 `decision.action=close`,不生成 candidates | +| dry-run 任务缺少必要事实 | 返回 `ask_user` 或 `notify_only`,说明 missing_info | +| dry-run 传入非法 `mock_now` 或非法 target | 返回参数错误,不写 trigger / preview / notification | +| dry-run 连续调用同一输入 | 每次只返回诊断结果,不触发去重状态、不发飞书 | + +### 14.5 trigger 与 worker checklist + +| 动作 | 预期 | +| --- | --- | +| task 创建时写入 `urgency_threshold_at` | upsert `active_schedule_jobs`,状态为待触发 | +| task 更新 `deadline_at / urgency_threshold_at` | 覆盖当前有效 job 的触发时间并更新 `updated_at` | +| task 完成 | 未执行 job 标记为 `canceled`,不物理删除 | +| worker 扫描到 due job 且 task 仍未完成 / 未进入日程 | 生成 `active_schedule.triggered` 或等价 trigger 记录 | +| worker 扫描到 due job 但 task 已完成 | job / trigger 标记为 `skipped / canceled`,不写 preview | +| API `trigger` 使用 `mock_now` | 写入 trigger,payload 标记 `is_mock_time=true` | +| 后台真实 worker 触发 | 不允许传入 `mock_now`,使用真实当前时间 | +| 同一用户同一 task 30 分钟内重复触发 `important_urgent_task` | 命中去重,不重复生成 preview 和飞书通知 | + +### 14.6 preview checklist + +| 动作 | 预期 | +| --- | --- | +| 正式 trigger 成功生成候选 | 写入 `active_schedule_previews`,包含 `trigger_id / candidate_id / base_version / before_summary / preview_changes / expires_at` | +| preview 生成完成 | `active_schedule_triggers.status=preview_generated` 或等价状态 | +| preview 生成后查询正式日程 | `schedule_events / schedules` 未发生变化 | +| preview 查询接口读取详情 | 返回触发原因、解释摘要、before/after、风险、不调整后果、候选信息 | +| preview 超过 1 小时 | 仍可查看历史说明,但确认 API 拒绝 apply | +| 日程在 preview 生成后被用户手动改动 | confirm 时基于 `base_version / before_summary` 重校验失败,正式日程不落库 | + +### 14.7 notification checklist + +| 动作 | 预期 | +| --- | --- | +| preview 生成成功 | 发布 `notification.feishu.requested` 或等价 outbox 事件 | +| notification handler 收到事件 | 先写 `notification_records`,再调用 provider | +| LLM summary 生成成功 | 飞书文案使用 summary,包含 `/schedule-adjust/{preview_id}` | +| LLM summary 失败 / 超时 / 空内容 | 使用固定 fallback 文案,通知链路不中断 | +| 飞书 provider 返回成功 | `notification_records.status=sent`,记录 `sent_at / provider_response_json` | +| 飞书 provider 返回临时失败 | `notification_records.status=failed`,递增 `attempt_count`,写 `last_error / next_retry_at` | +| 重试到达上限或不可恢复错误 | `notification_records.status=dead`,不再自动重试 | +| 同一 `user_id + trigger_type + time_window` 内重复通知 | 命中 `dedupe_key`,不重复创建多条待发送通知 | + +### 14.8 confirm apply checklist + +| 动作 | 预期 | +| --- | --- | +| 用户打开 `/schedule-adjust/{preview_id}` | 能读取 preview 详情;如果已过期,页面显示不可确认 | +| 用户确认原候选 | confirm API 生成 `apply_id`,写入 `applying`,同步重校验后事务写正式日程 | +| 用户拖动 after 方案后确认 | 请求携带 `edited_changes`,后端重新校验坐标和目标,不信任前端 | +| task_pool 候选确认成功 | 写入 `schedule_events(type=task, task_source_type=task_pool, rel_id=tasks.id)` 和对应 `schedules` 原子节次 | +| task_item 补做块确认成功 | 通过 schedule / task_class apply port 或现有领域 service 写入,不绕过既有正式写入链路 | +| confirm 时发生冲突 | 事务不落库,preview 标记 `apply_failed`,写入 `apply_error` | +| confirm 成功 | preview 标记 `applied`,记录 `applied_at / applied_event_ids / applied_changes_json` | +| confirm 成功后再次确认同一 preview | 不重复写日程,返回已应用结果或明确业务错误 | +| confirm 使用过期 preview | 拒绝 apply,不写正式日程 | + +### 14.9 幂等与重复提交 checklist + +| 动作 | 预期 | +| --- | --- | +| 同一 `preview_id + idempotency_key` 重复提交相同 confirm 请求 | 返回同一个 `apply_id` 和同一组 apply 结果,不重复写日程 | +| 同一 `preview_id + idempotency_key` 提交不同请求体 | 拒绝请求,提示幂等键被复用到不同内容 | +| 同一 `preview_id` 使用不同 `idempotency_key` 再次确认 | 若 preview 已 applied,拒绝重复应用或返回已应用状态 | +| 网络超时后前端重试 | 后端根据 `idempotency_key` 返回上一轮结果 | +| worker 重复消费同一通知事件 | `notification_records.dedupe_key` 防止重复飞书 | + +### 14.10 失败注入 checklist + +| 动作 | 预期 | +| --- | --- | +| 构造 LLM 选择超时 | 使用后端 fallback 决策或标记失败,trigger 状态可排障 | +| 构造 LLM summary 超时 | 使用固定通知模板,preview 仍可通知 | +| 构造 DB 写 preview 失败 | trigger 标记 failed,不发布 notification | +| 构造 notification provider 失败 | preview 保留,notification record 进入 failed / retry,不影响 preview 查询 | +| 构造 apply 写 schedule 中途失败 | 事务回滚,`schedule_events / schedules` 不产生半写状态 | +| 构造 outbox 消费重复 | 消费幂等,业务状态不重复推进 | + +### 14.11 自动化测试建议 + +自动化测试原则: + +1. 能自动验收的,由实现者自己完成,不把可自动验证的工作转交给用户。 +2. 能用测试代码验证的,优先写单元测试或集成测试;若按项目规则临时生成 `*_test.go`,测试后必须删除临时测试文件。 +3. 能用 API + DB 查询验证的,必须实际调用接口并核对落库结果,不能只说“理论上可行”。 +4. 能用 mock provider 验证的外部服务链路,必须先用 mock 跑通状态机,再说明真实 provider 还剩哪些人工配置。 +5. 实在无法自动完成的验收项必须显式列入“需要用户验收”清单,写清动作、预期和阻塞原因。 +6. 最终报告必须区分: + - 已自动验收通过。 + - 已自动验收失败并修复。 + - 因环境 / 权限 / 外部服务限制未能验收,需要用户执行。 + +建议自动化流程: + +1. 静态与编译验证: + ```text + gofmt 相关改动文件 + go test ./... + 清理项目根目录 .gocache + 检查新增 / 改动 Go 文件是否超过 700 行 + 检查是否遗留临时 *_test.go + ``` +2. 单元测试: + - 时间窗转换:绝对时间到 `week / day_of_week / section`。 + - 候选合法性校验:冲突、越界、预计节数、target 篡改。 + - decision 裁决:`close / ask_user / notify_only / select_candidate`。 + - 幂等键与 `apply_request_hash` 校验。 + - notification `dedupe_key` 生成。 +3. API + DB 集成测试: + - dry-run 不落库。 + - trigger 写 trigger / preview / notification record。 + - confirm 成功写 schedule。 + - confirm 冲突失败不落库。 + - 过期 preview 拒绝 apply。 +4. Worker 测试: + - due job 扫描。 + - outbox 发布和消费。 + - notification retry。 +5. 外部 provider 测试: + - mock provider 成功:`notification_records.status=sent`。 + - mock provider 临时失败:`status=failed`,写 `attempt_count / last_error / next_retry_at`。 + - retry 后成功:同一条 record 变为 `sent`,不新建重复通知。 + - 真实飞书 webhook / open_id 受限时,必须记录为“需要用户验收”,不能用 mock 结果冒充真实 provider 验收。 +6. 手工验收: + - 使用 `/schedule-adjust/{preview_id}` 打开详情页。 + - 拖动 after 方案并确认。 + - 查看飞书测试消息跳转。 + +每阶段交付报告模板: + +```text +已自动验收: +- go test ./...:通过 / 失败后已修复 +- API 链路:列出请求、关键 ID、响应状态 +- DB 核对:列出表名、关键字段和结果 +- 幂等 / 失败注入:列出动作和结果 + +未能自动验收: +- 验收项: +- 阻塞原因: +- 需要用户执行的动作: +- 预期结果: + +风险与下一步: +- 尚未覆盖的边界: +- 建议下一阶段优先补的自动化: +``` + +### 14.12 验收通过标准 + +MVP 验收通过至少需要满足: + +1. `important_urgent_task` 和 `unfinished_feedback` 两条主触发均可生成 preview。 +2. dry-run、trigger、worker 三类入口进入同一套主动调度 pipeline。 +3. preview 生成前后正式日程不被提前修改。 +4. 飞书通知记录可查,成功 / 失败 / 重试状态可观察。 +5. confirm 成功后正式日程正确写入,失败时事务不落库。 +6. 重复触发、重复通知、重复 confirm 都有幂等保护。 +7. 过期 preview、日程基准变化、前端篡改 `edited_changes` 都能被拒绝。 +8. 关键状态能通过 `trigger_id / preview_id / notification_id / apply_id` 串起来排障。 diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 3b9d597..83474ed 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -36,12 +36,36 @@ import type { ConversationMeta, ThinkingModeType, SchedulePreviewData, - ThinkingSummaryPayload, } from '@/types/dashboard' import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue' import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue' import { formatConversationTime, formatMessageTime } from '@/utils/date' import { renderMarkdown } from '@/utils/markdown' +import { + buildAssistantChatRequestExtra, + buildConversationPreviewItem, + buildThinkingSummarySignature, + buildStatusSummary, + buildTaskUpdatePayload, + buildToolDetail, + createDraftConversationId, + createMessageId, + formatTaskDeadlineForStatus, + getThinkingBackendKey, + isAssistantTimelineKind, + isDraftConversationId, + isLocalEphemeralMessageId, + isManualThinkingEnabled, + isLegacyToolStatusCode, + mapLegacyToolStatusToState, + mapToolEventState, + mergeConversationListItems, + migrateConversationListIds, + normalizeStatusCode, + normalizeToolSummary, + resolveConversationGroupLabel, + shouldSkipStatusEvent, +} from '@/utils/assistantPanelTrace' import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue' import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue' import type { @@ -49,150 +73,24 @@ import type { TaskQueryCardData, TaskRecordCardData } from '@/api/schedule_agent' - -interface StreamDeltaPayload { - content?: string -} - -interface StreamChoicePayload { - delta?: StreamDeltaPayload - finish_reason?: string | null -} - -interface StreamErrorPayload { - message?: string -} - -interface StreamConfirmPayload { - interaction_id?: string - title?: string - summary?: string -} - -interface StreamStatusExtraPayload { - code?: string - summary?: string -} - -interface StreamToolExtraPayload { - name?: string - status?: string - summary?: string - arguments_preview?: string - argument_view?: ToolView - result_view?: ToolView -} - -interface StreamExtraPayload { - kind?: string - block_id?: string - stage?: string - status?: StreamStatusExtraPayload - tool?: StreamToolExtraPayload - confirm?: StreamConfirmPayload - business_card?: TimelineBusinessCardPayload - thinking_summary?: ThinkingSummaryPayload -} - -interface StreamEventPayload { - choices?: StreamChoicePayload[] - delta?: StreamDeltaPayload - content?: string - finish_reason?: string | null - error?: StreamErrorPayload - extra?: StreamExtraPayload -} - -type ToolTraceState = 'called' | 'completed' | 'create' | 'blocked' - -interface ToolTraceEvent { - id: string - seq: number - state: ToolTraceState - summary: string - detail?: string - toolName?: string - argumentView?: ToolView - resultView?: ToolView -} - -interface StatusTraceEvent { - id: string - seq: number - code: string - stage: string - summary: string -} - - -interface ConversationGroup { - key: string - label: string - items: ConversationListItem[] -} - -interface ConfirmOverlayState { - visible: boolean - manuallyClosed: boolean - interactionId: string - title: string - summary: string -} - -interface ConversationListItemRevealOptions { - animate?: boolean -} - -interface EnsureConversationMetaOptions { - forceReload?: boolean - syncListItem?: boolean - listItemReveal?: ConversationListItemRevealOptions -} - -// 展示用消息:合并连续 assistant 消息后的视图模型 -interface DisplayMessage { - /** 第一条源消息的 id,用作 Vue key */ - id: string - role: 'user' | 'assistant' | 'system' - /** 合并后的正文内容 */ - content: string - /** 最后一条源消息的时间 */ - createdAt: string - /** 合并后的推理内容 */ - reasoning?: string - /** 原始消息引用列表 */ - sources: AssistantMessage[] - /** 是否为多条合并 */ - merged: boolean -} - -interface DisplayAssistantBlock { - id: string - type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card' - seq: number - text?: string - event?: ToolTraceEvent - statusEvent?: StatusTraceEvent - schedulePreview?: SchedulePreviewData - businessCard?: TimelineBusinessCardPayload - /** 所属的源消息 ID,用于状态查询 */ - sourceId?: string - /** 所属的源消息引用,用于渲染辅助信息 */ - source?: AssistantMessage -} - -interface AssistantContentBlock { - id: string - seq: number - text: string -} - -interface ThinkingSummaryBlockState { - blockId: string - lastSummarySeq: number - lastSignature: string - finished: boolean -} +import type { + ApplyThinkingSummaryOptions, + AssistantContentBlock, + ConfirmOverlayState, + ConversationGroup, + ConversationListItemRevealOptions, + DisplayAssistantBlock, + DisplayMessage, + EnsureConversationMetaOptions, + StatusTraceEvent, + StreamConfirmPayload, + StreamEventPayload, + StreamExtraPayload, + StreamToolExtraPayload, + ThinkingSummaryBlockState, + ToolTraceEvent, + ToolTraceState, +} from '@/types/assistant-panel' const props = withDefaults( defineProps<{ @@ -589,34 +487,6 @@ const displayMessages = computed(() => { return result }) -function resolveConversationGroupLabel(timeText?: string | null) { - if (!timeText) { - return '更早' - } - - const messageDate = new Date(timeText) - if (Number.isNaN(messageDate.getTime())) { - return '更早' - } - - const now = new Date() - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const targetDay = new Date(messageDate.getFullYear(), messageDate.getMonth(), messageDate.getDate()) - const diffDays = Math.floor((today.getTime() - targetDay.getTime()) / (24 * 60 * 60 * 1000)) - - if (diffDays <= 0) { - return '今天' - } - if (diffDays < 7) { - return '7 天内' - } - if (diffDays < 30) { - return '30 天内' - } - - return `${messageDate.getFullYear()}-${String(messageDate.getMonth() + 1).padStart(2, '0')}` -} - const groupedConversationList = computed(() => { const orderedGroups: ConversationGroup[] = [] const groupMap = new Map() @@ -1080,31 +950,6 @@ function enqueueThinkingDetail(messageId: string, blockId: string, detail: strin startThinkingStreamTicker(messageId, blockId) } -interface ApplyThinkingSummaryOptions { - backendBlockId?: string - stage?: string - summary: ThinkingSummaryPayload - /** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配 */ - eventSeq?: number - /** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入 */ - fromHistory?: boolean -} - -function getThinkingBackendKey(opts: Pick) { - const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim() - return backendKeyRaw || 'thinking' -} - -function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) { - return [ - typeof summary.summary_seq === 'number' ? summary.summary_seq : '', - (summary.short_summary || '').trim(), - (summary.detail_summary || '').trim(), - typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '', - summary.final === true ? '1' : '0', - ].join('\u001f') -} - function ensureThinkingSummaryBlockBucket(messageId: string) { if (!thinkingSummaryBlockStateMap[messageId]) { thinkingSummaryBlockStateMap[messageId] = {} @@ -1261,150 +1106,6 @@ function appendBusinessCardEvent(messageId: string, payload: TimelineBusinessCar assistantTimelineLastKindMap[messageId] = 'business_card' } -function mapToolEventState(rawStatus?: string): ToolTraceState { - const normalized = `${rawStatus || ''}`.trim().toLowerCase() - if (normalized === 'start' || normalized === 'calling' || normalized === 'called') { - return 'called' - } - if (normalized === 'create' || normalized === 'created') { - return 'create' - } - if (normalized === 'blocked') { - return 'blocked' - } - if (normalized === 'failed' || normalized === 'error') { - return 'blocked' - } - return 'completed' -} - -function normalizeToolSummary(extra: StreamToolExtraPayload): string { - const summary = `${extra.summary || ''}`.trim() - if (summary) { - return summary - } - const toolName = `${extra.name || ''}`.trim() - if (!toolName) { - return '工具事件' - } - return `已调用工具:${toolName}` -} - -function buildToolDetail(extra: StreamToolExtraPayload): string { - const argsPreview = `${extra.arguments_preview || ''}`.trim() - if (!argsPreview || argsPreview === '{}') { - return '' - } - return argsPreview -} - -function normalizeStatusCode(rawCode?: string) { - const code = `${rawCode || ''}`.trim().toLowerCase() - if (!code) { - return 'status' - } - return code -} - -function mapStatusCodeLabel(code: string) { - const labelMap: Record = { - accepted: '请求已接收', - planning: '正在规划', - resumed: '继续处理中', - confirmed: '确认后继续执行', - rejected: '已取消并重新规划', - executing: '正在执行', - plan_confirm: '等待计划确认', - tool_confirm: '等待操作确认', - ask_user: '等待补充信息', - confirm: '等待用户确认', - interrupted: '会话已中断', - summarizing: '正在生成总结', - done: '流程已结束', - rough_building: '正在生成初始排课方案', - rough_build_failed: '初始排课失败', - rough_build_done: '初始排课已完成', - rough_build_done_no_refine: '初始排课已完成', - order_guard_initialized: '已记录顺序基线', - order_guard_passed: '顺序校验通过', - order_guard_restored: '顺序已自动恢复', - order_guard_restore_skipped: '顺序恢复已跳过', - context_compact_start: '正在压缩上下文', - context_compact_done: '上下文压缩完成', - plan_auto_confirmed: '计划已自动确认', - } - return labelMap[code] || '状态已更新' -} - -function buildStatusSummary(extra: StreamExtraPayload): string { - const summary = `${extra.status?.summary || ''}`.trim() - if (summary) { - return summary - } - return mapStatusCodeLabel(normalizeStatusCode(extra.status?.code)) -} - -function isLegacyToolStatusCode(code: string) { - return code === 'tool_call' || code === 'tool_result' || code === 'tool_blocked' -} - -function mapLegacyToolStatusToState(code: string): ToolTraceState { - if (code === 'tool_call') { - return 'called' - } - if (code === 'tool_blocked') { - return 'blocked' - } - return 'completed' -} - -function shouldSkipStatusEvent(code: string, stage = '') { - // confirm_request 已有专属卡片,避免重复显示同语义状态行。 - if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) { - return true - } - - const hiddenStatusCodes = new Set([ - 'accepted', - 'ask_user', - 'planning', - 'resumed', - 'confirmed', - 'rejected', - 'executing', - 'summarizing', - 'done', - 'rough_building', - 'order_guard_initialized', - 'order_guard_passed', - 'order_guard_restored', - 'order_guard_restore_skipped', - 'context_compact_start', - 'context_compact_done', - 'plan_auto_confirmed', - ]) - - if (hiddenStatusCodes.has(code)) { - return true - } - return false -} - -function isAssistantTimelineKind(kind: string) { - const assistantKinds = new Set([ - 'assistant_text', - 'tool_call', - 'tool_result', - 'confirm_request', - 'schedule_completed', - 'interrupt', - 'status', - 'business_card', - 'thinking_summary', - ]) - return assistantKinds.has(kind) -} - function isToolTraceExpanded(eventId: string) { return toolTraceExpandedMap[eventId] === true } @@ -1450,18 +1151,6 @@ function clearConfirmStreamFlags(messageId: string) { delete confirmVisiblePrefixMap[messageId] } -function createDraftConversationId() { - return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} - -function createMessageId(role: AssistantMessage['role']) { - return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} - -function isDraftConversationId(conversationId: string) { - return conversationId.startsWith('draft-') -} - function upsertConversationMeta(meta: ConversationMeta) { conversationMetaMap[meta.conversation_id] = meta } @@ -1495,26 +1184,11 @@ function migrateConversationState(fromConversationId: string, toConversationId: delete conversationMetaMap[fromConversationId] } - const latestMap = new Map() - const deduplicated: ConversationListItem[] = [] - const seen = new Set() - - for (const item of conversationList.value) { - const nextItem = - item.conversation_id === fromConversationId ? { ...item, conversation_id: toConversationId } : item - latestMap.set(nextItem.conversation_id, nextItem) - } - - for (const item of conversationList.value) { - const nextId = item.conversation_id === fromConversationId ? toConversationId : item.conversation_id - if (seen.has(nextId)) { - continue - } - seen.add(nextId) - deduplicated.push(latestMap.get(nextId)!) - } - - conversationList.value = deduplicated + conversationList.value = migrateConversationListIds( + conversationList.value, + fromConversationId, + toConversationId, + ) if (selectedConversationId.value === fromConversationId) { selectedConversationId.value = toConversationId @@ -1530,37 +1204,18 @@ function migrateConversationState(fromConversationId: string, toConversationId: // 2. 不负责决定选中哪个会话,选中逻辑交给 ensureSelectedConversationAfterListLoad。 // 3. 本地尚未完成 round-trip 的 draft 会话会原样保留,避免用户发出首条消息后列表瞬间丢失。 function mergeConversationList(items: ConversationListItem[]) { - const merged = [...conversationList.value, ...items] - const latestMap = new Map() - const deduplicated: ConversationListItem[] = [] - const seen = new Set() - - for (const item of merged) { - latestMap.set(item.conversation_id, item) - } - - for (const item of merged) { - if (seen.has(item.conversation_id)) { - continue - } - seen.add(item.conversation_id) - deduplicated.push(latestMap.get(item.conversation_id) ?? item) - } - - conversationList.value = deduplicated + conversationList.value = mergeConversationListItems(conversationList.value, items) } function prependConversationPreview(conversationId: string, previewText: string, createdAt: string) { const current = conversationList.value.find((item) => item.conversation_id === conversationId) - const nextItem: ConversationListItem = { - conversation_id: conversationId, - title: current?.title || previewText.slice(0, 24), - has_title: current?.has_title ?? false, - message_count: Math.max(current?.message_count ?? 0, conversationMessagesMap[conversationId]?.length ?? 0), - last_message_at: createdAt, - status: current?.status || 'active', - created_at: current?.created_at || createdAt, - } + const nextItem = buildConversationPreviewItem( + conversationId, + previewText, + createdAt, + current, + conversationMessagesMap[conversationId]?.length ?? 0, + ) conversationList.value = [ nextItem, @@ -1666,10 +1321,6 @@ function isLatestAssistantMessage(messageId: string) { return lastAssistant?.id === messageId } -function isLocalEphemeralMessageId(id: string) { - return /^(user|assistant|system)-\d{13}-[a-z0-9]+$/i.test(id) -} - function resolvePromptBeforeAssistantMessage(messageId: string) { const index = findMessageIndex(messageId) if (index <= 0) { @@ -2766,10 +2417,6 @@ function startNewConversation() { suppressEmptyStateTransition.value = false } -function isManualThinkingEnabled(mode: ThinkingModeType) { - return mode === 'true' -} - async function openFineTuneModal(data: SchedulePreviewData) { // 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。 if ((data as any).is_placeholder) { @@ -2901,13 +2548,7 @@ async function handleSaveTask() { try { if (isEditMode.value && editingTaskId.value) { const taskId = editingTaskId.value - const updateData = { - task_id: taskId, - title: taskForm.title.trim(), - priority_group: taskForm.priority_group, - deadline_at: taskForm.deadline_at ? (typeof taskForm.deadline_at === 'string' ? taskForm.deadline_at : taskForm.deadline_at.toISOString()) : null, - urgency_threshold_at: taskForm.urgency_threshold_at ? (typeof taskForm.urgency_threshold_at === 'string' ? taskForm.urgency_threshold_at : taskForm.urgency_threshold_at.toISOString()) : null, - } + const updateData = buildTaskUpdatePayload(taskId, taskForm) await updateTask(updateData) // 同步更新本地状态映射,让所有历史卡片实时联动 @@ -2915,11 +2556,7 @@ async function handleSaveTask() { taskStatusMap[taskId].title = updateData.title taskStatusMap[taskId].priority_group = updateData.priority_group // 格式化截止时间用于展示 - taskStatusMap[taskId].deadline_at = taskForm.deadline_at - ? (taskForm.deadline_at instanceof Date - ? taskForm.deadline_at.toLocaleDateString('zh-CN').replace(/\//g, '-') - : String(taskForm.deadline_at).split('T')[0]) - : null + taskStatusMap[taskId].deadline_at = formatTaskDeadlineForStatus(taskForm.deadline_at) } ElMessage.success('任务详情已更新') @@ -2961,19 +2598,7 @@ function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) { function buildChatRequestExtra( planningTaskClassIds: number[] = [], ): ChatRequestExtra | undefined { - const extra: ChatRequestExtra = {} - - // 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。 - if (planningTaskClassIds.length > 0) { - extra.task_class_ids = [...planningTaskClassIds] - } - - // 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。 - if (selectedExecutionMode.value === 'always') { - extra.always_execute = true - } - - return Object.keys(extra).length > 0 ? extra : undefined + return buildAssistantChatRequestExtra(planningTaskClassIds, selectedExecutionMode.value) } function handlePlanningSelectionApplied(taskClassIds: number[]) { diff --git a/frontend/src/types/assistant-panel.ts b/frontend/src/types/assistant-panel.ts new file mode 100644 index 0000000..34cbdd9 --- /dev/null +++ b/frontend/src/types/assistant-panel.ts @@ -0,0 +1,160 @@ +import type { TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent' +import type { + AssistantMessage, + ConversationListItem, + SchedulePreviewData, + ThinkingSummaryPayload, +} from '@/types/dashboard' + +export interface StreamDeltaPayload { + content?: string +} + +export interface StreamChoicePayload { + delta?: StreamDeltaPayload + finish_reason?: string | null +} + +export interface StreamErrorPayload { + message?: string +} + +export interface StreamConfirmPayload { + interaction_id?: string + title?: string + summary?: string +} + +export interface StreamStatusExtraPayload { + code?: string + summary?: string +} + +export interface StreamToolExtraPayload { + name?: string + status?: string + summary?: string + arguments_preview?: string + argument_view?: ToolView + result_view?: ToolView +} + +export interface StreamExtraPayload { + kind?: string + block_id?: string + stage?: string + status?: StreamStatusExtraPayload + tool?: StreamToolExtraPayload + confirm?: StreamConfirmPayload + business_card?: TimelineBusinessCardPayload + thinking_summary?: ThinkingSummaryPayload +} + +export interface StreamEventPayload { + choices?: StreamChoicePayload[] + delta?: StreamDeltaPayload + content?: string + finish_reason?: string | null + error?: StreamErrorPayload + extra?: StreamExtraPayload +} + +export type ToolTraceState = 'called' | 'completed' | 'create' | 'blocked' + +export interface ToolTraceEvent { + id: string + seq: number + state: ToolTraceState + summary: string + detail?: string + toolName?: string + argumentView?: ToolView + resultView?: ToolView +} + +export interface StatusTraceEvent { + id: string + seq: number + code: string + stage: string + summary: string +} + +export interface ConversationGroup { + key: string + label: string + items: ConversationListItem[] +} + +export interface ConfirmOverlayState { + visible: boolean + manuallyClosed: boolean + interactionId: string + title: string + summary: string +} + +export interface ConversationListItemRevealOptions { + animate?: boolean +} + +export interface EnsureConversationMetaOptions { + forceReload?: boolean + syncListItem?: boolean + listItemReveal?: ConversationListItemRevealOptions +} + +// 展示用消息:合并连续 assistant 消息后的视图模型。 +export interface DisplayMessage { + /** 第一条源消息的 id,用作 Vue key。 */ + id: string + role: 'user' | 'assistant' | 'system' + /** 合并后的正文内容。 */ + content: string + /** 最后一条源消息的时间。 */ + createdAt: string + /** 合并后的推理内容。 */ + reasoning?: string + /** 原始消息引用列表。 */ + sources: AssistantMessage[] + /** 是否为多条合并。 */ + merged: boolean +} + +export interface DisplayAssistantBlock { + id: string + type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card' + seq: number + text?: string + event?: ToolTraceEvent + statusEvent?: StatusTraceEvent + schedulePreview?: SchedulePreviewData + businessCard?: TimelineBusinessCardPayload + /** 所属的源消息 ID,用于状态查询。 */ + sourceId?: string + /** 所属的源消息引用,用于渲染辅助信息。 */ + source?: AssistantMessage +} + +export interface AssistantContentBlock { + id: string + seq: number + text: string +} + +export interface ThinkingSummaryBlockState { + blockId: string + lastSummarySeq: number + lastSignature: string + finished: boolean +} + +export interface ApplyThinkingSummaryOptions { + backendBlockId?: string + stage?: string + summary: ThinkingSummaryPayload + /** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配。 */ + eventSeq?: number + /** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入。 */ + fromHistory?: boolean +} diff --git a/frontend/src/utils/assistantPanelTrace.ts b/frontend/src/utils/assistantPanelTrace.ts new file mode 100644 index 0000000..99252a5 --- /dev/null +++ b/frontend/src/utils/assistantPanelTrace.ts @@ -0,0 +1,336 @@ +import type { ApplyThinkingSummaryOptions, StreamExtraPayload, StreamToolExtraPayload, ToolTraceState } from '@/types/assistant-panel' +import type { + AssistantMessage, + ChatRequestExtra, + ConversationListItem, + ThinkingModeType, + ThinkingSummaryPayload, +} from '@/types/dashboard' + +interface TaskFormLike { + title: string + priority_group: number + deadline_at: Date | string | null + urgency_threshold_at: Date | string | null +} + +export function createDraftConversationId() { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export function createMessageId(role: AssistantMessage['role']) { + return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export function isDraftConversationId(conversationId: string) { + return conversationId.startsWith('draft-') +} + +export function isLocalEphemeralMessageId(id: string) { + return /^(user|assistant|system)-\d{13}-[a-z0-9]+$/i.test(id) +} + +export function resolveConversationGroupLabel(timeText?: string | null) { + if (!timeText) { + return '更早' + } + + const messageDate = new Date(timeText) + if (Number.isNaN(messageDate.getTime())) { + return '更早' + } + + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const targetDay = new Date(messageDate.getFullYear(), messageDate.getMonth(), messageDate.getDate()) + const diffDays = Math.floor((today.getTime() - targetDay.getTime()) / (24 * 60 * 60 * 1000)) + + if (diffDays <= 0) { + return '今天' + } + if (diffDays < 7) { + return '7 天内' + } + if (diffDays < 30) { + return '30 天内' + } + + return `${messageDate.getFullYear()}-${String(messageDate.getMonth() + 1).padStart(2, '0')}` +} + +export function migrateConversationListIds( + items: ConversationListItem[], + fromConversationId: string, + toConversationId: string, +) { + const latestMap = new Map() + const deduplicated: ConversationListItem[] = [] + const seen = new Set() + + for (const item of items) { + const nextItem = + item.conversation_id === fromConversationId ? { ...item, conversation_id: toConversationId } : item + latestMap.set(nextItem.conversation_id, nextItem) + } + + for (const item of items) { + const nextId = item.conversation_id === fromConversationId ? toConversationId : item.conversation_id + if (seen.has(nextId)) { + continue + } + seen.add(nextId) + deduplicated.push(latestMap.get(nextId)!) + } + + return deduplicated +} + +export function mergeConversationListItems( + currentItems: ConversationListItem[], + nextItems: ConversationListItem[], +) { + const merged = [...currentItems, ...nextItems] + const latestMap = new Map() + const deduplicated: ConversationListItem[] = [] + const seen = new Set() + + for (const item of merged) { + latestMap.set(item.conversation_id, item) + } + + for (const item of merged) { + if (seen.has(item.conversation_id)) { + continue + } + seen.add(item.conversation_id) + deduplicated.push(latestMap.get(item.conversation_id) ?? item) + } + + return deduplicated +} + +export function buildConversationPreviewItem( + conversationId: string, + previewText: string, + createdAt: string, + current: ConversationListItem | undefined, + messageCount: number, +): ConversationListItem { + return { + conversation_id: conversationId, + title: current?.title || previewText.slice(0, 24), + has_title: current?.has_title ?? false, + message_count: Math.max(current?.message_count ?? 0, messageCount), + last_message_at: createdAt, + status: current?.status || 'active', + created_at: current?.created_at || createdAt, + } +} + +export function serializeTaskDateForApi(value: Date | string | null) { + if (!value) { + return null + } + return typeof value === 'string' ? value : value.toISOString() +} + +export function formatTaskDeadlineForStatus(value: Date | string | null) { + if (!value) { + return null + } + return value instanceof Date + ? value.toLocaleDateString('zh-CN').replace(/\//g, '-') + : String(value).split('T')[0] +} + +export function buildTaskUpdatePayload(taskId: number, taskForm: TaskFormLike) { + return { + task_id: taskId, + title: taskForm.title.trim(), + priority_group: taskForm.priority_group, + deadline_at: serializeTaskDateForApi(taskForm.deadline_at), + urgency_threshold_at: serializeTaskDateForApi(taskForm.urgency_threshold_at), + } +} + +export function buildAssistantChatRequestExtra( + planningTaskClassIds: number[] = [], + executionMode: 'manual' | 'always', +): ChatRequestExtra | undefined { + const extra: ChatRequestExtra = {} + + // 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。 + if (planningTaskClassIds.length > 0) { + extra.task_class_ids = [...planningTaskClassIds] + } + + // 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。 + if (executionMode === 'always') { + extra.always_execute = true + } + + return Object.keys(extra).length > 0 ? extra : undefined +} + +export function isManualThinkingEnabled(mode: ThinkingModeType) { + return mode === 'true' +} + +export function getThinkingBackendKey(opts: Pick) { + const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim() + return backendKeyRaw || 'thinking' +} + +export function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) { + return [ + typeof summary.summary_seq === 'number' ? summary.summary_seq : '', + (summary.short_summary || '').trim(), + (summary.detail_summary || '').trim(), + typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '', + summary.final === true ? '1' : '0', + ].join('\u001f') +} + +export function mapToolEventState(rawStatus?: string): ToolTraceState { + const normalized = `${rawStatus || ''}`.trim().toLowerCase() + if (normalized === 'start' || normalized === 'calling' || normalized === 'called') { + return 'called' + } + if (normalized === 'create' || normalized === 'created') { + return 'create' + } + if (normalized === 'blocked') { + return 'blocked' + } + if (normalized === 'failed' || normalized === 'error') { + return 'blocked' + } + return 'completed' +} + +export function normalizeToolSummary(extra: StreamToolExtraPayload): string { + const summary = `${extra.summary || ''}`.trim() + if (summary) { + return summary + } + const toolName = `${extra.name || ''}`.trim() + if (!toolName) { + return '工具事件' + } + return `已调用工具:${toolName}` +} + +export function buildToolDetail(extra: StreamToolExtraPayload): string { + const argsPreview = `${extra.arguments_preview || ''}`.trim() + if (!argsPreview || argsPreview === '{}') { + return '' + } + return argsPreview +} + +export function normalizeStatusCode(rawCode?: string) { + const code = `${rawCode || ''}`.trim().toLowerCase() + if (!code) { + return 'status' + } + return code +} + +export function mapStatusCodeLabel(code: string) { + const labelMap: Record = { + accepted: '请求已接收', + planning: '正在规划', + resumed: '继续处理中', + confirmed: '确认后继续执行', + rejected: '已取消并重新规划', + executing: '正在执行', + plan_confirm: '等待计划确认', + tool_confirm: '等待操作确认', + ask_user: '等待补充信息', + confirm: '等待用户确认', + interrupted: '会话已中断', + summarizing: '正在生成总结', + done: '流程已结束', + rough_building: '正在生成初始排课方案', + rough_build_failed: '初始排课失败', + rough_build_done: '初始排课已完成', + rough_build_done_no_refine: '初始排课已完成', + order_guard_initialized: '已记录顺序基线', + order_guard_passed: '顺序校验通过', + order_guard_restored: '顺序已自动恢复', + order_guard_restore_skipped: '顺序恢复已跳过', + context_compact_start: '正在压缩上下文', + context_compact_done: '上下文压缩完成', + plan_auto_confirmed: '计划已自动确认', + } + return labelMap[code] || '状态已更新' +} + +export function buildStatusSummary(extra: StreamExtraPayload): string { + const summary = `${extra.status?.summary || ''}`.trim() + if (summary) { + return summary + } + return mapStatusCodeLabel(normalizeStatusCode(extra.status?.code)) +} + +export function isLegacyToolStatusCode(code: string) { + return code === 'tool_call' || code === 'tool_result' || code === 'tool_blocked' +} + +export function mapLegacyToolStatusToState(code: string): ToolTraceState { + if (code === 'tool_call') { + return 'called' + } + if (code === 'tool_blocked') { + return 'blocked' + } + return 'completed' +} + +export function shouldSkipStatusEvent(code: string, stage = '') { + // confirm_request 已有专属卡片,避免重复显示同语义状态行。 + if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) { + return true + } + + const hiddenStatusCodes = new Set([ + 'accepted', + 'ask_user', + 'planning', + 'resumed', + 'confirmed', + 'rejected', + 'executing', + 'summarizing', + 'done', + 'rough_building', + 'order_guard_initialized', + 'order_guard_passed', + 'order_guard_restored', + 'order_guard_restore_skipped', + 'context_compact_start', + 'context_compact_done', + 'plan_auto_confirmed', + ]) + + if (hiddenStatusCodes.has(code)) { + return true + } + return false +} + +export function isAssistantTimelineKind(kind: string) { + const assistantKinds = new Set([ + 'assistant_text', + 'tool_call', + 'tool_result', + 'confirm_request', + 'schedule_completed', + 'interrupt', + 'status', + 'business_card', + 'thinking_summary', + ]) + return assistantKinds.has(kind) +}