Version: 0.9.59.dev.260430
后端: 1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程 2. 同步更新主动调度实施文档的阶段状态与验收记录 前端: 3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
This commit is contained in:
337
backend/active_scheduler/candidate/candidate.go
Normal file
337
backend/active_scheduler/candidate/candidate.go
Normal file
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user