From d155e45a44b3a807be5a9ab16f24be34df6fbaa3 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 12 May 2026 11:10:21 +0800 Subject: [PATCH] fix(conversation): topic chips after warmup + English chip copy - Buffer topic_suggestions until chat UI attaches (uiOwner + callback); replay on attach - build_topic_chips respects user language for label/text; router passes user_language Co-authored-by: Cursor --- api/app/agents/chat/prompts_conversation.py | 34 +++++++------ api/app/features/conversation/ws/router.py | 1 + api/tests/test_interview_turn_plan.py | 10 ++++ .../features/conversation/realtime-session.ts | 24 +++++++++- .../realtime-session-sync-order.test.ts | 48 +++++++++++++++++++ 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 1081dd9..b1eb844 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -710,41 +710,47 @@ def build_topic_chips( empty_slots: List[str], *, max_chips: int = 4, + language: str = "zh", ) -> List[Dict[str, str]]: """根据当前阶段与空 slot 列表生成 quick-start 话题 chips。 返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}] """ + slot_labels = slot_name_map_for(language) stage_bank = _STAGE_TOPIC_CHIP_BANK.get(current_stage) or [] seen: set[str] = set() chips: List[Dict[str, str]] = [] # 优先从「当前阶段空 slot」挑选(与开场提问方向一致) empty_set = {s for s in empty_slots if s} - for slot_key, label in stage_bank: + for slot_key, zh_label in stage_bank: if slot_key in empty_set and slot_key not in seen: - chips.append( - { - "id": slot_key, - "label": label, - "text": f"我想聊聊{label}", - } + label = ( + zh_label if language != "en" else slot_labels.get(slot_key, zh_label) ) + text = ( + f"I'd like to talk about {label}." + if language == "en" + else f"我想聊聊{label}" + ) + chips.append({"id": slot_key, "label": label, "text": text}) seen.add(slot_key) if len(chips) >= max_chips: return chips # 不足则用阶段默认话题补齐 - for slot_key, label in stage_bank: + for slot_key, zh_label in stage_bank: if slot_key in seen: continue - chips.append( - { - "id": slot_key, - "label": label, - "text": f"我想聊聊{label}", - } + label = ( + zh_label if language != "en" else slot_labels.get(slot_key, zh_label) ) + text = ( + f"I'd like to talk about {label}." + if language == "en" + else f"我想聊聊{label}" + ) + chips.append({"id": slot_key, "label": label, "text": text}) seen.add(slot_key) if len(chips) >= max_chips: return chips diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index c1dbb45..9e522ad 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -235,6 +235,7 @@ async def websocket_endpoint( memoir_state.current_stage, empty_slots, max_chips=settings.chat_topic_chips_max, + language=user_language, ) if not chips: return diff --git a/api/tests/test_interview_turn_plan.py b/api/tests/test_interview_turn_plan.py index 46dea10..7e03d35 100644 --- a/api/tests/test_interview_turn_plan.py +++ b/api/tests/test_interview_turn_plan.py @@ -286,3 +286,13 @@ def test_plan_follow_when_no_empty_slots(): ) assert p.mode == "follow_user_only" assert p.low_information_reply is True + + +def test_build_topic_chips_english_uses_slot_name_map_en(): + from app.agents.chat.prompts_conversation import build_topic_chips + + chips = build_topic_chips("childhood", ["place"], max_chips=2, language="en") + assert len(chips) >= 1 + place_chip = next(c for c in chips if c["id"] == "place") + assert place_chip["label"] == "where you grew up" + assert place_chip["text"].startswith("I'd like to talk about") diff --git a/app-expo/src/features/conversation/realtime-session.ts b/app-expo/src/features/conversation/realtime-session.ts index 6ddcab1..1a82c12 100644 --- a/app-expo/src/features/conversation/realtime-session.ts +++ b/app-expo/src/features/conversation/realtime-session.ts @@ -32,6 +32,8 @@ export type TopicSuggestionsCallback = (payload: { suggestions: TopicSuggestion[]; }) => void; +type TopicSuggestionsPayload = Parameters[0]; + /** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */ export type TtsSegmentPayload = { audioBase64?: string; @@ -85,6 +87,8 @@ export class RealtimeSession { private streamingBuffer = ''; /** 单段回复且服务端带 `assistant_message_id` 时用于落缓存 id */ private pendingAssistantMessageId: string | null = null; + /** WS 在聊天页挂载 `onTopicSuggestions` 之前到达(如列表预热连开场)时暂存,接棒后重放 */ + private pendingTopicSuggestionsPayload: TopicSuggestionsPayload | null = null; private destroyed = false; /** 本条用户消息是否请求「先 TTS 再出字」的助手轮次 */ @@ -162,6 +166,15 @@ export class RealtimeSession { if (!this.assistantTurnTtsSync && this.streamingBuffer.trim().length > 0) { this.onStreamingText?.(this.streamingBuffer, false); } + if ( + this.uiOwner && + this.pendingTopicSuggestionsPayload && + this.onTopicSuggestions + ) { + const p = this.pendingTopicSuggestionsPayload; + this.pendingTopicSuggestionsPayload = null; + this.onTopicSuggestions(p); + } } releaseUiCallbacks( @@ -197,6 +210,7 @@ export class RealtimeSession { this.resetAssistantTtsSyncState(); this.unsubEvent?.(); this.unsubState?.(); + this.pendingTopicSuggestionsPayload = null; this.client.dispose(); } @@ -394,11 +408,17 @@ export class RealtimeSession { } if (event.kind === 'topic_suggestions') { - this.onTopicSuggestions?.({ + const payload: TopicSuggestionsPayload = { reason: event.reason, stage: event.stage, suggestions: event.suggestions, - }); + }; + if (this.uiOwner && this.onTopicSuggestions) { + this.pendingTopicSuggestionsPayload = null; + this.onTopicSuggestions(payload); + } else { + this.pendingTopicSuggestionsPayload = payload; + } return; } diff --git a/app-expo/tests/features/conversation/realtime-session-sync-order.test.ts b/app-expo/tests/features/conversation/realtime-session-sync-order.test.ts index a3778b1..1b3468d 100644 --- a/app-expo/tests/features/conversation/realtime-session-sync-order.test.ts +++ b/app-expo/tests/features/conversation/realtime-session-sync-order.test.ts @@ -250,4 +250,52 @@ describe('RealtimeSession sync TTS / agent ordering', () => { expect(staleScreenOnTts).not.toHaveBeenCalled(); session.dispose(); }); + + it('replays topic_suggestions after attachUiCallbacks with owner (warmup path)', async () => { + const onTopic = jest.fn(); + const owner = Symbol('ui'); + const session = new RealtimeSession({ + conversationId: 'conv-x', + queryClient: qc, + }); + + await session.connect(); + await new Promise((r) => setImmediate(r)); + + const ws = MockWebSocket.instances[0]!; + ws.simulateMessage({ + type: 'topic_suggestions', + conversation_id: 'conv-x', + data: { + reason: 'opening', + stage: 'childhood', + suggestions: [ + { + id: 'place', + label: 'where you grew up', + text: "I'd like to talk about where you grew up.", + }, + ], + }, + timestamp: new Date().toISOString(), + }); + + expect(onTopic).not.toHaveBeenCalled(); + + session.attachUiCallbacks( + { + onTopicSuggestions: onTopic, + onStreamingText: () => {}, + onTtsSegment: () => {}, + onError: () => {}, + onStateChange: () => {}, + }, + owner, + ); + + expect(onTopic).toHaveBeenCalledTimes(1); + expect(onTopic.mock.calls[0]![0].suggestions[0]!.id).toBe('place'); + + session.dispose(); + }); });