package chat import ( "context" "fmt" "log/slog" "strings" "sync" "time" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/replyexport" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" ) // Message 历史消息结构 type Message struct { ID string `json:"id"` Role string `json:"role"` // "user" | "assistant" Content string `json:"content"` Intent intent.IntentType `json:"intent,omitempty"` Branch intent.ResolutionBranch `json:"branch,omitempty"` AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` Timestamp time.Time `json:"timestamp"` } // ChatResponse 会话单轮响应 type ChatResponse struct { ReplyText string `json:"reply_text"` Branch intent.ResolutionBranch `json:"branch"` IntentResult *intent.IntentRecognitionResult `json:"intent_result"` CoordinationResult *coordinator.CoordinationResult `json:"coordination_result"` AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"` IntentPlan *IntentPlanView `json:"intent_plan,omitempty"` } // Session 多轮对话与协同会话管理器 // mu 保护 Messages/UserCtx/LastAccessedAt/ProjectID/ActiveStageKey 的并发访问; // execMu 仅用于同 session 串行执行 LLM 回合,两者正交。 type Session struct { ID string `json:"id"` UserID int64 `json:"user_id"` ProjectID int64 `json:"project_id"` ActiveStageKey string `json:"active_stage_key,omitempty"` MessageScope *projectinstance.MessageScope `json:"-"` UserCtx *coordinator.UserProjectContext `json:"user_ctx"` Recognizer intent.Recognizer `json:"-"` Coordinator *coordinator.CoordinatorEngine `json:"-"` CtxMgr *ctxmgr.ContextManager `json:"-"` ChatLog ChatMemory `json:"-"` Actions *ActionServices `json:"-"` Messages []Message `json:"messages"` CreatedAt time.Time `json:"created_at"` LastAccessedAt time.Time `json:"last_accessed_at"` mu sync.RWMutex `json:"-"` execMu sync.Mutex `json:"-"` } // SnapshotMessages 返回 Messages 的只读快照(拷贝),调用方可在锁外安全遍历/编码。 func (s *Session) SnapshotMessages() []Message { s.mu.RLock() defer s.mu.RUnlock() out := make([]Message, len(s.Messages)) copy(out, s.Messages) return out } // AppendMessage 在锁内向 Messages 追加一条消息。 func (s *Session) AppendMessage(m Message) { s.mu.Lock() defer s.mu.Unlock() s.Messages = append(s.Messages, m) } // TouchLastAccessed 更新访问时间戳。 func (s *Session) TouchLastAccessed() { s.mu.Lock() defer s.mu.Unlock() s.LastAccessedAt = time.Now() } // LastAccessed 读取访问时间戳。 func (s *Session) LastAccessed() time.Time { s.mu.RLock() defer s.mu.RUnlock() return s.LastAccessedAt } // SetProjectID 设置当前会话绑定的项目 ID。 func (s *Session) SetProjectID(pid int64) { s.mu.Lock() defer s.mu.Unlock() s.ProjectID = pid } // Project 读取当前会话绑定的项目 ID。 func (s *Session) Project() int64 { s.mu.RLock() defer s.mu.RUnlock() return s.ProjectID } // TurnOptions 客户端回合幂等选项 type TurnOptions struct { ClientTurnID string } // WithExecLock 同 session 内串行执行 func (s *Session) WithExecLock(fn func() error) error { s.execMu.Lock() defer s.execMu.Unlock() return fn() } // NewSession 创建新对话会话 func NewSession(id string, userCtx *coordinator.UserProjectContext, rec intent.Recognizer, coord *coordinator.CoordinatorEngine) *Session { if rec == nil { rec = intent.NewRecognizer() } if id == "" { id = fmt.Sprintf("sess_%d", time.Now().Unix()) } // 若上下文尚未挂起预期且当前已具有阶段,则初始化默认选项草稿 if userCtx != nil && userCtx.PendingExpectation == nil && userCtx.Project.CurrentStage != "" { _ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{ ExpectedVersion: userCtx.Version, SetPendingExpectation: true, PendingExpectation: coordinator.BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name), }) } sess := &Session{ ID: id, UserID: 1, ProjectID: 1, UserCtx: userCtx, Recognizer: rec, Coordinator: coord, Messages: make([]Message, 0), CreatedAt: time.Now(), LastAccessedAt: time.Now(), } sess.SyncRecentDialogue() return sess } // SyncRecentDialogue 使用双预算滑动窗口将消息转换为 DialogueTurn 同步到 UserCtx.RecentDialogue func (s *Session) SyncRecentDialogue() { if s.UserCtx == nil { return } records := s.toChatRecords() cfg := ctxmgr.DefaultWindowConfig() window, early, truncated := ctxmgr.AssembleWindow(records, cfg) turns := make([]coordinator.DialogueTurn, 0, len(window)) for _, m := range window { if strings.TrimSpace(m.Content) == "" { continue } turns = append(turns, coordinator.DialogueTurn{Role: m.Role, Content: m.Content}) } _ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{ ExpectedVersion: s.UserCtx.Version, SetRecentDialogue: true, RecentDialogue: turns, }) // 若发生了截断且存在早期消息,同步计算或加载滚动摘要 if truncated && len(early) > 0 && s.CtxMgr != nil { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() s.syncDialogueSummary(ctx, early) } } func (s *Session) toChatRecords() []ctxmgr.ChatMessageRecord { records := make([]ctxmgr.ChatMessageRecord, 0, len(s.Messages)) for i, m := range s.Messages { records = append(records, ctxmgr.ChatMessageRecord{ ID: int64(i + 1), SessionID: s.ID, Role: m.Role, Content: m.Content, Intent: string(m.Intent), Branch: string(m.Branch), CreatedAt: m.Timestamp, }) } return records } func (s *Session) syncDialogueSummary(ctx context.Context, early []ctxmgr.ChatMessageRecord) { if s.CtxMgr == nil || len(early) == 0 { return } oldSum, _ := s.CtxMgr.GetChatSummary(ctx, s.ID) if oldSum == nil || len(early) > oldSum.CoveredCount { newSum, err := s.CtxMgr.GenerateRollingSummary(ctx, s.ID, oldSum, early) if err == nil && newSum != nil && s.UserCtx != nil { s.UserCtx.DialogueSummary = newSum.RenderPromptSnippet() } } else if oldSum != nil && s.UserCtx != nil { s.UserCtx.DialogueSummary = oldSum.RenderPromptSnippet() } } // syncPersonalizedGuidance 将用户偏好画像编译为 Prompt 指导语并注入 UserCtx func (s *Session) syncPersonalizedGuidance(ctx context.Context, userInput string) { if s.UserCtx == nil || s.CtxMgr == nil || s.UserID <= 0 { return } // 首次注入,或用户显式表达定制要求("记住..." / "以后请..." / "默认都..."),或周期性刷新(每10条消息) needsRefresh := s.UserCtx.PersonalizedGuidance == "" || strings.Contains(userInput, "记住") || strings.Contains(userInput, "以后请") || strings.Contains(userInput, "默认都") || len(s.Messages)%10 == 0 if !needsRefresh { return } if guidance, err := s.CtxMgr.CompilePromptGuidance(ctx, s.UserID); err == nil && guidance != "" { s.UserCtx.PersonalizedGuidance = guidance } } // shouldRetrieveMemory 判定当轮意图与输入是否需要按需触发历史对话记忆语义召回 func (s *Session) shouldRetrieveMemory(userInput string, recRes *intent.IntentRecognitionResult) bool { if recRes == nil { return false } // 分支 3:模糊咨询/分忧引导,需要召回历史记忆辅助 LLM 理解语境 if recRes.Branch == intent.BranchAmbiguousFallback { return true } // 用户包含显式历史追溯意图关键词,触发召回 keywords := []string{"历史", "之前", "上次", "讨论过", "记录", "查一下", "提过", "曾", "往期", "回顾"} for _, kw := range keywords { if strings.Contains(userInput, kw) { return true } } if recRes.TopIntent != nil { switch recRes.TopIntent.Intent { case intent.IntentQueryProgress, intent.IntentQuerySimilarCases, intent.IntentQueryRegulations: return true } } return false } // syncRecalledMemories 按需执行历史对话切片语义检索并格式化注入 UserCtx func (s *Session) syncRecalledMemories(ctx context.Context, userInput string, recRes *intent.IntentRecognitionResult) { if s.CtxMgr == nil || s.UserCtx == nil || !s.shouldRetrieveMemory(userInput, recRes) { return } opts := ctxmgr.ChatMemorySearchOptions{ Query: userInput, UserID: s.UserID, ProjectID: s.ProjectID, ExcludeSessionID: s.ID, TopK: 3, MinScore: 0.20, } var items []ctxmgr.RecalledMemoryItem var err error if s.ChatLog != nil { items, err = s.ChatLog.SearchMemory(ctx, s.UserID, MemoryQuery{ Query: userInput, ProjectID: s.ProjectID, ExcludeSessionID: s.ID, TopK: 3, MinScore: 0.20, }) } else { items, err = s.CtxMgr.SearchChatMemory(ctx, opts) } if err == nil && len(items) > 0 { s.UserCtx.RecalledMemories = items s.UserCtx.RecalledMemorySnippet = ctxmgr.FormatRecalledMemorySnippet(items, s.ID) } } // SendMessage 处理一轮用户输入 (标准阻塞模式) func (s *Session) SendMessage(ctx context.Context, userInput string) (*ChatResponse, error) { streamer := stream.FromContext(ctx) return s.sendMessageInternal(ctx, userInput, streamer, nil) } // SendMessageStream 处理一轮用户输入并实时流式推送事件 (支持 SSE / 终端流式监听) func (s *Session) SendMessageStream(ctx context.Context, userInput string, listener stream.Listener, includeDetails bool) (*ChatResponse, error) { return s.SendMessageStreamTurn(ctx, userInput, listener, includeDetails, nil) } // SendMessageStreamTurn 带 client_turn_id 的流式对话 func (s *Session) SendMessageStreamTurn(ctx context.Context, userInput string, listener stream.Listener, includeDetails bool, turn *TurnOptions) (*ChatResponse, error) { ctxWithS, streamer := stream.WithListener(ctx, includeDetails, listener) return s.sendMessageInternal(ctxWithS, userInput, streamer, turn) } func (s *Session) sendMessageInternal(ctx context.Context, userInput string, streamer *stream.Streamer, turn *TurnOptions) (*ChatResponse, error) { start := time.Now() s.TouchLastAccessed() if streamer != nil { streamer.EmitPhase("intent_recognizing", "正在研判您的意图与项目上下文...", "coordinator", 0, 0, 0) } // 0. 同步已有前序对话到 UserCtx,供意图快照、路由评分与对话 LLM 感知历史轮次 s.SyncRecentDialogue() userMsg := Message{ ID: fmt.Sprintf("msg_%d_u", time.Now().UnixNano()), Role: "user", Content: userInput, Timestamp: time.Now(), } s.AppendMessage(userMsg) var userMsgID int64 clientTurnID := "" if turn != nil { clientTurnID = turn.ClientTurnID } turnCompleted := false if clientTurnID != "" && s.CtxMgr != nil { defer func() { if !turnCompleted { _ = s.CtxMgr.FailChatTurn(ctx, s.ID, clientTurnID) } }() } if s.CtxMgr != nil { userMeta := map[string]any{} if s.ProjectID > 0 { userMeta["project_id"] = s.ProjectID } s.applyMessageScopeMeta(userMeta) if clientTurnID != "" { userMeta["client_turn_id"] = clientTurnID userMeta["client_id"] = clientTurnID + ":user" } var recErr error userMsgID, recErr = s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, s.UserID, "user", userInput, "", "", userMeta) if recErr != nil { if streamer != nil { streamer.EmitErrorInfo(stream.ClassifyError(recErr, "persist_user_message", stream.RequestIDFromContext(ctx), s.ID, "")) } if clientTurnID != "" { _ = s.CtxMgr.FailChatTurn(ctx, s.ID, clientTurnID) } return nil, fmt.Errorf("record user message: %w", recErr) } if clientTurnID != "" { _ = s.CtxMgr.UpdateChatTurnUserMessage(ctx, s.ID, clientTurnID, userMsgID) } if streamer != nil && userMsgID > 0 { streamer.EmitTurnAck(clientTurnID, userMsgID, "processing", false) } } // 偏好画像同步 s.syncPersonalizedGuidance(ctx, userInput) // 1. 意图识别 (L1 对话层,带非阻塞桌角备忘快照) var snap *intent.ContextSnapshot if s.UserCtx != nil { snap = s.UserCtx.ToSnapshot() } recRes, err := s.Recognizer.Recognize(ctx, userInput, snap) if err != nil { if streamer != nil { streamer.EmitErrorInfo(stream.ClassifyError(err, "intent_recognizing", stream.RequestIDFromContext(ctx), s.ID, "")) } return nil, fmt.Errorf("intent recognition failed: %w", err) } var persistedPlan *IntentPlanView if s.Actions != nil && s.Actions.IntentPlans != nil && s.UserID > 0 && s.ProjectID > 0 { env := s.Actions.Environment if env == "" { env = "dev" } turnID := clientTurnID if turnID == "" { turnID = fmt.Sprintf("turn-%d", time.Now().UnixNano()) } tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, IntentTurnInput{ UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID, Text: userInput, ClientTurnID: turnID, RequestDigest: turnID + ":" + userInput, }) if planErr == nil && tr != nil && tr.Plan != nil { persistedPlan = tr.Plan recRes.IntentPlan = nil } } if streamer != nil && recRes.TopIntent != nil { streamer.EmitStatus( string(recRes.TopIntent.Intent), string(recRes.Branch), string(s.UserCtx.Project.CurrentStage), fmt.Sprintf("识别分支: %s (置信度: %.0f%%)", recRes.Branch, recRes.TopIntent.Confidence*100), recRes.TopIntent.FilledSlots, ) } // 1.5 规则查询改写 (P3 阶段:基于当轮输入前的历史状态机进行省略补全 + 代词消解 + 主题延续) searchQuery := userInput if s.UserCtx != nil { rewriteRes := coordinator.RewriteQuery( userInput, s.UserCtx.TaskState, s.UserCtx.RecentDialogue, s.UserCtx.Project, ) s.UserCtx.RewrittenQuery = rewriteRes.Rewritten s.UserCtx.RewriteRules = rewriteRes.AppliedRules if rewriteRes.SearchQuery != "" { searchQuery = rewriteRes.SearchQuery } } // 1.6 刷新会话级任务状态机 (DST,合入改写后完整问句所携带的实体与锚点) if s.UserCtx != nil { if s.UserCtx.TaskState == nil { s.UserCtx.TaskState = ctxmgr.NewConversationTaskState() } intentName := "" var filledSlots map[string]string if recRes.TopIntent != nil { intentName = string(recRes.TopIntent.Intent) filledSlots = recRes.TopIntent.FilledSlots } effectiveInput := userInput if s.UserCtx.RewrittenQuery != "" { effectiveInput = s.UserCtx.RewrittenQuery } s.UserCtx.TaskState.UpdateFromTurn( effectiveInput, intentName, filledSlots, string(recRes.Branch), string(s.UserCtx.Project.CurrentStage), ) } // 1.8 按需语义召回历史对话切片 (P2 阶段:优先使用改写增强后的 searchQuery) s.syncRecalledMemories(ctx, searchQuery, recRes) // 2. 协调调度与校验 (L2 协调层) if streamer != nil { streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40) } coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx) if err != nil { if streamer != nil { streamer.EmitErrorInfo(stream.ClassifyError(err, "dialogue_generating", stream.RequestIDFromContext(ctx), s.ID, "")) } return nil, fmt.Errorf("coordination execution failed: %w", err) } if coordRes.PendingAction != nil { execResult, execErr := s.executeAction(ctx, coordRes.PendingAction, s.UserCtx) coordRes.NaturalReply = renderActionReply(coordRes.PendingAction, execResult, execErr) coordRes.PendingAction = nil } // 3. 更新上下文与挂起预期生命周期 if coordRes.UpdatedContext != nil { if coordRes.UpdatedContext.TaskState == nil && s.UserCtx != nil { coordRes.UpdatedContext.TaskState = s.UserCtx.TaskState } if coordRes.UpdatedContext.DialogueSummary == "" && s.UserCtx != nil { coordRes.UpdatedContext.DialogueSummary = s.UserCtx.DialogueSummary } if len(coordRes.UpdatedContext.RecalledMemories) == 0 && s.UserCtx != nil { coordRes.UpdatedContext.RecalledMemories = s.UserCtx.RecalledMemories coordRes.UpdatedContext.RecalledMemorySnippet = s.UserCtx.RecalledMemorySnippet } if coordRes.UpdatedContext.RewrittenQuery == "" && s.UserCtx != nil { coordRes.UpdatedContext.RewrittenQuery = s.UserCtx.RewrittenQuery coordRes.UpdatedContext.RewriteRules = s.UserCtx.RewriteRules } s.UserCtx = coordRes.UpdatedContext } if coordRes.ClearExpectation { _ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{ ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true, }) } else if coordRes.NextExpectation != nil { _ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{ ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true, PendingExpectation: coordRes.NextExpectation, }) } else if recRes.Branch == intent.BranchExpectationHit { _ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{ ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true, }) } // 若为 BranchNewIntentPreempt 且未产生新预期,原挂起预期在焦点底静默保留,不阻拦用户 asstMsg := Message{ ID: fmt.Sprintf("msg_%d_a", time.Now().UnixNano()), Role: "assistant", Content: coordRes.NaturalReply, Intent: coordRes.Intent, Branch: recRes.Branch, AdvisorBrief: coordRes.AdvisorBrief, InteractiveCard: coordRes.InteractiveCard, Timestamp: time.Now(), } s.AppendMessage(asstMsg) if s.CtxMgr != nil { replySrc := coordRes.ReplySource if replySrc == "" { replySrc = coordinator.ReplySourceTemplate } asstMeta := map[string]any{ "reply_source": replySrc, } if s.ProjectID > 0 { asstMeta["project_id"] = s.ProjectID } if coordRes.TaskStatus != "" { asstMeta["task_status"] = coordRes.TaskStatus } if coordRes.TaskKey != "" { asstMeta["task_key"] = coordRes.TaskKey } if coordRes.DocID != "" { artifact := map[string]any{ "doc_id": coordRes.DocID, "title": coordRes.ArtifactTitle, } if coordRes.TaskKey != "" { artifact["task_key"] = coordRes.TaskKey } if coordRes.PendingAgentCandidateID > 0 { artifact["pending_agent_candidate_id"] = coordRes.PendingAgentCandidateID artifact["awaiting_author_confirmation"] = true if coordRes.PendingChangeReportID > 0 { artifact["pending_change_report_id"] = coordRes.PendingChangeReportID } if coordRes.PendingRiskLevel != "" { artifact["pending_risk_level"] = coordRes.PendingRiskLevel } } asstMeta["artifact"] = artifact } s.applyMessageScopeMeta(asstMeta) if recRes.TopIntent != nil { asstMeta["turn_intent"] = string(recRes.TopIntent.Intent) } isMilestone := false if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 { isMilestone = true asstMeta["milestone"] = true } if coordRes.Intent == intent.IntentConfirmAction || coordRes.Intent == intent.IntentExecuteStageAdvance || coordRes.Intent == intent.IntentTriggerAdhocTask { isMilestone = true asstMeta["milestone"] = true } evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{ Role: "assistant", Status: "completed", Content: coordRes.NaturalReply, HasInteractiveCard: coordRes.InteractiveCard != nil, Revision: 1, }) asstMeta["content_kind"] = string(evalKind) asstMeta["export_policy"] = string(evalCap.Policy) asstMeta["content_revision"] = evalCap.ContentRevision asstMeta["content_sha256"] = evalCap.ContentSha256 if clientTurnID != "" { asstMeta["client_turn_id"] = clientTurnID asstMeta["client_id"] = clientTurnID + ":assistant" } asstMsgID, _ := s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta) // 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复) sessID := s.ID turnNo := len(s.Messages) / 2 uid := s.UserID pid := s.ProjectID q := userInput ans := coordRes.NaturalReply iName := string(coordRes.Intent) bName := string(recRes.Branch) go func() { defer func() { if rec := recover(); rec != nil { slog.Error("chat_turn_memory_panic", "session_id", sessID, "recover", rec) } }() bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, _ = s.CtxMgr.RecordTurnMemory(bgCtx, sessID, turnNo, uid, pid, q, ans, iName, bName, isMilestone) }() // 本轮全部处理完毕后,再次同步,保持 UserCtx 与 Messages 一致 s.SyncRecentDialogue() // 4. 流式推送参谋自然语言回复与决策锦囊 if streamer != nil { if coordRes.NaturalReply != "" { streamer.EmitDelta(coordRes.NaturalReply) } if coordRes.AdvisorBrief != nil { streamer.EmitBriefing(coordRes.AdvisorBrief) } streamer.EmitDoneWithTurnExport(s.ID, clientTurnID, time.Since(start), userMsgID, asstMsgID, evalCap.ContentRevision, evalCap.ContentSha256, evalCap) } if clientTurnID != "" { _ = s.CtxMgr.CompleteChatTurn(ctx, s.ID, clientTurnID, asstMsgID) turnCompleted = true } } else { // 无 CtxMgr 时的降级 evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{ Role: "assistant", Status: "completed", Content: coordRes.NaturalReply, HasInteractiveCard: coordRes.InteractiveCard != nil, Revision: 1, }) _ = evalKind s.SyncRecentDialogue() if streamer != nil { if coordRes.NaturalReply != "" { streamer.EmitDelta(coordRes.NaturalReply) } if coordRes.AdvisorBrief != nil { streamer.EmitBriefing(coordRes.AdvisorBrief) } streamer.EmitDoneWithExport(s.ID, time.Since(start), 0, 0, evalCap.ContentRevision, evalCap.ContentSha256, evalCap) } } return &ChatResponse{ ReplyText: coordRes.NaturalReply, Branch: recRes.Branch, IntentResult: recRes, CoordinationResult: coordRes, AdvisorBrief: coordRes.AdvisorBrief, InteractiveCard: coordRes.InteractiveCard, PendingExpectation: s.UserCtx.PendingExpectation, IntentPlan: persistedPlan, }, nil } func (s *Session) applyMessageScopeMeta(meta map[string]any) { if meta == nil { return } if s.MessageScope != nil { projectinstance.ApplyScopeToMetadata(meta, s.MessageScope) return } if s.ActiveStageKey != "" { meta["stage_key"] = s.ActiveStageKey } }