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