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 收口并同步接力状态
This commit is contained in:
31
backend/active_scheduler/service/dry_run_graph.go
Normal file
31
backend/active_scheduler/service/dry_run_graph.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
||||
)
|
||||
|
||||
// AsGraphDryRunFunc 把现有 dry-run service 适配成 graph runner 可用的入口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做 service.Result -> graph.DryRunData 的轻量转换;
|
||||
// 2. 不改写 dry-run 行为,不引入额外候选逻辑;
|
||||
// 3. 让 graph runner 可以复用现有 BuildContext -> Observe -> GenerateCandidates 链路。
|
||||
func (s *DryRunService) AsGraphDryRunFunc() activegraph.DryRunFunc {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*activegraph.DryRunData, error) {
|
||||
result, err := s.DryRun(ctx, trig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &activegraph.DryRunData{
|
||||
Context: result.Context,
|
||||
Observation: result.Observation,
|
||||
Candidates: result.Candidates,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
302
backend/active_scheduler/service/session_bridge.go
Normal file
302
backend/active_scheduler/service/session_bridge.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/selection"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
activeScheduleConversationNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:conversation"))
|
||||
activeScheduleSessionNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:session"))
|
||||
)
|
||||
|
||||
// WithActiveScheduleSessionBridge 注入主动调度 session 预创建所需的 DAO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只把 trigger -> notification 前的会话桥接能力接入 workflow;
|
||||
// 2. 不改变 dry-run / preview / notification 的主状态机;
|
||||
// 3. 为空时保留旧能力,便于局部测试与迁移期回退。
|
||||
func WithActiveScheduleSessionBridge(agentDAO *dao.AgentDAO, sessionDAO *dao.ActiveScheduleSessionDAO) TriggerWorkflowOption {
|
||||
return func(s *TriggerWorkflowService) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.agentDAO = agentDAO
|
||||
s.sessionDAO = sessionDAO
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrapActiveScheduleConversationInTx 负责在 notification 发出前预建会话与首屏内容。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先生成确定性的 conversation/session ID,保证 trigger 重试时不会拆成多条会话;
|
||||
// 2. 再在同一事务里创建或复用 agent_chats 和 active_schedule_sessions;
|
||||
// 3. 如果是首次落库,则顺手补一条 assistant_text,必要时再补一张主动调度卡片;
|
||||
// 4. 任一步失败都直接返回 error,让上层事务整体回滚,避免“通知已发但会话底稿没落”。
|
||||
func (s *TriggerWorkflowService) bootstrapActiveScheduleConversationInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
triggerRow model.ActiveScheduleTrigger,
|
||||
previewDetail activepreview.ActiveSchedulePreviewDetail,
|
||||
selectionResult selection.Result,
|
||||
now time.Time,
|
||||
) error {
|
||||
if s == nil {
|
||||
return errors.New("主动调度会话桥未初始化")
|
||||
}
|
||||
if s.agentDAO == nil || s.sessionDAO == nil {
|
||||
return nil
|
||||
}
|
||||
if tx == nil {
|
||||
return errors.New("gorm tx 不能为空")
|
||||
}
|
||||
if triggerRow.ID == "" {
|
||||
return errors.New("trigger_id 不能为空")
|
||||
}
|
||||
|
||||
conversationID := buildActiveScheduleConversationID(triggerRow.ID)
|
||||
sessionID := buildActiveScheduleSessionID(triggerRow.ID)
|
||||
txAgentDAO := s.agentDAO.WithTx(tx)
|
||||
txSessionDAO := s.sessionDAO.WithTx(tx)
|
||||
|
||||
if err := ensureAgentConversationExists(ctx, txAgentDAO, triggerRow.UserID, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseSeq, err := txAgentDAO.GetConversationTimelineMaxSeq(ctx, triggerRow.UserID, conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 只有首次创建会话时才写首屏消息,避免同一 trigger 的重试把时间线重复刷一遍。
|
||||
// 2. 若 timeline 已存在,说明这段主动调度会话已经被成功预热过,直接复用现成内容即可。
|
||||
if baseSeq == 0 {
|
||||
assistantText := resolveInitialActiveScheduleAssistantText(selectionResult, previewDetail)
|
||||
if assistantText != "" {
|
||||
if err := txAgentDAO.SaveChatHistoryInTx(ctx, triggerRow.UserID, conversationID, "assistant", assistantText, "", 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindAssistantText, "assistant", assistantText, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
baseSeq++
|
||||
}
|
||||
|
||||
if shouldSeedActiveSchedulePreviewCard(selectionResult) {
|
||||
cardPayload, err := buildActiveScheduleBusinessCardPayload(previewDetail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindBusinessCard, "assistant", assistantText, cardPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionSnapshot := &model.ActiveScheduleSessionSnapshot{
|
||||
SessionID: sessionID,
|
||||
UserID: triggerRow.UserID,
|
||||
ConversationID: conversationID,
|
||||
TriggerID: triggerRow.ID,
|
||||
CurrentPreviewID: strings.TrimSpace(previewDetail.PreviewID),
|
||||
Status: resolveInitialActiveScheduleSessionStatus(selectionResult),
|
||||
State: buildInitialActiveScheduleSessionState(selectionResult, previewDetail),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return txSessionDAO.UpsertActiveScheduleSession(ctx, sessionSnapshot)
|
||||
}
|
||||
|
||||
func buildActiveScheduleConversationID(triggerID string) string {
|
||||
normalized := strings.TrimSpace(triggerID)
|
||||
if normalized == "" {
|
||||
return uuid.NewString()
|
||||
}
|
||||
return uuid.NewSHA1(activeScheduleConversationNamespace, []byte(normalized)).String()
|
||||
}
|
||||
|
||||
func buildActiveScheduleSessionID(triggerID string) string {
|
||||
normalized := strings.TrimSpace(triggerID)
|
||||
if normalized == "" {
|
||||
return uuid.NewString()
|
||||
}
|
||||
return uuid.NewSHA1(activeScheduleSessionNamespace, []byte(normalized)).String()
|
||||
}
|
||||
|
||||
func ensureAgentConversationExists(ctx context.Context, agentDAO *dao.AgentDAO, userID int, conversationID string) error {
|
||||
if agentDAO == nil {
|
||||
return errors.New("agent dao 不能为空")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id 不能为空")
|
||||
}
|
||||
|
||||
exists, err := agentDAO.IfChatExists(ctx, userID, normalizedConversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
_, err = agentDAO.CreateNewChat(userID, normalizedConversationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveInitialActiveScheduleAssistantText(selectionResult selection.Result, previewDetail activepreview.ActiveSchedulePreviewDetail) string {
|
||||
switch selectionResult.Action {
|
||||
case selection.ActionAskUser:
|
||||
return firstNonEmptyString(
|
||||
selectionResult.AskUserQuestion,
|
||||
selectionResult.ExplanationText,
|
||||
previewDetail.Explanation,
|
||||
previewDetail.Notification,
|
||||
"请先补充主动调度需要的关键信息。",
|
||||
)
|
||||
default:
|
||||
return firstNonEmptyString(
|
||||
selectionResult.ExplanationText,
|
||||
selectionResult.NotificationSummary,
|
||||
previewDetail.Notification,
|
||||
previewDetail.Explanation,
|
||||
"主动调度建议已更新。",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSeedActiveSchedulePreviewCard(selectionResult selection.Result) bool {
|
||||
return selectionResult.Action == selection.ActionSelectCandidate
|
||||
}
|
||||
|
||||
func resolveInitialActiveScheduleSessionStatus(selectionResult selection.Result) string {
|
||||
switch selectionResult.Action {
|
||||
case selection.ActionAskUser:
|
||||
return model.ActiveScheduleSessionStatusWaitingUserReply
|
||||
case selection.ActionSelectCandidate:
|
||||
return model.ActiveScheduleSessionStatusReadyPreview
|
||||
default:
|
||||
return model.ActiveScheduleSessionStatusIgnored
|
||||
}
|
||||
}
|
||||
|
||||
func buildInitialActiveScheduleSessionState(
|
||||
selectionResult selection.Result,
|
||||
previewDetail activepreview.ActiveSchedulePreviewDetail,
|
||||
) model.ActiveScheduleSessionState {
|
||||
state := model.ActiveScheduleSessionState{
|
||||
LastCandidateID: strings.TrimSpace(selectionResult.SelectedCandidateID),
|
||||
MissingInfo: cloneStringSlice(previewDetail.ContextSummary.MissingInfo),
|
||||
}
|
||||
if !previewDetail.ExpiresAt.IsZero() {
|
||||
expiresAt := previewDetail.ExpiresAt
|
||||
state.ExpiresAt = &expiresAt
|
||||
}
|
||||
switch selectionResult.Action {
|
||||
case selection.ActionAskUser:
|
||||
state.PendingQuestion = firstNonEmptyString(
|
||||
selectionResult.AskUserQuestion,
|
||||
selectionResult.ExplanationText,
|
||||
)
|
||||
case selection.ActionSelectCandidate:
|
||||
state.PendingQuestion = ""
|
||||
state.MissingInfo = nil
|
||||
state.FailedReason = ""
|
||||
default:
|
||||
state.PendingQuestion = ""
|
||||
state.MissingInfo = nil
|
||||
state.ExpiresAt = nil
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func buildActiveScheduleBusinessCardPayload(detail activepreview.ActiveSchedulePreviewDetail) (map[string]any, error) {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"business_card": map[string]any{
|
||||
"card_type": "active_schedule_preview",
|
||||
"title": "SmartFlow 日程调整建议",
|
||||
"summary": firstNonEmptyString(detail.Notification, detail.Explanation, detail.SelectedCandidate.Summary),
|
||||
"data": detail,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func saveActiveScheduleTimelineEvent(
|
||||
ctx context.Context,
|
||||
agentDAO *dao.AgentDAO,
|
||||
userID int,
|
||||
conversationID string,
|
||||
seq int64,
|
||||
kind string,
|
||||
role string,
|
||||
content string,
|
||||
payload map[string]any,
|
||||
) error {
|
||||
if agentDAO == nil {
|
||||
return errors.New("agent dao 不能为空")
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if userID <= 0 || normalizedConversationID == "" {
|
||||
return errors.New("时间线事件主键不合法")
|
||||
}
|
||||
|
||||
payloadJSON := ""
|
||||
if len(payload) > 0 {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadJSON = string(raw)
|
||||
}
|
||||
|
||||
_, _, err := agentDAO.SaveConversationTimelineEvent(ctx, model.ChatTimelinePersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: normalizedConversationID,
|
||||
Seq: seq,
|
||||
Kind: kind,
|
||||
Role: role,
|
||||
Content: content,
|
||||
PayloadJSON: payloadJSON,
|
||||
TokensConsumed: 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
@@ -118,6 +118,7 @@ func BuildFeishuRequestedPayload(
|
||||
requestedAt time.Time,
|
||||
) sharedevents.FeishuNotificationRequestedPayload {
|
||||
summary := strings.TrimSpace(notificationSummary)
|
||||
targetURL := fmt.Sprintf("/assistant/%s", buildActiveScheduleConversationID(triggerRow.ID))
|
||||
return sharedevents.FeishuNotificationRequestedPayload{
|
||||
UserID: triggerRow.UserID,
|
||||
TriggerID: triggerRow.ID,
|
||||
@@ -126,9 +127,9 @@ func BuildFeishuRequestedPayload(
|
||||
TargetType: triggerRow.TargetType,
|
||||
TargetID: triggerRow.TargetID,
|
||||
DedupeKey: BuildNotificationDedupeKey(triggerRow.UserID, triggerRow.TriggerType, triggerRow.RequestedAt),
|
||||
TargetURL: fmt.Sprintf("/schedule-adjust/%s", strings.TrimSpace(previewID)),
|
||||
TargetURL: targetURL,
|
||||
SummaryText: summary,
|
||||
FallbackText: buildNotificationFallbackText(summary, strings.TrimSpace(previewID)),
|
||||
FallbackText: buildNotificationFallbackText(summary, targetURL),
|
||||
TraceID: triggerRow.TraceID,
|
||||
RequestedAt: requestedAt,
|
||||
}
|
||||
@@ -201,8 +202,8 @@ func normalizeKafkaConfig(cfg kafkabus.Config) kafkabus.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func buildNotificationFallbackText(summary string, previewID string) string {
|
||||
link := fmt.Sprintf("/schedule-adjust/%s", previewID)
|
||||
func buildNotificationFallbackText(summary string, targetURL string) string {
|
||||
link := strings.TrimSpace(targetURL)
|
||||
if summary == "" {
|
||||
return "你有一条新的日程调整建议,请查看:" + link
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activegraph "github.com/LoveLosita/smartflow/backend/active_scheduler/graph"
|
||||
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
@@ -28,38 +29,57 @@ const (
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只推进主动调度 trigger 的后台状态机,不负责启动 outbox worker;
|
||||
// 2. dry-run 与 preview 复用现有 service,不再单独实现第二套候选生成逻辑;
|
||||
// 2. dry-run 与选择器都复用 active_scheduler 独立模块,不再往 newAgent 里塞主动调度逻辑;
|
||||
// 3. notification 只发布 requested 事件,不直接接真实飞书 provider。
|
||||
type TriggerWorkflowService struct {
|
||||
activeDAO *dao.ActiveScheduleDAO
|
||||
dryRun *DryRunService
|
||||
outbox *outboxinfra.Repository
|
||||
kafkaCfg kafkabus.Config
|
||||
clock func() time.Time
|
||||
activeDAO *dao.ActiveScheduleDAO
|
||||
graphRunner *activegraph.Runner
|
||||
outbox *outboxinfra.Repository
|
||||
kafkaCfg kafkabus.Config
|
||||
agentDAO *dao.AgentDAO
|
||||
sessionDAO *dao.ActiveScheduleSessionDAO
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func NewTriggerWorkflowService(
|
||||
activeDAO *dao.ActiveScheduleDAO,
|
||||
dryRun *DryRunService,
|
||||
graphRunner *activegraph.Runner,
|
||||
outboxRepo *outboxinfra.Repository,
|
||||
kafkaCfg kafkabus.Config,
|
||||
) (*TriggerWorkflowService, error) {
|
||||
return NewTriggerWorkflowServiceWithOptions(activeDAO, graphRunner, outboxRepo, kafkaCfg)
|
||||
}
|
||||
|
||||
// NewTriggerWorkflowServiceWithOptions 创建主动调度 trigger 编排服务,并允许注入迁移期可选能力。
|
||||
func NewTriggerWorkflowServiceWithOptions(
|
||||
activeDAO *dao.ActiveScheduleDAO,
|
||||
graphRunner *activegraph.Runner,
|
||||
outboxRepo *outboxinfra.Repository,
|
||||
kafkaCfg kafkabus.Config,
|
||||
opts ...TriggerWorkflowOption,
|
||||
) (*TriggerWorkflowService, error) {
|
||||
if activeDAO == nil {
|
||||
return nil, errors.New("active schedule dao 不能为空")
|
||||
}
|
||||
if dryRun == nil {
|
||||
return nil, errors.New("dry-run service 不能为空")
|
||||
if graphRunner == nil {
|
||||
return nil, errors.New("active scheduler graph runner 不能为空")
|
||||
}
|
||||
if outboxRepo == nil {
|
||||
return nil, errors.New("outbox repository 不能为空")
|
||||
}
|
||||
return &TriggerWorkflowService{
|
||||
activeDAO: activeDAO,
|
||||
dryRun: dryRun,
|
||||
outbox: outboxRepo,
|
||||
kafkaCfg: kafkaCfg,
|
||||
clock: time.Now,
|
||||
}, nil
|
||||
svc := &TriggerWorkflowService{
|
||||
activeDAO: activeDAO,
|
||||
graphRunner: graphRunner,
|
||||
outbox: outboxRepo,
|
||||
kafkaCfg: kafkaCfg,
|
||||
clock: time.Now,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(svc)
|
||||
}
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (s *TriggerWorkflowService) SetClock(clock func() time.Time) {
|
||||
@@ -68,12 +88,15 @@ func (s *TriggerWorkflowService) SetClock(clock func() time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerWorkflowOption 是 trigger 编排服务的可选注入项。
|
||||
type TriggerWorkflowOption func(*TriggerWorkflowService)
|
||||
|
||||
// ProcessTriggeredInTx 在 outbox 消费事务内推进 trigger 主链路。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先锁 trigger 行,确保同一 trigger 在并发 worker 下只能由一个事务推进;
|
||||
// 2. 再把状态切到 processing,避免排障时看不出消息已经被消费;
|
||||
// 3. 复用 dry-run + preview service 生成预览;若发现已有 preview,则直接复用,避免重复写库;
|
||||
// 3. 复用 active scheduler graph 跑 dry-run + 受限选择;若发现已有 preview,则直接复用,避免重复写库;
|
||||
// 4. preview 成功后回写 trigger 状态,并在同一事务里补发 notification.requested outbox;
|
||||
// 5. 任一步失败都返回 error,由外层 handler 负责记录 failed 状态并触发 outbox retry。
|
||||
func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||
@@ -81,7 +104,7 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||
tx *gorm.DB,
|
||||
payload sharedevents.ActiveScheduleTriggeredPayload,
|
||||
) error {
|
||||
if s == nil || s.activeDAO == nil || s.dryRun == nil || s.outbox == nil {
|
||||
if s == nil || s.activeDAO == nil || s.graphRunner == nil || s.outbox == nil {
|
||||
return errors.New("trigger workflow service 未初始化")
|
||||
}
|
||||
if tx == nil {
|
||||
@@ -125,14 +148,18 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||
}
|
||||
|
||||
domainTrigger := buildDomainTriggerFromModel(*triggerRow, payload)
|
||||
dryRunResult, err := s.dryRun.DryRun(ctx, domainTrigger)
|
||||
graphResult, err := s.graphRunner.Run(ctx, domainTrigger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(dryRunResult.Candidates) == 0 {
|
||||
if graphResult == nil || graphResult.DryRunData == nil {
|
||||
return errors.New("active scheduler graph 返回空结果")
|
||||
}
|
||||
dryRunData := graphResult.DryRunData
|
||||
if len(dryRunData.Candidates) == 0 {
|
||||
return s.markClosedWithoutPreview(ctx, txDAO, triggerRow.ID, now)
|
||||
}
|
||||
if !dryRunResult.Observation.Decision.ShouldNotify && !dryRunResult.Observation.Decision.ShouldWritePreview {
|
||||
if !dryRunData.Observation.Decision.ShouldNotify && !dryRunData.Observation.Decision.ShouldWritePreview {
|
||||
return s.markClosedWithoutPreview(ctx, txDAO, triggerRow.ID, now)
|
||||
}
|
||||
|
||||
@@ -141,11 +168,15 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||
return err
|
||||
}
|
||||
previewResp, err := previewService.CreatePreview(ctx, activepreview.CreatePreviewRequest{
|
||||
ActiveContext: dryRunResult.Context,
|
||||
Observation: dryRunResult.Observation,
|
||||
Candidates: dryRunResult.Candidates,
|
||||
TriggerID: triggerRow.ID,
|
||||
GeneratedAt: now,
|
||||
ActiveContext: dryRunData.Context,
|
||||
Observation: dryRunData.Observation,
|
||||
Candidates: dryRunData.Candidates,
|
||||
TriggerID: triggerRow.ID,
|
||||
GeneratedAt: now,
|
||||
SelectedCandidateID: graphResult.SelectionResult.SelectedCandidateID,
|
||||
ExplanationText: graphResult.SelectionResult.ExplanationText,
|
||||
NotificationSummary: graphResult.SelectionResult.NotificationSummary,
|
||||
FallbackUsed: graphResult.SelectionResult.FallbackUsed,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -162,10 +193,16 @@ func (s *TriggerWorkflowService) ProcessTriggeredInTx(
|
||||
return err
|
||||
}
|
||||
|
||||
if !dryRunResult.Observation.Decision.ShouldNotify {
|
||||
if !dryRunData.Observation.Decision.ShouldNotify {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. 离线通知发出前,先把用户点击后要进入的助手会话和主动调度 session 预热好。
|
||||
// 2. 这一步和 preview / notification outbox 在同一事务内提交,避免出现“飞书已送达但会话空白”的断裂状态。
|
||||
if err := s.bootstrapActiveScheduleConversationInTx(ctx, tx, *triggerRow, previewResp.Detail, graphResult.SelectionResult, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notificationPayload := BuildFeishuRequestedPayload(
|
||||
*triggerRow,
|
||||
previewID,
|
||||
|
||||
Reference in New Issue
Block a user