Files
smartmate/backend/active_scheduler/observe/observe.go
LoveLosita e945578fbf Version: 0.9.59.dev.260430
后端:
1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程
2. 同步更新主动调度实施文档的阶段状态与验收记录

前端:
3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
2026-04-30 12:05:15 +08:00

294 lines
9.8 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 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
}