Ver Fonte

fix: 修复 A/B 组字幕泄漏 neutral 及情感标签解析问题

统一解析 [EMOTION:xxx] 与 LLM 裸写 emotion 词,发送字幕/TTS 前剥离标签,并强化 A/B 任务 prompt 格式要求。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu há 4 meses atrás
pai
commit
47d35d8fdc

BIN
backend.zip


+ 27 - 58
backend/ai_service.py

@@ -22,6 +22,7 @@ from http_pool import http_pool  # 🔥 使用连接池管理器
 from scenario_config import is_special_ab_experiment_user
 from prompt_constants import AB_TASK_EMOTION_APPENDIX
 from ab_experiment_media_config import resolve_llm_model, resolve_tts_params, TtsParams
+from emotion_text_utils import parse_emotion_from_text, strip_emotion_from_text
 
 logger = logging.getLogger(__name__)
 
@@ -393,64 +394,48 @@ Output ONLY the greeting line.
                 buffer += content
                 full_response += content
                 
-                # 🎯 优先检测情感标记(如果还没检测到)
-                if detected_emotion == "neutral" and "[EMOTION:" in full_response:
-                    emotion_start = full_response.find("[EMOTION:")
-                    emotion_end = full_response.find("]", emotion_start)
-                    if emotion_end > emotion_start:
-                        emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
-                        valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
-                        if emotion_str in valid_emotions:
-                            detected_emotion = emotion_str
-                            logger.info(f"[LLM情感检测] ✅ 检测到情感标签: {emotion_str}, 原始片段: {full_response[:200]}")
-                        else:
-                            # 🔥 未知情感词,默认映射为 neutral,但仍然移除标签
-                            logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral, 原始片段: {full_response[:200]}")
-                            detected_emotion = "neutral"
-                        
-                        # 🚀 立即 yield 空句子传递情感(减少延迟)
-                        if not emotion_yielded:
-                            yield ("", detected_emotion)  # 空句子,仅传递情感
-                            emotion_yielded = True
+                # 🎯 优先检测情感([EMOTION:xxx] 或首行裸词,大小写不敏感)
+                if not emotion_yielded:
+                    parsed = parse_emotion_from_text(full_response)
+                    if parsed:
+                        detected_emotion = parsed  # type: ignore[assignment]
+                        logger.info(
+                            f"[LLM情感检测] ✅ 检测到情感: {parsed}, 原始片段: {full_response[:200]}"
+                        )
+                        yield ("", detected_emotion)
+                        emotion_yielded = True
                 
-                # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
-                # 检测句子边界(完整的句子)
-                # 注意:buffer 中可能包含情感标签,但我们保留它
+                # 检测句子边界;yield 前剥离情感标签/裸词
                 if self._is_complete_sentence(buffer):
-                    sentence = buffer.strip()
+                    sentence = strip_emotion_from_text(buffer.strip())
+                    buffer = ""
+                    if not sentence:
+                        continue
                     sentence_count += 1
-                    # 使用已检测到的情感,但保留情感标签在文本中
                     yield (sentence, detected_emotion)
-                    buffer = ""
             
-            # ✅ v0.3: 保留情感标签在文本中(前端需要)
-            # 处理buffer中的剩余内容(已输出句子之后的部分)
-            remaining = buffer.strip()
+            remaining = strip_emotion_from_text(buffer.strip())
             if remaining:
                 sentence_count += 1
                 yield (remaining, detected_emotion)
             elif not remaining and sentence_count == 0:
-                # 如果一个句子都没输出,输出完整内容(保留情感标签)
-                if full_response.strip():
-                    yield (full_response.strip(), detected_emotion)
+                cleaned_full = strip_emotion_from_text(full_response.strip())
+                if cleaned_full:
+                    yield (cleaned_full, detected_emotion)
                 else:
-                    # 🔥 兜底逻辑:LLM没有返回任何文本(只返回了情感标记)
                     default_response = "I understand. Could you tell me more?"
                     yield (default_response, detected_emotion)
             
-            # 🔍 调试日志:记录LLM完整响应和情感检测结果
             logger.debug(
                 f"[LLM完整响应] 总chunk数={chunk_count}, "
                 f"检测到的情感={detected_emotion}, "
-                f"是否包含[EMOTION:={'[EMOTION:' in full_response}, "
                 f"完整响应长度={len(full_response)}, "
                 f"响应前500字符={full_response[:500]}"
             )
             
-            # ⚠️ 警告:如果没有检测到情感标签
-            if detected_emotion == "neutral" and "[EMOTION:" not in full_response:
+            if not parse_emotion_from_text(full_response):
                 logger.warning(
-                    f"[LLM情感检测] ⚠️ 未检测到情感标签,LLM可能没有生成。"
+                    f"[LLM情感检测] ⚠️ 未检测到情感标签,将使用默认 neutral。"
                     f"完整响应长度={len(full_response)}, "
                     f"响应前500字符={full_response[:500]}"
                 )
@@ -547,30 +532,14 @@ Output ONLY the greeting line.
             
             full_response = response.choices[0].message.content or ""
             
-            # 提取情感标签
-            emotion: EmotionType = "neutral"
-            response_text = full_response
-            
-            # 查找情感标签
-            if "[EMOTION:" in full_response:
-                emotion_start = full_response.find("[EMOTION:")
-                emotion_end = full_response.find("]", emotion_start)
-                if emotion_end > emotion_start:
-                    emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
-                    # 验证情感类型
-                    valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry", "gentle"]
-                    if emotion_str in valid_emotions:
-                        emotion = emotion_str
-                    else:
-                        # 🔥 未知情感词,默认映射为 neutral
-                        logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral")
-                    # 移除情感标签(不发送给TTS)
-                    response_text = full_response[:emotion_start].strip()
+            parsed = parse_emotion_from_text(full_response)
+            emotion: EmotionType = parsed or "neutral"  # type: ignore[assignment]
+            response_text = strip_emotion_from_text(full_response)
             
-            # 如果没有文本内容,使用默认回复
             if not response_text:
                 response_text = "I understand. Please tell me more!"
-                emotion = "neutral"
+                if not parsed:
+                    emotion = "neutral"
             
             return (response_text, emotion)
             

+ 63 - 0
backend/emotion_text_utils.py

@@ -0,0 +1,63 @@
+"""LLM 情感标签解析与展示文本清理(字幕 / TTS 共用)"""
+from __future__ import annotations
+
+import re
+from typing import Optional
+
+VALID_EMOTIONS = frozenset(
+    {
+        "neutral",
+        "happy",
+        "sad",
+        "excited",
+        "angry",
+        "surprised",
+        "thoughtful",
+        "encouraging",
+        "confused",
+        "worried",
+        "satisfied",
+        "friendly",
+        "sorry",
+    }
+)
+
+EMOTION_TAG_PATTERN = re.compile(r"\[EMOTION:\s*(\w+)\]", re.IGNORECASE)
+_EMOTION_WORDS_ALT = "|".join(re.escape(w) for w in sorted(VALID_EMOTIONS, key=len, reverse=True))
+_LEADING_PLAIN_EMOTION = re.compile(
+    rf"^\s*(?P<word>{_EMOTION_WORDS_ALT})(?:\s*[\r\n]+|\s+(?=\S)|\s*$)",
+    re.IGNORECASE,
+)
+
+
+def normalize_emotion(raw: str) -> str:
+    word = (raw or "").strip().lower()
+    return word if word in VALID_EMOTIONS else "neutral"
+
+
+def parse_emotion_from_text(text: str) -> Optional[str]:
+    """从 LLM 输出中解析情感(支持 [EMOTION:xxx] 及首行裸词)。"""
+    if not text:
+        return None
+    tag_match = EMOTION_TAG_PATTERN.search(text)
+    if tag_match:
+        return normalize_emotion(tag_match.group(1))
+    plain_match = _LEADING_PLAIN_EMOTION.match(text)
+    if plain_match:
+        return normalize_emotion(plain_match.group("word"))
+    return None
+
+
+def strip_emotion_from_text(text: str) -> str:
+    """移除情感标签/裸词,供字幕与 TTS 使用。"""
+    cleaned = EMOTION_TAG_PATTERN.sub("", text)
+    cleaned = _LEADING_PLAIN_EMOTION.sub("", cleaned, count=1)
+    cleaned = cleaned.strip()
+    if re.fullmatch(rf"(?:{_EMOTION_WORDS_ALT})", cleaned, re.IGNORECASE):
+        return ""
+    return cleaned
+
+
+def clean_text_for_display(text: str) -> str:
+    """字幕展示文本清理(emotion 已通过独立 WebSocket 消息下发)。"""
+    return strip_emotion_from_text(text)

+ 6 - 0
backend/prompt_constants.py

@@ -13,7 +13,13 @@ The FIRST line of every response MUST be:
 Allowed emotions:
 neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
 
+Example (follow this format exactly):
+[EMOTION:friendly]
+That sounds interesting! What do you like most about that course?
+
 Critical:
+- Use the [EMOTION:name] tag format ONLY — NEVER write the emotion name as plain text (wrong: "neutral" or "friendly" on its own line)
+- Prefer friendly, encouraging, or happy for warm/supportive replies; use neutral only when the tone is truly flat
 - NEVER return ONLY the emotion tag
 - ALWAYS include actual spoken text after the tag
 - The emotion tag MUST be the FIRST line, before any other content

+ 15 - 18
backend/websocket_handler.py

@@ -14,8 +14,10 @@ from starlette.websockets import WebSocketDisconnect, WebSocketState
 
 logger = logging.getLogger(__name__)
 
-# 预编译正则表达式(性能优化)
-EMOTION_TAG_PATTERN = re.compile(r'\[EMOTION:\w+\]')
+from emotion_text_utils import (
+    clean_text_for_display,
+    strip_emotion_from_text,
+)
 # 表情符号正则表达式(用于TTS前清理)
 EMOJI_PATTERN = re.compile(
     "["
@@ -785,28 +787,23 @@ class WebSocketHandler:
                     
                     sentence_count += 1
                     
-                    # ✅ v0.3: 保留情感标签在发送给前端的文本中
-                    # 发送给前端的文本(保留情感标签)
-                    text_for_frontend = sentence
+                    # 字幕展示文本:剥离 [EMOTION:xxx] 及 LLM 偶发的裸 emotion 词
+                    display_text = clean_text_for_display(sentence)
                     
-                    # 🧹 清理文本:移除情感标记、特殊字符和表情符号(仅用于TTS)
-                    clean_sentence = EMOTION_TAG_PATTERN.sub('', sentence)
+                    # 🧹 TTS 文本清理(括号/emoji 仅影响语音,不影响字幕)
+                    clean_sentence = strip_emotion_from_text(sentence)
                     clean_sentence = clean_sentence.translate(str.maketrans('', '', '()[]')).strip()
-                    clean_sentence = EMOJI_PATTERN.sub('', clean_sentence)  # 🔥 移除表情符号
+                    clean_sentence = EMOJI_PATTERN.sub('', clean_sentence)
                     
-                    # 如果清理后为空,跳过TTS
+                    # 如果清理后为空,跳过TTS(emotion 已通过 send_emotion 下发,无需空字幕)
                     if not clean_sentence:
-                        # 但如果有情感标签,仍然发送给前端
-                        if "[EMOTION:" in text_for_frontend:
-                            await self.send_text_result(
-                                request_id, text_for_frontend, is_final=False, source="assistant"
-                            )
                         continue
                     
-                    # 发送文本给前端(保留情感标签,source=assistant 用于字幕拼接)
-                    await self.send_text_result(
-                        request_id, text_for_frontend, is_final=False, source="assistant"
-                    )
+                    # 发送文本给前端(source=assistant 用于字幕拼接)
+                    if display_text:
+                        await self.send_text_result(
+                            request_id, display_text, is_final=False, source="assistant"
+                        )
                     
                     # 收集AI响应(用于日志记录)- 使用清理后的文本
                     self.ai_responses[request_id].append(clean_sentence)

+ 24 - 8
frontend/src/pages/ChatBotPage.tsx

@@ -659,16 +659,32 @@ export const ChatBotPage = () => {
   }, []);
 
   const resetFrequencyVisualization = useCallback(() => {}, []);
-  // 清理字幕文本中的情感前缀(例如 [EMOTION: happy] 你好 → 你好)
+  // 清理字幕文本中的情感前缀(例如 [EMOTION:happy] / neutral\nHello → Hello)
   const cleanSubtitleText = useCallback((text: string): string => {
     if (!text) return '';
-    // 1) 去掉开头的 [EMOTION:xxx] 或 [emotion:xxx](支持中英文冒号)
-    let cleaned = text.replace(/^\s*\[emotion[::][^\]]*]\s*/i, '');
-    // 2) 兼容早期格式:去掉开头的【...】或[...] 这一类简单情感标签
+    const emotionWords =
+      'neutral|happy|sad|excited|angry|surprised|thoughtful|encouraging|confused|worried|satisfied|friendly|sorry';
+    let cleaned = text;
+    // 1) 去掉任意位置的 [EMOTION:xxx](支持冒号后空格)
+    cleaned = cleaned.replace(/\[emotion\s*:\s*[^\]]*]/gi, '');
+    // 2) 去掉开头的裸 emotion 词(LLM 未按格式输出时)
+    cleaned = cleaned.replace(
+      new RegExp(`^\\s*(?:${emotionWords})(?:\\s*[\\r\\n]+|\\s+(?=\\S)|\\s*$)`, 'i'),
+      '',
+    );
+    // 3) 兼容早期格式:去掉开头的【...】或[...] 这一类简单情感标签
     cleaned = cleaned.replace(/^\s*[\[【][^[\]【】]{1,16}[\]】]\s*/, '');
-    return cleaned.trim();
+    cleaned = cleaned.trim();
+    // 整段仅为 emotion 词时不展示
+    if (new RegExp(`^(?:${emotionWords})$`, 'i').test(cleaned)) {
+      return '';
+    }
+    return cleaned;
   }, []);
-  // 同步字幕开关状态到 ref,便于在 AudioPlayer 回调中读取最新值
+  const appendSubtitleText = useCallback(
+    (prev: string, chunk: string): string => cleanSubtitleText(prev ? `${prev} ${chunk}` : chunk),
+    [cleanSubtitleText],
+  );
   useEffect(() => {
     subtitleOnRef.current = subtitleOn;
   }, [subtitleOn]);
@@ -2864,7 +2880,7 @@ export const ChatBotPage = () => {
               setRecognizedText(cleaned);
             } else {
               const prev = useAppStore.getState().recognizedText;
-              setRecognizedText(prev ? `${prev} ${cleaned}` : cleaned);
+              setRecognizedText(appendSubtitleText(prev, cleaned));
             }
           }
           break;
@@ -2886,7 +2902,7 @@ export const ChatBotPage = () => {
           setRecognizedText(cleaned);
         } else {
           const prev = useAppStore.getState().recognizedText;
-          setRecognizedText(prev ? `${prev} ${cleaned}` : cleaned);
+          setRecognizedText(appendSubtitleText(prev, cleaned));
         }
         break;
       }