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