Kaynağa Gözat

fix: 剥离粘连情感词并强化 A/B 组情感 prompt

支持 friendlyHi 等 LLM 非标准输出,避免 TTS 朗读情感词;对齐普通用户情感规则并为 A/B 组追加每轮格式提醒。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 4 ay önce
ebeveyn
işleme
ff821ee307

+ 5 - 2
backend/ai_service.py

@@ -20,7 +20,7 @@ from config import (
 from models import EmotionType
 from http_pool import http_pool  # 🔥 使用连接池管理器
 from scenario_config import is_special_ab_experiment_user
-from prompt_constants import AB_TASK_EMOTION_APPENDIX
+from prompt_constants import AB_TASK_EMOTION_APPENDIX, EMOTION_TURN_REMINDER
 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
 
@@ -311,7 +311,10 @@ Output ONLY the greeting line.
             if chat_history:
                 messages.extend(chat_history)
             
-            # 情感标签规则已写在 _get_system_prompt_base 中,不再追加重复 system 条以节省 token
+            # 普通用户:情感规则已在 _get_system_prompt_base 末尾,不再每轮追加以省 token
+            # A/B 组:任务 prompt 较长,额外每轮提醒格式(与普通用户文档设计一致)
+            if is_special_ab_experiment_user(user_info):
+                messages.append({"role": "system", "content": EMOTION_TURN_REMINDER})
             
             # 添加当前用户输入
             messages.append({"role": "user", "content": user_text})

+ 34 - 10
backend/emotion_text_utils.py

@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 import re
-from typing import Optional
+from typing import Optional, Tuple
 
 VALID_EMOTIONS = frozenset(
     {
@@ -24,10 +24,7 @@ VALID_EMOTIONS = frozenset(
 
 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,
-)
+_EMOTION_WORD_HEAD = re.compile(rf"^\s*(?P<word>{_EMOTION_WORDS_ALT})", re.IGNORECASE)
 
 
 def normalize_emotion(raw: str) -> str:
@@ -35,6 +32,33 @@ def normalize_emotion(raw: str) -> str:
     return word if word in VALID_EMOTIONS else "neutral"
 
 
+def _split_plain_emotion_metadata(text: str) -> Tuple[Optional[str], Optional[str]]:
+    """
+    若开头为裸 emotion 元数据,返回 (emotion, 正文);否则 (None, None)。
+    元数据形态:单独一行 / 空格+大写句首 / 粘连大写句首(如 friendlyHi)。
+    不将「happy to see you」等正常英文句首当作元数据。
+    """
+    m = _EMOTION_WORD_HEAD.match(text)
+    if not m:
+        return None, None
+    word = m.group("word")
+    if word.lower() not in VALID_EMOTIONS:
+        return None, None
+    rest = text[m.end() :]
+    if not rest:
+        return normalize_emotion(word), ""
+    if rest[0] in "\r\n":
+        return normalize_emotion(word), rest.lstrip()
+    if rest[0] == " ":
+        body = rest.lstrip()
+        if body and body[0].isascii() and body[0].isupper():
+            return normalize_emotion(word), body
+        return None, None
+    if rest[0].isascii() and rest[0].isupper():
+        return normalize_emotion(word), rest
+    return None, None
+
+
 def parse_emotion_from_text(text: str) -> Optional[str]:
     """从 LLM 输出中解析情感(支持 [EMOTION:xxx] 及首行裸词)。"""
     if not text:
@@ -42,16 +66,16 @@ def parse_emotion_from_text(text: str) -> Optional[str]:
     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
+    emotion, _ = _split_plain_emotion_metadata(text)
+    return emotion
 
 
 def strip_emotion_from_text(text: str) -> str:
     """移除情感标签/裸词,供字幕与 TTS 使用。"""
     cleaned = EMOTION_TAG_PATTERN.sub("", text)
-    cleaned = _LEADING_PLAIN_EMOTION.sub("", cleaned, count=1)
+    _, body = _split_plain_emotion_metadata(cleaned)
+    if body is not None:
+        cleaned = body
     cleaned = cleaned.strip()
     if re.fullmatch(rf"(?:{_EMOTION_WORDS_ALT})", cleaned, re.IGNORECASE):
         return ""

+ 27 - 9
backend/prompt_constants.py

@@ -1,9 +1,9 @@
 """
-LLM 提示词片段:A/B 任务 prompt 需追加的系统规则(与现有 TTS/前端情感链路兼容)
+LLM 提示词片段:情感标签规则(普通用户 base prompt 与 A/B 任务 prompt 共用语义)
 """
 
-# 追加到任务表 prompt 末尾,保证流式回复仍输出 [EMOTION:xxx]
-AB_TASK_EMOTION_APPENDIX = """
+# 与普通用户 _get_system_prompt_base 末尾 EMOTION 段对齐的核心规则
+EMOTION_TAG_RULES = """
 ━━━━━━━━━━━━━━━━
 EMOTION TAG — REQUIRED
 ━━━━━━━━━━━━━━━━
@@ -13,16 +13,34 @@ 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?
+Choose based on:
+- User's emotion (sad → sorry/encouraging, happy → excited/happy, worried → encouraging)
+- Your tone (warm/supportive → friendly or encouraging; empathetic → thoughtful or sorry)
+
+Examples (follow this format exactly):
+[EMOTION:happy]
+Hello! I'm so glad to see you. How are you feeling today?
+
+[EMOTION:encouraging]
+That sounds like a good start. What made you interested in 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
+- The tag is METADATA ONLY — it controls Lumi's expression and voice tone
+- NEVER speak or write the emotion name as plain text (wrong: "neutral" or "friendly" on its own line)
+- Use [EMOTION:name] format ONLY — never output bare emotion words before your reply
 - NEVER return ONLY the emotion tag
-- ALWAYS include actual spoken text after the tag
+- ALWAYS include actual spoken English after the tag (1–3 short sentences)
 - The emotion tag MUST be the FIRST line, before any other content
 - NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
 - NO emojis or emoticons in your response
+- Prefer friendly, encouraging, or happy for warm/supportive replies; use neutral only when the tone is truly flat
 """.strip()
+
+# 追加到 A/B 任务表 prompt 末尾(长任务 prompt 后强化情感格式,避免被忽略)
+AB_TASK_EMOTION_APPENDIX = EMOTION_TAG_RULES
+
+# 每轮对话注入一条 system 提醒(文档中普通用户曾使用;A/B 实验 LLM 单独启用)
+EMOTION_TURN_REMINDER = (
+    "Remember: Line 1 MUST be [EMOTION:name] only (metadata, not spoken). "
+    "Line 2+ is spoken English. Never write bare emotion words like 'neutral' or 'friendly'."
+)

+ 6 - 2
frontend/src/pages/ChatBotPage.tsx

@@ -667,9 +667,13 @@ export const ChatBotPage = () => {
     let cleaned = text;
     // 1) 去掉任意位置的 [EMOTION:xxx](支持冒号后空格)
     cleaned = cleaned.replace(/\[emotion\s*:\s*[^\]]*]/gi, '');
-    // 2) 去掉开头的裸 emotion 词(LLM 未按格式输出时)
+    // 2) 去掉开头的裸 emotion 元数据(含粘连大写句首:friendlyHi → Hi)
     cleaned = cleaned.replace(
-      new RegExp(`^\\s*(?:${emotionWords})(?:\\s*[\\r\\n]+|\\s+(?=\\S)|\\s*$)`, 'i'),
+      new RegExp(`^\\s*(?:${emotionWords})(?=[A-Z])`),
+      '',
+    );
+    cleaned = cleaned.replace(
+      new RegExp(`^\\s*(?:${emotionWords})(?:\\s*[\\r\\n]+|\\s+(?=[A-Z])|\\s*$)`, 'i'),
       '',
     );
     // 3) 兼容早期格式:去掉开头的【...】或[...] 这一类简单情感标签