Files
smartmate/backend/active_scheduler/candidate/candidate.go
Losita 166fb1b507 Version: 0.9.63.dev.260503
后端:
1. 主动调度 `unfinished_feedback` 候选生成收口——仅在反馈目标可定位到 `task_item` 时生成 `create_makeup`,课程块与 `target_id=0` 继续回退 `ask_user`,避免生成会被 apply 层拦截的无效预览
2026-05-03 15:53:09 +08:00

341 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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) {
if ctx == nil || ctx.FeedbackFacts.TargetTaskItemID <= 0 {
return Candidate{}, false
}
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]
}