后端:
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 收口并同步接力状态
308 lines
10 KiB
Go
308 lines
10 KiB
Go
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),
|
||
}
|
||
}
|