Files
smartmate/backend/active_scheduler/preview/service.go
Losita a3eaa9b2c2 Version: 0.9.61.dev.260501
后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值

前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截

文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
2026-05-01 20:48:32 +08:00

308 lines
10 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 preview
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrInvalidPreviewRequest = errors.New("主动调度预览请求不合法")
ErrPreviewNotFound = errors.New("主动调度预览不存在")
)
// Repository 是 preview service 依赖的最小持久化端口。
//
// 职责边界:
// 1. 只覆盖本轮 preview 写入和详情查询需要的方法;
// 2. 不暴露正式日程写入、通知投递或 confirm apply 能力;
// 3. 现有 dao.ActiveScheduleDAO 已满足该接口,后续迁移独立 repo 时可并行替换实现。
type Repository interface {
CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error
GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error)
}
// Service 负责主动调度 preview 的写入和查询。
//
// 职责边界:
// 1. 将 dry-run 结果固化为 active_schedule_previews 中的 ready 快照;
// 2. 查询时校验 user_id并返回 API 可直接透传的详情 DTO
// 3. 不正式写日程、不发通知、不处理 confirm/apply也不修改 trigger 状态。
type Service struct {
repo Repository
clock func() time.Time
}
func NewService(repo Repository) (*Service, error) {
if repo == nil {
return nil, fmt.Errorf("%w: preview repository 不能为空", ErrInvalidPreviewRequest)
}
return &Service{repo: repo, clock: time.Now}, nil
}
// SetClock 注入测试时钟。
//
// 职责边界:
// 1. 只影响 generated_at / expires_at 和查询时的 expired 计算;
// 2. 不改写 dry-run 上下文中的业务当前时间;
// 3. clock 为空时保持原时钟,避免运行期误注入导致 panic。
func (s *Service) SetClock(clock func() time.Time) {
if s == nil || clock == nil {
return
}
s.clock = clock
}
// CreatePreview 把 dry-run 结果保存为 ready preview。
//
// 职责边界:
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
// 2. 优先吃上层 selection 结果中的 selected_candidate_id / explanation / notification 摘要;
// 若上层未显式传入,则为了兼容旧链路继续回退到 top1 candidate
// 3. 写库后只返回详情 DTO不发布通知、不正式应用候选、不回写 trigger。
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if req.ActiveContext == nil {
return nil, fmt.Errorf("%w: dry-run 结果不能为空", ErrInvalidPreviewRequest)
}
if len(req.Candidates) == 0 {
return nil, fmt.Errorf("%w: dry-run 未生成可保存候选", ErrInvalidPreviewRequest)
}
activeContext := req.ActiveContext
triggerID := strings.TrimSpace(req.TriggerID)
if triggerID == "" {
triggerID = strings.TrimSpace(activeContext.Trigger.TriggerID)
}
if triggerID == "" {
return nil, fmt.Errorf("%w: trigger_id 不能为空", ErrInvalidPreviewRequest)
}
generatedAt := req.GeneratedAt
if generatedAt.IsZero() {
generatedAt = s.now()
}
previewID := strings.TrimSpace(req.PreviewID)
if previewID == "" {
previewID = "asp_" + uuid.NewString()
}
// 1. 先解析选中的候选,再构造展示快照;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
// 1.1 若上层已经给出 selected_candidate_id就严格按该候选落库避免 preview 与选择结果不一致。
// 1.2 若未给出,则继续沿用后端候选顺序的第一条,保持旧流程兼容。
// 1.3 若指定 ID 不在候选列表中,直接返回错误,避免写入一份错位的 preview。
selected, err := pickSelectedCandidate(req.Candidates, req.SelectedCandidateID)
if err != nil {
return nil, err
}
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected, req.FallbackUsed)
baseVersion := strings.TrimSpace(req.BaseVersion)
if baseVersion == "" {
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
}
explanation := strings.TrimSpace(req.ExplanationText)
if explanation == "" {
explanation = selected.Summary
}
notificationSummary := strings.TrimSpace(req.NotificationSummary)
if notificationSummary == "" {
notificationSummary = selected.Summary
}
row, err := buildPreviewModel(previewID, triggerID, generatedAt, baseVersion, explanation, notificationSummary, activeContext, snapshot)
if err != nil {
return nil, err
}
// 2. 写入 active_schedule_previews。这里不包事务写其它表因为本服务不负责 trigger/notification/apply 状态推进。
if err := s.repo.CreatePreview(ctx, row); err != nil {
return nil, err
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &CreatePreviewResponse{Detail: detail}, nil
}
// GetPreview 查询 preview 详情,并强制校验归属用户。
//
// 职责边界:
// 1. preview_id 不存在或不属于 user_id 时统一返回 ErrPreviewNotFound避免泄漏其它用户数据
// 2. 查询不会把过期 preview 回写为 expired过期状态仅在 DTO 中计算;
// 3. 不读取正式日程实时状态,因此不会触发 confirm 的 base_version 重校验。
func (s *Service) GetPreview(ctx context.Context, userID int, previewID string) (*ActiveSchedulePreviewDetail, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if userID <= 0 || strings.TrimSpace(previewID) == "" {
return nil, ErrPreviewNotFound
}
row, err := s.repo.GetPreviewByID(ctx, strings.TrimSpace(previewID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPreviewNotFound
}
return nil, err
}
if row == nil || row.UserID != userID {
return nil, ErrPreviewNotFound
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &detail, nil
}
func (s *Service) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
func pickSelectedCandidate(candidates []candidate.Candidate, selectedCandidateID string) (candidate.Candidate, error) {
if len(candidates) == 0 {
return candidate.Candidate{}, fmt.Errorf("%w: dry-run 链路未生成可保存候选", ErrInvalidPreviewRequest)
}
selectedCandidateID = strings.TrimSpace(selectedCandidateID)
if selectedCandidateID == "" {
return candidates[0], nil
}
for _, item := range candidates {
if strings.TrimSpace(item.CandidateID) == selectedCandidateID {
return item, nil
}
}
return candidate.Candidate{}, fmt.Errorf("%w: selected_candidate_id 不在候选列表中", ErrInvalidPreviewRequest)
}
func buildPreviewModel(
previewID string,
triggerID string,
generatedAt time.Time,
baseVersion string,
explanation string,
notificationSummary string,
activeContext *schedulercontext.ActiveScheduleContext,
snapshot rawPreviewSnapshot,
) (*model.ActiveSchedulePreview, error) {
selectedJSON, err := jsonString(snapshot.selectedCandidate)
if err != nil {
return nil, err
}
candidatesJSON, err := jsonString(snapshot.candidates)
if err != nil {
return nil, err
}
decisionJSON, err := jsonString(snapshot.decision)
if err != nil {
return nil, err
}
metricsJSON, err := jsonString(snapshot.metrics)
if err != nil {
return nil, err
}
issuesJSON, err := jsonString(snapshot.issues)
if err != nil {
return nil, err
}
contextJSON, err := jsonString(snapshot.contextSummary)
if err != nil {
return nil, err
}
beforeJSON, err := jsonString(snapshot.before)
if err != nil {
return nil, err
}
changesJSON, err := jsonString(snapshot.changes)
if err != nil {
return nil, err
}
afterJSON, err := jsonString(snapshot.after)
if err != nil {
return nil, err
}
riskJSON, err := jsonString(snapshot.risk)
if err != nil {
return nil, err
}
return &model.ActiveSchedulePreview{
ID: previewID,
UserID: activeContext.User.UserID,
TriggerID: triggerID,
TriggerType: string(activeContext.Trigger.TriggerType),
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
Status: model.ActiveSchedulePreviewStatusReady,
SelectedCandidateID: snapshot.selectedCandidate.CandidateID,
CandidateCount: len(snapshot.candidates),
SelectedCandidateJSON: &selectedJSON,
CandidatesJSON: &candidatesJSON,
DecisionJSON: &decisionJSON,
MetricsJSON: &metricsJSON,
IssuesJSON: &issuesJSON,
ContextSummaryJSON: &contextJSON,
BeforeSummaryJSON: &beforeJSON,
PreviewChangesJSON: &changesJSON,
AfterSummaryJSON: &afterJSON,
RiskJSON: &riskJSON,
ExplanationText: explanation,
NotificationSummary: notificationSummary,
BaseVersion: baseVersion,
ExpiresAt: generatedAt.Add(time.Hour),
GeneratedAt: generatedAt,
ApplyStatus: model.ActiveScheduleApplyStatusNone,
TraceID: activeContext.Trace.TraceID,
}, nil
}
func buildSnapshot(
activeContext *schedulercontext.ActiveScheduleContext,
observation observe.Result,
candidates []candidate.Candidate,
selected candidate.Candidate,
fallbackUsed bool,
) rawPreviewSnapshot {
selectedDTO := candidateDTO(selected)
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
for _, item := range candidates {
candidateDTOs = append(candidateDTOs, candidateDTO(item))
}
changes := changeDTOs(selected.CandidateID, selected.Changes)
before := buildBeforeSummary(activeContext, selected, changes)
after := buildAfterSummary(before, selected, changes)
return rawPreviewSnapshot{
selectedCandidate: selectedDTO,
candidates: candidateDTOs,
decision: observation.Decision,
metrics: observation.Metrics,
issues: observation.Issues,
contextSummary: contextSummaryDTO(activeContext),
before: before,
changes: changes,
after: after,
risk: riskDTO(selected, observation, changes, fallbackUsed),
}
}