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:
LoveLosita
2026-04-30 12:05:15 +08:00
parent 1555042e80
commit e945578fbf
38 changed files with 10267 additions and 580 deletions

View File

@@ -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
}