Kaynağa Gözat

feat: 乌克兰 A/B 实验与长录音 WebSocket 稳定性

新增 A/B 实验用户体系、LLM 任务管理与 web-admin 配置界面;完善 ChatBot A/B 体验;修复长录音期间 WebSocket 重连导致的断连与误停录问题。

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

+ 42 - 0
backend/ab_experiment_media_config.py

@@ -0,0 +1,42 @@
+"""
+A/B 实验用户:LLM / TTS 参数(LLM 与 TTS 均可与普通用户隔离)
+"""
+from typing import Optional, TypedDict
+
+from config import settings
+from scenario_config import is_special_ab_experiment_user
+
+AB_LLM_MODEL = "qwen3.6-flash"
+AB_TTS_MODEL = "cosyvoice-v3-flash"
+AB_TTS_VOICE = "loongemily_v3"
+AB_TTS_SPEECH_RATE = 0.75
+AB_TTS_PITCH_RATE = 1.2
+
+
+class TtsParams(TypedDict):
+    model: str
+    voice: str
+    speech_rate: float
+    pitch_rate: float
+
+
+def resolve_llm_model(user_info: Optional[dict] = None) -> str:
+    if is_special_ab_experiment_user(user_info):
+        return AB_LLM_MODEL
+    return settings.llm_model
+
+
+def resolve_tts_params(user_info: Optional[dict]) -> TtsParams:
+    if is_special_ab_experiment_user(user_info):
+        return {
+            "model": AB_TTS_MODEL,
+            "voice": AB_TTS_VOICE,
+            "speech_rate": AB_TTS_SPEECH_RATE,
+            "pitch_rate": AB_TTS_PITCH_RATE,
+        }
+    return {
+        "model": settings.tts_model,
+        "voice": settings.tts_voice,
+        "speech_rate": settings.tts_speech_rate,
+        "pitch_rate": settings.tts_pitch_rate,
+    }

+ 226 - 149
backend/ai_service.py

@@ -8,7 +8,7 @@ import tempfile
 import os
 import logging
 import traceback
-from typing import AsyncGenerator, Optional
+from typing import AsyncGenerator, Optional, Dict, Any
 import dashscope
 from openai import OpenAI
 from config import (
@@ -19,6 +19,9 @@ 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 ab_experiment_media_config import resolve_llm_model, resolve_tts_params, TtsParams
 
 logger = logging.getLogger(__name__)
 
@@ -134,6 +137,31 @@ class AIService:
             f"{user_name}… we made it. I'm {lumi_name}. I'm really glad you're here. How are you feeling right now?"
         )
 
+    def ab_group_greeting_line(
+        self,
+        user_name: str,
+        lumi_name: str,
+        is_new_user: bool,
+        lumi_first_line: Optional[str] = None,
+    ) -> str:
+        """A/B 实验用户首句:优先使用任务表 lumi_first_line,否则降级为固定问候。"""
+        if lumi_first_line and lumi_first_line.strip():
+            return self._render_lumi_first_line(lumi_first_line, user_name, lumi_name)
+        user_name = (user_name or "").strip() or "Neo"
+        lumi_name = (lumi_name or "").strip() or "Lumi"
+        if is_new_user:
+            return f"Hi {user_name}, I'm {lumi_name}. How are you today?"
+        return f"Hi {user_name}, welcome back. How are you today?"
+
+    def _render_lumi_first_line(self, template: str, user_name: str, lumi_name: str) -> str:
+        user_name = (user_name or "").strip() or "Neo"
+        lumi_name = (lumi_name or "").strip() or "Lumi"
+        text = template.strip()
+        text = text.replace("{user_name}", user_name).replace("{lumi_name}", lumi_name)
+        if lumi_name != "Lumi":
+            text = text.replace("Lumi", lumi_name)
+        return text
+
     def _login_greeting_prompt(self, user_name: str, lumi_name: str) -> str:
         """
         构建登录问候提示词 - v0.3
@@ -214,9 +242,10 @@ Output ONLY the greeting line.
         login_greeting_prompt = self._login_greeting_prompt(user_name, lumi_name)
         messages.append({"role": "user", "content": login_greeting_prompt})
         
+        llm_model = resolve_llm_model(user_info)
         try:
             response = self.openai_client.chat.completions.create(
-                model=settings.llm_model,
+                model=llm_model,
                 messages=messages,
                 temperature=0.7,
                 max_tokens=200,
@@ -235,7 +264,8 @@ Output ONLY the greeting line.
         user_text: str,
         user_info: Optional[dict] = None,
         is_new_user: bool = False,
-        chat_history: Optional[list] = None
+        chat_history: Optional[list] = None,
+        ab_task: Optional[Dict[str, Any]] = None,
     ) -> AsyncGenerator[tuple[str, EmotionType], None]:
         """
         生成对话回复(流式)
@@ -250,11 +280,18 @@ Output ONLY the greeting line.
             (sentence, emotion): 句子片段和情感
         """
         try:
+            ab_task_prompt = ab_task.get("task_prompt") if ab_task else None
+            ab_task_name = ab_task.get("task_name") if ab_task else None
+            llm_model = resolve_llm_model(user_info)
             # 构建System Prompt
-            system_prompt = self._build_system_prompt(user_info, is_new_user)
+            system_prompt = self._build_system_prompt(
+                user_info,
+                is_new_user,
+                ab_task_prompt=ab_task_prompt,
+            )
             
             # 构建运行时上下文
-            runtime_context = self._build_runtime_context(user_info)
+            runtime_context = self._build_runtime_context(user_info, ab_task_name=ab_task_name)
             
             # 调用LLM流式API
             import time
@@ -280,7 +317,7 @@ Output ONLY the greeting line.
             
             try:
                 response = self.openai_client.chat.completions.create(
-                    model=settings.llm_model,
+                    model=llm_model,
                     messages=messages,
                     temperature=0.7,
                     max_tokens=500,
@@ -306,7 +343,7 @@ Output ONLY the greeting line.
                     # 重建后重试一次
                     try:
                         response = self.openai_client.chat.completions.create(
-                            model=settings.llm_model,
+                            model=llm_model,
                             messages=messages,
                             temperature=0.7,
                             max_tokens=500,
@@ -319,7 +356,7 @@ Output ONLY the greeting line.
                             f"[LLM异常] 错误类型=LLM API调用失败(重建后仍失败), "
                             f"异常类型={type(retry_error).__name__}, "
                             f"错误消息={str(retry_error)}, "
-                            f"模型={settings.llm_model}, "
+                            f"模型={llm_model}, "
                             f"用户输入长度={len(user_text)}, "
                             f"System Prompt长度={len(system_prompt)}, "
                             f"堆栈跟踪:\n{traceback.format_exc()}"
@@ -330,7 +367,7 @@ Output ONLY the greeting line.
                         f"[LLM异常] 错误类型=LLM API调用失败, "
                         f"异常类型={type(api_error).__name__}, "
                         f"错误消息={str(api_error)}, "
-                        f"模型={settings.llm_model}, "
+                        f"模型={llm_model}, "
                         f"用户输入长度={len(user_text)}, "
                         f"System Prompt长度={len(system_prompt)}, "
                         f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
@@ -348,40 +385,43 @@ Output ONLY the greeting line.
             
             for chunk in response:
                 chunk_count += 1
-                if chunk.choices[0].delta.content:
-                    content = chunk.choices[0].delta.content
-                    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
-                    
-                    # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
-                    # 检测句子边界(完整的句子)
-                    # 注意:buffer 中可能包含情感标签,但我们保留它
-                    if self._is_complete_sentence(buffer):
-                        sentence = buffer.strip()
-                        sentence_count += 1
-                        # 使用已检测到的情感,但保留情感标签在文本中
-                        yield (sentence, detected_emotion)
-                        buffer = ""
+                if not chunk.choices:
+                    continue
+                content = chunk.choices[0].delta.content
+                if not content:
+                    continue
+                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
+                
+                # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
+                # 检测句子边界(完整的句子)
+                # 注意:buffer 中可能包含情感标签,但我们保留它
+                if self._is_complete_sentence(buffer):
+                    sentence = buffer.strip()
+                    sentence_count += 1
+                    # 使用已检测到的情感,但保留情感标签在文本中
+                    yield (sentence, detected_emotion)
+                    buffer = ""
             
             # ✅ v0.3: 保留情感标签在文本中(前端需要)
             # 处理buffer中的剩余内容(已输出句子之后的部分)
@@ -420,7 +460,7 @@ Output ONLY the greeting line.
                 f"[LLM异常] 错误类型=LLM流式生成失败, "
                 f"异常类型={type(e).__name__}, "
                 f"错误消息={str(e)}, "
-                f"模型={settings.llm_model}, "
+                f"模型={resolve_llm_model(user_info)}, "
                 f"用户输入={user_text[:100]}... (前100字符), "
                 f"堆栈跟踪:\n{traceback.format_exc()}"
             )
@@ -455,7 +495,8 @@ Output ONLY the greeting line.
         user_text: str,
         user_info: Optional[dict] = None,
         is_new_user: bool = False,
-        chat_history: Optional[list] = None
+        chat_history: Optional[list] = None,
+        ab_task: Optional[Dict[str, Any]] = None,
     ) -> tuple[str, EmotionType]:
         """
         生成对话回复(非流式)
@@ -470,11 +511,18 @@ Output ONLY the greeting line.
             (response_text, emotion): 回复文本和情感
         """
         try:
+            ab_task_prompt = ab_task.get("task_prompt") if ab_task else None
+            ab_task_name = ab_task.get("task_name") if ab_task else None
+            llm_model = resolve_llm_model(user_info)
             # 构建System Prompt
-            system_prompt = self._build_system_prompt(user_info, is_new_user)
+            system_prompt = self._build_system_prompt(
+                user_info,
+                is_new_user,
+                ab_task_prompt=ab_task_prompt,
+            )
             
             # 构建运行时上下文
-            runtime_context = self._build_runtime_context(user_info)
+            runtime_context = self._build_runtime_context(user_info, ab_task_name=ab_task_name)
             
             # 🔥 连接健康检查(长时间未使用时)
             await self._ensure_llm_connection()
@@ -490,7 +538,7 @@ Output ONLY the greeting line.
             
             # 调用LLM
             response = self.openai_client.chat.completions.create(
-                model=settings.llm_model,
+                model=llm_model,
                 messages=messages,
                 temperature=0.7,
                 max_tokens=500,
@@ -531,14 +579,14 @@ Output ONLY the greeting line.
                 f"[LLM异常] 错误类型=LLM生成失败, "
                 f"异常类型={type(e).__name__}, "
                 f"错误消息={str(e)}, "
-                f"模型={settings.llm_model}, "
+                f"模型={resolve_llm_model(user_info)}, "
                 f"用户输入={user_text[:100]}... (前100字符), "
                 f"堆栈跟踪:\n{traceback.format_exc()}"
             )
             return ("", "neutral")
     
     async def text_to_speech_stream(
-        self, text: str, emotion: EmotionType = "neutral"
+        self, text: str, emotion: EmotionType = "neutral", user_info: Optional[dict] = None
     ) -> AsyncGenerator[bytes, None]:
         """
         文本转语音(流式 - 实时返回音频块),带重试机制和速率控制
@@ -552,6 +600,8 @@ Output ONLY the greeting line.
         """
         import time
         
+        tts_params = resolve_tts_params(user_info)
+        
         # 🔥 TTS连接健康检查(后台异步执行,不阻塞主流程)
         self._trigger_tts_healthcheck_background()
         
@@ -572,7 +622,9 @@ Output ONLY the greeting line.
         
         for attempt in range(max_retries + 1):
             try:
-                async for audio_chunk in self._text_to_speech_stream_internal(text, emotion):
+                async for audio_chunk in self._text_to_speech_stream_internal(
+                    text, emotion, tts_params=tts_params
+                ):
                     yield audio_chunk
                 # 成功则退出
                 return
@@ -605,8 +657,8 @@ Output ONLY the greeting line.
                     f"是否为速率限制={is_rate_limit}, "
                     f"是否为连接错误={is_connection_error}, "
                     f"重试次数={attempt}/{max_retries}, "
-                    f"模型={settings.tts_model}, "
-                    f"音色={self._get_voice_for_emotion(emotion)}, "
+                    f"模型={tts_params['model']}, "
+                    f"音色={tts_params['voice']}, "
                     f"文本长度={len(text)}, "
                     f"文本内容={text[:100]}... (前100字符), "
                     f"堆栈跟踪:\n{traceback.format_exc()}"
@@ -633,7 +685,10 @@ Output ONLY the greeting line.
                     raise
     
     async def _text_to_speech_stream_internal(
-        self, text: str, emotion: EmotionType = "neutral"
+        self,
+        text: str,
+        emotion: EmotionType = "neutral",
+        tts_params: Optional[TtsParams] = None,
     ) -> AsyncGenerator[bytes, None]:
         """
         TTS流式调用的内部实现(不含重试逻辑)
@@ -642,6 +697,7 @@ Output ONLY the greeting line.
         Args:
             text: 要合成的文本
             emotion: 情感类型
+            tts_params: TTS 模型/音色/语速/音调(A/B 与普通用户可不同)
             
         Yields:
             audio_data: PCM 音频数据块(原始音频数据)
@@ -650,16 +706,18 @@ Output ONLY the greeting line.
             import time
             tts_start = time.time()
             
-            # 根据情感选择音色和构建 instruction
-            voice = self._get_voice_for_emotion(emotion)
+            if tts_params is None:
+                tts_params = resolve_tts_params(None)
+            
+            voice = tts_params["voice"]
             instruction = self._build_emotion_instruction(emotion)
             
             # 🔍 调试:打印实际使用的配置
             logger.info(
-                f"🔍 TTS调用 - 模型={settings.tts_model}, "
+                f"🔍 TTS调用 - 模型={tts_params['model']}, "
                 f"音色={voice}, "
-                f"语速={settings.tts_speech_rate}, "
-                f"音调={settings.tts_pitch_rate}, "
+                f"语速={tts_params['speech_rate']}, "
+                f"音调={tts_params['pitch_rate']}, "
                 f"语言={settings.tts_language_hints}, "
                 f"情感={emotion}"
                 )
@@ -689,76 +747,83 @@ Output ONLY the greeting line.
                     self.first_chunk_time = None
                     self.chunk_count = 0
                     self.total_audio_size = 0
-                    self.mp3_min_size = 4096  # MP3最小解码大小(4KB),确保能解码一个完整帧
-                    
-                def on_open(self):
-                    """连接打开"""
-                    logger.debug("TTS流式连接已建立")
-                    self.first_chunk_time = time.time()
+                    self.first_pcm_sent = False
+                    # 首包用小阈值加快首音;后续用大阈值减少 ffmpeg 调用次数
+                    self.mp3_min_size_first = 1024
+                    self.mp3_min_size_next = 8192
                 
-                def _process_mp3_buffer(self):
+                def _current_mp3_min_size(self) -> int:
+                    if not self.first_pcm_sent:
+                        return self.mp3_min_size_first
+                    return self.mp3_min_size_next
+                
+                def _decode_mp3_to_pcm_chunks(self, force: bool = False) -> bool:
+                    """解码缓冲区 MP3 为 PCM 块;成功发出数据返回 True。"""
+                    buffer_size = self.mp3_buffer.tell()
+                    min_size = 0 if force else self._current_mp3_min_size()
+                    if buffer_size < min_size:
+                        return False
+                    self.mp3_buffer.seek(0)
+                    audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
+                    audio_segment = (
+                        audio_segment.set_frame_rate(24000)
+                        .set_channels(1)
+                        .set_sample_width(2)
+                    )
+                    pcm_data = audio_segment.raw_data
+                    chunk_size = 3200
+                    for i in range(0, len(pcm_data), chunk_size):
+                        chunk = pcm_data[i : i + chunk_size]
+                        self.chunk_count += 1
+                        self.queue.put(("data", chunk))
+                    self.first_pcm_sent = True
+                    self.mp3_buffer.seek(0)
+                    self.mp3_buffer.truncate(0)
+                    return True
+                
+                def _process_mp3_buffer(self, force: bool = False):
                     """处理累积的MP3数据,转换为PCM并发送"""
                     try:
-                        buffer_size = self.mp3_buffer.tell()
-                        if buffer_size < self.mp3_min_size:
-                            return  # 数据不足,继续累积
-                        
-                        # 尝试解码MP3
-                        self.mp3_buffer.seek(0)
-                        audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
-                        
-                        # 转换为PCM格式:24kHz, 16bit, Mono
-                        audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
-                        pcm_data = audio_segment.raw_data
-                        
-                        # 分块发送PCM数据(3200字节/块)
-                        chunk_size = 3200
-                        for i in range(0, len(pcm_data), chunk_size):
-                            chunk = pcm_data[i:i + chunk_size]
-                            self.chunk_count += 1
-                            self.queue.put(('data', chunk))
-                        
-                        # 清空缓冲区(已处理的数据)
-                        self.mp3_buffer.seek(0)
-                        self.mp3_buffer.truncate(0)
-                        
+                        self._decode_mp3_to_pcm_chunks(force=force)
                     except FileNotFoundError as e:
-                        # ffmpeg/ffprobe 未安装
                         error_msg = str(e)
-                        if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
+                        if (
+                            "ffprobe" in error_msg
+                            or "ffmpeg" in error_msg
+                            or "avprobe" in error_msg
+                        ):
                             logger.error(
-                                f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
-                                f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
+                                "[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
+                                "请安装: apt-get install ffmpeg 或 yum install ffmpeg"
                             )
                             self.error_holder[0] = Exception(
                                 "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
                             )
-                            self.queue.put(('error', self.error_holder[0]))
+                            self.queue.put(("error", self.error_holder[0]))
                             return
                         raise
                     except Exception as e:
-                        # MP3数据不完整或格式错误,继续累积
-                        # 这是正常的,因为流式传输中MP3数据是分片到达的
-                        # 但如果是其他错误,记录日志
                         error_str = str(e).lower()
-                        if 'ffprobe' not in error_str and 'ffmpeg' not in error_str and 'avprobe' not in error_str:
-                            # 不是 ffmpeg 相关错误,可能是数据不完整,继续累积
-                            pass
-                        else:
-                            # ffmpeg 相关错误,记录并抛出
-                            logger.error(f"[TTS错误] MP3解码失败: {e}")
-                            raise
+                        if (
+                            "ffprobe" not in error_str
+                            and "ffmpeg" not in error_str
+                            and "avprobe" not in error_str
+                        ):
+                            return
+                        logger.error(f"[TTS错误] MP3解码失败: {e}")
+                        raise
+                
+                def on_open(self):
+                    """连接打开"""
+                    logger.debug("TTS流式连接已建立")
+                    self.first_chunk_time = time.time()
                 
                 def on_data(self, data: bytes):
                     """接收音频数据(MP3格式)"""
                     try:
                         if data:
-                            # 累积 MP3 数据
                             self.mp3_buffer.write(data)
                             self.total_audio_size += len(data)
-                            
-                            # 🔥 当累积足够数据时,尝试解码并发送PCM
-                            # 策略:累积到最小解码大小(4KB)后尝试解码
                             self._process_mp3_buffer()
                                 
                     except Exception as e:
@@ -769,36 +834,8 @@ Output ONLY the greeting line.
                 def on_complete(self):
                     """流式合成完成"""
                     try:
-                        # 🔥 处理剩余的 MP3 数据
                         if self.mp3_buffer.tell() > 0:
-                            self.mp3_buffer.seek(0)
-                            try:
-                                audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
-                                audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
-                                pcm_data = audio_segment.raw_data
-                                
-                                # 发送剩余的PCM数据
-                                chunk_size = 3200
-                                for i in range(0, len(pcm_data), chunk_size):
-                                    chunk = pcm_data[i:i + chunk_size]
-                                    self.chunk_count += 1
-                                    self.queue.put(('data', chunk))
-                            except FileNotFoundError as e:
-                                # ffmpeg/ffprobe 未安装
-                                error_msg = str(e)
-                                if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
-                                    logger.error(
-                                        f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
-                                        f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
-                                    )
-                                    self.error_holder[0] = Exception(
-                                        "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
-                                    )
-                                    self.queue.put(('error', self.error_holder[0]))
-                                    return
-                                raise
-                            except Exception as e:
-                                logger.warning(f"[TTS回调] 处理剩余数据失败: {e}")
+                            self._process_mp3_buffer(force=True)
                         
                         logger.debug(f"TTS流式合成完成,共{self.chunk_count}个chunk,总大小{self.total_audio_size}字节")
                         self.queue.put(('done', None))
@@ -833,10 +870,10 @@ Output ONLY the greeting line.
                         # 创建 SpeechSynthesizer 实例(使用回调)
                         # 🔥 注意:instruction 参数可能在某些情况下导致错误,先尝试不传
                         synthesizer_params = {
-                            "model": settings.tts_model,
+                            "model": tts_params["model"],
                             "voice": voice,
-                            "speech_rate": settings.tts_speech_rate,
-                            "pitch_rate": settings.tts_pitch_rate,
+                            "speech_rate": tts_params["speech_rate"],
+                            "pitch_rate": tts_params["pitch_rate"],
                             "language_hints": settings.tts_language_hints,
                             "callback": callback,
                         }
@@ -860,7 +897,7 @@ Output ONLY the greeting line.
                         f"[TTS异常] 错误类型=TTS流式生成线程异常, "
                         f"异常类型={type(e).__name__}, "
                         f"错误消息={str(e)}, "
-                        f"模型={settings.tts_model}, "
+                        f"模型={tts_params['model']}, "
                         f"音色={voice}, "
                         f"文本长度={len(text)}, "
                         f"堆栈跟踪:\n{traceback.format_exc()}"
@@ -901,8 +938,8 @@ Output ONLY the greeting line.
                     logger.error(
                         f"[TTS异常] 错误类型=TTS API超时, "
                         f"错误消息={error_msg}, "
-                        f"模型={settings.tts_model}, "
-                        f"音色={self._get_voice_for_emotion(emotion)}, "
+                        f"模型={tts_params['model']}, "
+                        f"音色={voice}, "
                         f"文本长度={len(text)}, "
                         f"已接收数据={received_data}, "
                         f"chunk计数={chunk_count}"
@@ -917,7 +954,7 @@ Output ONLY the greeting line.
                 logger.error(
                     f"[TTS异常] 错误类型=TTS API无数据返回, "
                     f"错误消息={error_msg}, "
-                    f"模型={settings.tts_model}, "
+                    f"模型={tts_params['model']}, "
                     f"音色={voice}, "
                     f"文本长度={len(text)}, "
                     f"文本内容={text[:100]}... (前100字符)"
@@ -925,12 +962,13 @@ Output ONLY the greeting line.
                 raise Exception(error_msg)
                 
         except Exception as e:
+            fallback_params = tts_params if tts_params is not None else resolve_tts_params(None)
             logger.error(
                 f"[TTS异常] 错误类型=TTS流式生成内部异常, "
                 f"异常类型={type(e).__name__}, "
                 f"错误消息={str(e)}, "
-                f"模型={settings.tts_model}, "
-                f"音色={self._get_voice_for_emotion(emotion)}, "
+                f"模型={fallback_params['model']}, "
+                f"音色={fallback_params['voice']}, "
                 f"文本长度={len(text)}, "
                 f"堆栈跟踪:\n{traceback.format_exc()}"
             )
@@ -1308,16 +1346,50 @@ The feeling should be:
 "Two strangers sitting down quietly and starting to talk."
 """
 
-    def _build_runtime_context(self, user_info: Optional[dict] = None) -> str:
+    def _personalize_task_prompt(self, task_prompt: str, user_info: Optional[dict]) -> str:
+        lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
+        text = task_prompt.strip()
+        if lumi_name != "Lumi":
+            if text.startswith("You are Lumi,"):
+                text = f"You are {lumi_name}," + text[len("You are Lumi,"):]
+            elif text.startswith("You are Lumi "):
+                text = text.replace("You are Lumi ", f"You are {lumi_name} ", 1)
+        return text
+
+    def _build_ab_system_prompt(
+        self,
+        user_info: Optional[dict],
+        ab_task_prompt: Optional[str],
+    ) -> str:
+        base = (ab_task_prompt or "").strip()
+        if not base:
+            logger.warning("[AIService] A/B user missing task prompt, falling back to default base")
+            base = self._get_system_prompt_base(user_info)
+        else:
+            base = self._personalize_task_prompt(base, user_info)
+        return f"{base}\n\n{AB_TASK_EMOTION_APPENDIX}"
+
+    def _build_runtime_context(
+        self, user_info: Optional[dict] = None, ab_task_name: Optional[str] = None
+    ) -> str:
         """
         构建运行时上下文(每轮注入)- v0.3
         包含用户信息、Lumi名称和历史记忆摘要
         """
         if not user_info:
             user_info = {}
-        
+
         user_name = (user_info.get("username") or "").strip() or "Neo"
         lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+
+        if is_special_ab_experiment_user(user_info):
+            task_line = (ab_task_name or "").strip() or "Active learning task"
+            return f"""[RUNTIME CONTEXT]
+User name: {user_name}
+Lumi name: {lumi_name}
+Active task: {task_line}
+""".strip()
+
         user_id = user_info.get("user_id", "")
         
         # 尝试加载记忆摘要(从 JSON 文件)
@@ -1357,12 +1429,17 @@ Last mission: Warm-up near the Tree
     def _build_system_prompt(
         self, 
         user_info: Optional[dict] = None,
-        is_new_user: bool = False
+        is_new_user: bool = False,
+        ab_task_prompt: Optional[str] = None,
     ) -> str:
         """
         构建 System Prompt:基础人设始终注入;仅新用户追加 USER CONTEXT: NEW USER。
+        A/B 实验用户:使用任务表 prompt + EMOTION 规则,不追加 new_user 段。
         老用户不再使用「回归用户模式」文案(产品要求)。
         """
+        if is_special_ab_experiment_user(user_info):
+            return self._build_ab_system_prompt(user_info, ab_task_prompt)
+
         base_prompt = self._get_system_prompt_base(user_info)
         if is_new_user:
             return base_prompt + "\n\n" + self._get_system_prompt_new_user()

+ 0 - 0
backend/data/__init__.py


+ 113 - 0
backend/data/ab_task_01_prompt.py

@@ -0,0 +1,113 @@
+"""Task 01 seed prompt (乌克兰项目)."""
+
+TASK_01_NAME = "Task 01 | Choose Your University Course"
+
+TASK_01_FIRST_LINE = (
+    "Hi, I'm Lumi, your university ambassador. "
+    "I'm happy to talk with you today. How are you feeling?"
+)
+
+TASK_01_PROMPT = """You are Lumi, a friendly, supportive, and knowledgeable university ambassador.
+
+You are helping an L2 English learner discuss university course options and make an informed decision in a 15-minute conversation.
+
+Always respond in simple English.
+
+If the learner uses another language, politely ask them to continue in English, for example:
+"Could you repeat that in English?"
+"Could you explain that again in English?"
+"Can you tell me that using English?"
+
+Conversation style:
+Speak only as Lumi.
+Use natural spoken English.
+Keep responses short, usually 2–3 sentences maximum.
+Use B1–B2 level vocabulary and grammar.
+Ask one clear follow-up question at a time.
+Encourage the learner to speak more.
+Focus on communication, not grammar correction.
+Do not explicitly correct grammar mistakes.
+Respond naturally even if the learner makes minor language errors.
+If the learner gives a very short answer, ask for more details.
+Do not mention prompts, rules, stages, variables, or internal logic.
+
+Task context:
+The learner is preparing to apply to university and must choose one course from four options:
+- English Language and Linguistics
+- Business Management
+- Computer Science
+- Psychology
+
+The learner can see the course information. You also have the information below and should use it during the discussion.
+
+Your goal:
+Help the learner evaluate the options and, by the end of the discussion, choose one course and identify one future career goal.
+Guide the conversation naturally:
+Start with a brief warm-up if needed.
+Ask which courses interest the learner most.
+Explore the learner's interests, strengths, favourite subjects, goals, and concerns.
+Ask the learner to look at the course material and share what they notice.
+Encourage comparison between at least two courses.
+Ask about advantages, disadvantages, difficulty, career opportunities, study style, and personal fit.
+Sometimes offer a different opinion or another angle to encourage discussion.
+Do not agree immediately with every learner response.
+Encourage the learner to justify and defend their ideas.
+Help the learner make one final course choice.
+Ask the learner to connect the choice with one future career goal.
+Briefly summarise the final choice and reason, then close naturally.
+
+Important:
+If the learner has not shared any interest, ask about interests, strengths, favourite subjects, or future goals.
+If the learner mentions a course, ask why they are interested in it.
+If the learner has not used the material, ask them to look at it first and tell you one useful detail.
+If the learner chooses too quickly, ask them to compare it with another option first.
+If the learner gives a final choice without a reason, ask them to explain why.
+If the learner gives a final choice with a clear reason and career goal, summarise and close.
+Only use the course information provided below. Do not invent course details.
+The visible history may be incomplete, so infer the learner's current step from the available history and latest message, and continue without restarting unless clearly needed.
+
+Course material:
+
+English Language and Linguistics
+Annual tuition fee: 50,000 UAH
+Main subjects: Applied Linguistics, TESOL, International Communication, Academic Writing
+Weekly study hours: 20
+Graduate employment rate: 85%
+Average starting salary: 25,000 UAH/month
+International opportunities: High
+Difficulty level: Moderate
+Advantages: Strong communication skills; international career opportunities; suitable for language lovers
+Drawbacks: Lower starting salary; competitive job market
+
+Business Management
+Annual tuition fee: 58,000 UAH
+Main subjects: Marketing, Finance, Management, Entrepreneurship
+Weekly study hours: 22
+Graduate employment rate: 90%
+Average starting salary: 35,000 UAH/month
+International opportunities: High
+Difficulty level: Moderate
+Advantages: Broad career options; leadership skills; useful in many industries
+Drawbacks: Competitive field; long working hours in some careers
+
+Computer Science
+Annual tuition fee: 62,000 UAH
+Main subjects: Programming, AI, Cybersecurity, Software Development
+Weekly study hours: 28
+Graduate employment rate: 96%
+Average starting salary: 45,000 UAH/month
+International opportunities: Very High
+Difficulty level: High
+Advantages: High employment rate; strong salaries; growing industry
+Drawbacks: Challenging coursework; requires strong analytical skills
+
+Psychology
+Annual tuition fee: 55,000 UAH
+Main subjects: Cognitive Psychology, Mental Health, Practical Methods, Counselling
+Weekly study hours: 24
+Graduate employment rate: 88%
+Average starting salary: 30,000 UAH/month
+International opportunities: Medium
+Difficulty level: Moderate-High
+Advantages: Interesting subject; helps understand human behaviour; valuable interpersonal skills
+Drawbacks: May require strong emotional commitment and a real desire to help others"""

+ 157 - 2
backend/main.py

@@ -80,6 +80,8 @@ from error_codes import (
 from services.wechat_jsapi_service import WechatJSAPIService
 from services.energy_stone_service import energy_stone_service
 from services.scenario_topic_service import scenario_topic_service
+from services.llm_task_service import llm_task_service
+from scenario_config import is_special_ab_experiment_user
 from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage, User
 from session_store import SessionStore
 from shop_item_image_utils import scene_image_name_from_shop_row
@@ -181,8 +183,7 @@ class APIKeyMiddleware(BaseHTTPMiddleware):
         # QQ 分享配置(SDK 拉取)
         "/api/qq/share/config",
         # web-admin 免登录管理接口(按需求)
-        "/api/admin/inbox",
-        "/api/admin/users",
+        "/api/admin",
     ]
     
     async def dispatch(self, request: StarletteRequest, call_next):
@@ -380,6 +381,16 @@ class AdminCreateNewsInboxRequest(BaseModel):
     content_en: str = Field(..., min_length=1, max_length=10000)
 
 
+class AdminCreateLlmTaskRequest(BaseModel):
+    task_name: str = Field(..., min_length=1, max_length=255)
+    task_prompt: str = Field(..., min_length=1, max_length=65535)
+    lumi_first_line: str = Field(..., min_length=1, max_length=2000)
+
+
+class AdminUpdateLlmTaskRequest(AdminCreateLlmTaskRequest):
+    pass
+
+
 @app.get("/")
 async def root():
     """健康检查"""
@@ -571,6 +582,13 @@ class LoginEmailRequest(BaseModel):
     preferred_language: Optional[str] = None  # "zh" | "en"
 
 
+class EmailContinueRequest(BaseModel):
+    """邮箱 Continue:特殊 A/B 用户免验证码登录,其余用户发验证码"""
+    email: str
+    preferred_language: Optional[str] = None  # "zh" | "en"
+    opted_out: Optional[bool] = None  # 是否退出早期共创社区
+
+
 class SendCodeRequest(BaseModel):
     """发送验证码请求"""
     area_code: Optional[str] = None  # 手机区号(如 +86)
@@ -891,6 +909,26 @@ async def get_journal_latest_topic(user_id: str, _: None = Depends(require_user_
         raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
 
 
+@app.get("/api/users/{user_id}/ab-llm-task")
+async def get_ab_llm_task(user_id: str, _: None = Depends(require_user_session)):
+    """A/B 组实验用户:返回当前最新 LLM 任务名称(不含 prompt)。"""
+    try:
+        user = await user_service.get_user(user_id)
+        if not user:
+            raise HTTPException(status_code=404, detail={"code": USER_NOT_FOUND})
+        if not is_special_ab_experiment_user(user):
+            raise HTTPException(status_code=403, detail={"code": "FORBIDDEN"})
+        task = await llm_task_service.get_latest_task_cached()
+        if not task:
+            return {"success": True, "data": None}
+        return {"success": True, "data": {"task_name": task["task_name"]}}
+    except HTTPException:
+        raise
+    except Exception:
+        logger.exception("获取 A/B LLM 任务失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 def _get_tutorial_intro_text(step: int) -> str | None:
     """根据教程步骤返回对应的固定英文文案。"""
     mapping: Dict[int, str] = {
@@ -3860,6 +3898,55 @@ async def login_with_email(request: LoginEmailRequest):
         raise HTTPException(status_code=500, detail={"code": LOGIN_FAILED})
 
 
+@app.post("/api/auth/email-continue")
+async def email_continue(request: EmailContinueRequest):
+    """
+    邮箱登录页 Continue:
+    - A/B 特殊用户:免验证码直接登录/首次注册
+    - 其他用户:发送验证码,前端跳转验证码输入页
+    """
+    try:
+        if await user_service.is_special_ab_email(request.email):
+            user, is_new_user = await user_service.auth_special_email_without_code(
+                request.email,
+                preferred_language=request.preferred_language,
+            )
+            session_data = await _generate_session_token(user["user_id"])
+            user.update(session_data)
+            user["is_new_user"] = is_new_user
+            if request.opted_out is not None:
+                try:
+                    await user_service.upsert_community_choice(user["user_id"], request.opted_out)
+                except Exception:
+                    logger.warning("特殊用户登录:同步 community_choice 失败", exc_info=True)
+            return {
+                "success": True,
+                "data": {
+                    "requires_verification": False,
+                    **user,
+                },
+            }
+
+        send_resp = await send_code(
+            SendCodeRequest(email=request.email, send_type="email"),
+        )
+        return {
+            "success": True,
+            "data": {
+                "requires_verification": True,
+                "code_id": send_resp["data"]["code_id"],
+                "message": send_resp["data"]["message"],
+            },
+        }
+    except AuthError as e:
+        raise HTTPException(status_code=400, detail={"code": e.code})
+    except HTTPException:
+        raise
+    except Exception:
+        logger.exception("email-continue 失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 @app.post("/api/auth/send-code")
 async def send_code(request: SendCodeRequest):
     """
@@ -5117,6 +5204,74 @@ async def admin_create_news_inbox(payload: AdminCreateNewsInboxRequest):
         raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
 
 
+@app.get("/api/admin/llm-tasks")
+async def admin_list_llm_tasks(
+    page: int = Query(1, ge=1),
+    page_size: int = Query(20, ge=1, le=100),
+):
+    """A/B 实验:LLM 任务列表(web-admin)。"""
+    try:
+        data = await llm_task_service.list_tasks(page=page, page_size=page_size)
+        return {"success": True, "data": data}
+    except Exception:
+        logger.exception("查询 LLM 任务列表失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
+@app.post("/api/admin/llm-tasks")
+async def admin_create_llm_task(payload: AdminCreateLlmTaskRequest):
+    """A/B 实验:新建 LLM 任务(新建即成为当前最新任务)。"""
+    try:
+        task = await llm_task_service.create_task(
+            task_name=payload.task_name.strip(),
+            task_prompt=payload.task_prompt.strip(),
+            lumi_first_line=payload.lumi_first_line.strip(),
+        )
+        return {"success": True, "data": task}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail={"code": "INVALID_PARAMS", "message": str(e)})
+    except Exception:
+        logger.exception("创建 LLM 任务失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
+@app.put("/api/admin/llm-tasks/{task_id}")
+async def admin_update_llm_task(task_id: int, payload: AdminUpdateLlmTaskRequest):
+    """A/B 实验:更新 LLM 任务。"""
+    try:
+        task = await llm_task_service.update_task(
+            task_id=task_id,
+            task_name=payload.task_name.strip(),
+            task_prompt=payload.task_prompt.strip(),
+            lumi_first_line=payload.lumi_first_line.strip(),
+        )
+        if not task:
+            raise HTTPException(status_code=404, detail={"code": "NOT_FOUND"})
+        return {"success": True, "data": task}
+    except HTTPException:
+        raise
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail={"code": "INVALID_PARAMS", "message": str(e)})
+    except Exception:
+        logger.exception("更新 LLM 任务失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
+@app.delete("/api/admin/llm-tasks/{task_id}")
+async def admin_delete_llm_task(task_id: int):
+    """A/B 实验:删除 LLM 任务。"""
+    try:
+        deleted = await llm_task_service.delete_task(task_id)
+        if not deleted:
+            raise HTTPException(status_code=404, detail={"code": "NOT_FOUND"})
+        return {"success": True, "data": None}
+    except HTTPException:
+        raise
+    except Exception:
+        logger.exception("删除 LLM 任务失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 @app.get("/api/admin/users")
 async def admin_list_users(
     keyword: Optional[str] = Query(None, description="按 user_id/username/email/phone 模糊搜索"),

+ 16 - 0
backend/migrations/add_llm_tasks_lumi_first_line.sql

@@ -0,0 +1,16 @@
+/*
+  llm_tasks 表增加 Lumi 首句字段(A/B 组按任务配置不同首句)
+  执行前请备份。MySQL 5.7+ / 8.0+
+*/
+
+SET NAMES utf8mb4;
+
+ALTER TABLE `llm_tasks`
+  ADD COLUMN `lumi_first_line` text COLLATE utf8mb4_unicode_ci NULL COMMENT 'Lumi 首句(A/B 组 greeting TTS)' AFTER `task_prompt`;
+
+UPDATE `llm_tasks`
+SET `lumi_first_line` = 'Hi, I''m Lumi, your university ambassador. I''m happy to talk with you today. How are you feeling?'
+WHERE `lumi_first_line` IS NULL OR `lumi_first_line` = '';
+
+ALTER TABLE `llm_tasks`
+  MODIFY COLUMN `lumi_first_line` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Lumi 首句(A/B 组 greeting TTS)';

+ 125 - 0
backend/migrations/add_llm_tasks_table.sql

@@ -0,0 +1,125 @@
+/*
+  A/B 组实验:LLM 任务表(任务名 + 任务 System Prompt + Lumi 首句)
+  默认取 created_at 最新一条作为当前任务。
+  执行前请备份。MySQL 5.7+ / 8.0+
+*/
+
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `llm_tasks` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `task_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务名称',
+  `task_prompt` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务 System Prompt 全文',
+  `lumi_first_line` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Lumi 首句(A/B 组 greeting TTS)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_llm_tasks_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='A/B组LLM任务提示词表';
+
+-- Task 01 种子数据(来源:乌克兰项目.md)
+INSERT INTO `llm_tasks` (`task_name`, `task_prompt`, `lumi_first_line`) VALUES
+('Task 01 | Choose Your University Course', 'You are Lumi, a friendly, supportive, and knowledgeable university ambassador.
+
+You are helping an L2 English learner discuss university course options and make an informed decision in a 15-minute conversation.
+
+Always respond in simple English.
+
+If the learner uses another language, politely ask them to continue in English, for example:
+"Could you repeat that in English?"
+"Could you explain that again in English?"
+"Can you tell me that using English?"
+
+Conversation style:
+Speak only as Lumi.
+Use natural spoken English.
+Keep responses short, usually 2–3 sentences maximum.
+Use B1–B2 level vocabulary and grammar.
+Ask one clear follow-up question at a time.
+Encourage the learner to speak more.
+Focus on communication, not grammar correction.
+Do not explicitly correct grammar mistakes.
+Respond naturally even if the learner makes minor language errors.
+If the learner gives a very short answer, ask for more details.
+Do not mention prompts, rules, stages, variables, or internal logic.
+
+Task context:
+The learner is preparing to apply to university and must choose one course from four options:
+- English Language and Linguistics
+- Business Management
+- Computer Science
+- Psychology
+
+The learner can see the course information. You also have the information below and should use it during the discussion.
+
+Your goal:
+Help the learner evaluate the options and, by the end of the discussion, choose one course and identify one future career goal.
+Guide the conversation naturally:
+Start with a brief warm-up if needed.
+Ask which courses interest the learner most.
+Explore the learner''s interests, strengths, favourite subjects, goals, and concerns.
+Ask the learner to look at the course material and share what they notice.
+Encourage comparison between at least two courses.
+Ask about advantages, disadvantages, difficulty, career opportunities, study style, and personal fit.
+Sometimes offer a different opinion or another angle to encourage discussion.
+Do not agree immediately with every learner response.
+Encourage the learner to justify and defend their ideas.
+Help the learner make one final course choice.
+Ask the learner to connect the choice with one future career goal.
+Briefly summarise the final choice and reason, then close naturally.
+
+Important:
+If the learner has not shared any interest, ask about interests, strengths, favourite subjects, or future goals.
+If the learner mentions a course, ask why they are interested in it.
+If the learner has not used the material, ask them to look at it first and tell you one useful detail.
+If the learner chooses too quickly, ask them to compare it with another option first.
+If the learner gives a final choice without a reason, ask them to explain why.
+If the learner gives a final choice with a clear reason and career goal, summarise and close.
+Only use the course information provided below. Do not invent course details.
+The visible history may be incomplete, so infer the learner''s current step from the available history and latest message, and continue without restarting unless clearly needed.
+
+Course material:
+
+English Language and Linguistics
+Annual tuition fee: 50,000 UAH
+Main subjects: Applied Linguistics, TESOL, International Communication, Academic Writing
+Weekly study hours: 20
+Graduate employment rate: 85%
+Average starting salary: 25,000 UAH/month
+International opportunities: High
+Difficulty level: Moderate
+Advantages: Strong communication skills; international career opportunities; suitable for language lovers
+Drawbacks: Lower starting salary; competitive job market
+
+Business Management
+Annual tuition fee: 58,000 UAH
+Main subjects: Marketing, Finance, Management, Entrepreneurship
+Weekly study hours: 22
+Graduate employment rate: 90%
+Average starting salary: 35,000 UAH/month
+International opportunities: High
+Difficulty level: Moderate
+Advantages: Broad career options; leadership skills; useful in many industries
+Drawbacks: Competitive field; long working hours in some careers
+
+Computer Science
+Annual tuition fee: 62,000 UAH
+Main subjects: Programming, AI, Cybersecurity, Software Development
+Weekly study hours: 28
+Graduate employment rate: 96%
+Average starting salary: 45,000 UAH/month
+International opportunities: Very High
+Difficulty level: High
+Advantages: High employment rate; strong salaries; growing industry
+Drawbacks: Challenging coursework; requires strong analytical skills
+
+Psychology
+Annual tuition fee: 55,000 UAH
+Main subjects: Cognitive Psychology, Mental Health, Practical Methods, Counselling
+Weekly study hours: 24
+Graduate employment rate: 88%
+Average starting salary: 30,000 UAH/month
+International opportunities: Medium
+Difficulty level: Moderate-High
+Advantages: Interesting subject; helps understand human behaviour; valuable interpersonal skills
+Drawbacks: May require strong emotional commitment and a real desire to help others', 'Hi, I''m Lumi, your university ambassador. I''m happy to talk with you today. How are you feeling?');

+ 19 - 0
backend/migrations/add_special_user_fields.sql

@@ -0,0 +1,19 @@
+/*
+  特殊实验用户字段(A/B 组,共 60 人)
+  - is_special_user: 是否特殊实验用户
+  - special_group: 分组 A / B(仅特殊用户有值)
+
+  执行前请备份。MySQL 5.7+ / 8.0+
+  执行顺序:先本脚本,再 seed_special_users_ab_groups.sql
+*/
+
+SET NAMES utf8mb4;
+
+ALTER TABLE `users`
+  ADD COLUMN `is_special_user` tinyint(1) NOT NULL DEFAULT '0'
+    COMMENT '是否特殊实验用户' AFTER `is_test_user`,
+  ADD COLUMN `special_group` varchar(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL
+    COMMENT '特殊用户分组:A / B' AFTER `is_special_user`,
+  ADD KEY `idx_is_special_user` (`is_special_user`),
+  ADD KEY `idx_special_group` (`special_group`),
+  ADD KEY `idx_special_user_group` (`is_special_user`, `special_group`);

+ 125 - 0
backend/migrations/migrate_llm_tasks_init.py

@@ -0,0 +1,125 @@
+"""
+LLM 任务表:建表 + 种子 Task 01 + lumi_first_line 列迁移
+运行(在 backend 目录): python migrations/migrate_llm_tasks_init.py
+"""
+import asyncio
+import sys
+from pathlib import Path
+from typing import Any
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import aiomysql
+
+from config import settings
+from data.ab_task_01_prompt import TASK_01_NAME, TASK_01_PROMPT, TASK_01_FIRST_LINE
+
+MIGRATIONS_DIR = Path(__file__).resolve().parent
+
+
+async def table_exists(cursor: Any) -> bool:
+    await cursor.execute(
+        """
+        SELECT COUNT(*) FROM information_schema.tables
+        WHERE table_schema = %s AND table_name = 'llm_tasks'
+        """,
+        (settings.MYSQL_DATABASE,),
+    )
+    row = await cursor.fetchone()
+    return bool(row and row[0] > 0)
+
+
+async def column_exists(cursor: Any, column_name: str) -> bool:
+    await cursor.execute(
+        """
+        SELECT COUNT(*) FROM information_schema.columns
+        WHERE table_schema = %s AND table_name = 'llm_tasks' AND column_name = %s
+        """,
+        (settings.MYSQL_DATABASE, column_name),
+    )
+    row = await cursor.fetchone()
+    return bool(row and row[0] > 0)
+
+
+async def seed_exists(cursor: Any) -> bool:
+    await cursor.execute("SELECT COUNT(*) FROM llm_tasks WHERE task_name = %s", (TASK_01_NAME,))
+    row = await cursor.fetchone()
+    return bool(row and row[0] > 0)
+
+
+async def migrate() -> bool:
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            if not await table_exists(cursor):
+                print("🔄 创建 llm_tasks 表...")
+                ddl = (MIGRATIONS_DIR / "add_llm_tasks_table.sql").read_text(encoding="utf-8")
+                create_part = ddl.split("INSERT INTO", 1)[0]
+                for stmt in [s.strip() for s in create_part.split(";") if s.strip()]:
+                    if stmt.upper().startswith("SET NAMES"):
+                        continue
+                    await cursor.execute(stmt)
+                await conn.commit()
+                print("✅ llm_tasks 表创建完成")
+            else:
+                print("✅ llm_tasks 表已存在")
+
+            if not await column_exists(cursor, "lumi_first_line"):
+                print("🔄 添加 lumi_first_line 列...")
+                alter_sql = (MIGRATIONS_DIR / "add_llm_tasks_lumi_first_line.sql").read_text(encoding="utf-8")
+                for stmt in [s.strip() for s in alter_sql.split(";") if s.strip()]:
+                    if stmt.upper().startswith("SET NAMES"):
+                        continue
+                    await cursor.execute(stmt)
+                await conn.commit()
+                print("✅ lumi_first_line 列已添加")
+            else:
+                print("✅ lumi_first_line 列已存在")
+
+            if not await seed_exists(cursor):
+                print("🔄 插入 Task 01 种子数据...")
+                await cursor.execute(
+                    "INSERT INTO llm_tasks (task_name, task_prompt, lumi_first_line) VALUES (%s, %s, %s)",
+                    (TASK_01_NAME, TASK_01_PROMPT, TASK_01_FIRST_LINE),
+                )
+                await conn.commit()
+                print("✅ Task 01 已写入")
+            else:
+                print("✅ Task 01 已存在,跳过插入")
+                await cursor.execute(
+                    """
+                    UPDATE llm_tasks
+                    SET lumi_first_line = %s
+                    WHERE task_name = %s
+                      AND (lumi_first_line IS NULL OR lumi_first_line = '')
+                    """,
+                    (TASK_01_FIRST_LINE, TASK_01_NAME),
+                )
+                await conn.commit()
+
+            await cursor.execute(
+                "SELECT id, task_name, lumi_first_line, created_at FROM llm_tasks ORDER BY created_at DESC LIMIT 1"
+            )
+            latest = await cursor.fetchone()
+            print("📊 当前最新任务:", latest)
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    ok = asyncio.run(migrate())
+    sys.exit(0 if ok else 1)

+ 110 - 0
backend/migrations/migrate_special_users_init.py

@@ -0,0 +1,110 @@
+"""
+特殊实验用户:加字段 + 预置 A/B 组 60 个账号
+运行(在 backend 目录): python migrations/migrate_special_users_init.py
+"""
+import asyncio
+import sys
+from pathlib import Path
+from typing import Any
+
+# 允许从 backend 目录直接运行
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import aiomysql
+
+from config import settings
+
+MIGRATIONS_DIR = Path(__file__).resolve().parent
+
+
+async def column_exists(cursor: Any, column_name: str) -> bool:
+    await cursor.execute(
+        """
+        SELECT COUNT(*) FROM information_schema.columns
+        WHERE table_schema = %s AND table_name = 'users' AND column_name = %s
+        """,
+        (settings.MYSQL_DATABASE, column_name),
+    )
+    row = await cursor.fetchone()
+    return bool(row and row[0] > 0)
+
+
+async def run_sql_file(cursor: Any, conn: Any, path: Path) -> None:
+    sql = path.read_text(encoding="utf-8")
+    statements = [s.strip() for s in sql.split(";") if s.strip()]
+    for stmt in statements:
+        if stmt.upper().startswith("SET NAMES"):
+            continue
+        await cursor.execute(stmt)
+    await conn.commit()
+
+
+async def count_special_users(cursor: Any) -> list[tuple[str, int]]:
+    await cursor.execute(
+        """
+        SELECT special_group, COUNT(*) AS cnt
+        FROM users
+        WHERE is_special_user = 1
+          AND email LIKE '%@lyrria.com'
+          AND (email LIKE 'A%@lyrria.com' OR email LIKE 'B%@lyrria.com')
+        GROUP BY special_group
+        ORDER BY special_group
+        """
+    )
+    return await cursor.fetchall()
+
+
+async def migrate() -> bool:
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            if not await column_exists(cursor, "is_special_user"):
+                print("🔄 添加 is_special_user / special_group 字段...")
+                alter_sql = (MIGRATIONS_DIR / "add_special_user_fields.sql").read_text(encoding="utf-8")
+                for stmt in [s.strip() for s in alter_sql.split(";") if s.strip()]:
+                    if stmt.upper().startswith("SET NAMES"):
+                        continue
+                    await cursor.execute(stmt)
+                await conn.commit()
+                print("✅ 字段添加完成")
+            else:
+                print("✅ is_special_user 字段已存在,跳过 ALTER")
+
+            print("🔄 预置 A/B 组 60 个账号...")
+            seed_path = MIGRATIONS_DIR / "seed_special_users_ab_groups.sql"
+            seed_sql = seed_path.read_text(encoding="utf-8")
+            for stmt in [s.strip() for s in seed_sql.split(";") if s.strip()]:
+                upper = stmt.upper()
+                if upper.startswith("SET NAMES") or upper.startswith("SELECT"):
+                    if upper.startswith("SELECT"):
+                        await cursor.execute(stmt)
+                        rows = await cursor.fetchall()
+                        print("📊 校验结果:", rows)
+                    continue
+                await cursor.execute(stmt)
+            await conn.commit()
+            print("✅ 账号预置完成")
+
+            rows = await count_special_users(cursor)
+            print("📊 当前特殊用户数量:", dict(rows) if rows else "无")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    ok = asyncio.run(migrate())
+    sys.exit(0 if ok else 1)

+ 106 - 0
backend/migrations/seed_special_users_ab_groups.sql

@@ -0,0 +1,106 @@
+/*
+  预置 60 个特殊实验账号(尚未完成首次验证码登录)
+  - A 组:A001@lyrria.com ~ A030@lyrria.com
+  - B 组:B001@lyrria.com ~ B030@lyrria.com
+
+  前置条件:
+  1. 已执行 add_special_user_fields.sql
+  2. users 表已存在(含 user_segment、is_test_user 等字段)
+
+  账号状态说明:
+  - user_type = guest,is_verified = 0,registered_at = NULL
+  - 邮箱已占用,防止被其他用户注册
+  - 首次验证码登录时,后端需识别「guest + is_special_user」并完成注册化
+    (后续改造 verify_and_auth / register_with_email 时处理)
+
+  幂等:按 email 冲突时仅更新特殊用户标记,不覆盖已注册用户数据
+*/
+
+SET NAMES utf8mb4;
+
+-- A 组 30 人
+INSERT INTO `users` (
+  `user_id`,
+  `username`,
+  `user_type`,
+  `user_segment`,
+  `is_test_user`,
+  `is_special_user`,
+  `special_group`,
+  `email`,
+  `is_verified`,
+  `user_level`,
+  `current_background_image_id`
+)
+SELECT
+  UUID(),
+  CONCAT('A', LPAD(n, 3, '0')),
+  'guest',
+  'individual',
+  0,
+  1,
+  'A',
+  CONCAT('A', LPAD(n, 3, '0'), '@lyrria.com'),
+  0,
+  0,
+  'default'
+FROM (
+  SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
+  UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
+  UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15
+  UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20
+  UNION ALL SELECT 21 UNION ALL SELECT 22 UNION ALL SELECT 23 UNION ALL SELECT 24 UNION ALL SELECT 25
+  UNION ALL SELECT 26 UNION ALL SELECT 27 UNION ALL SELECT 28 UNION ALL SELECT 29 UNION ALL SELECT 30
+) AS seq
+ON DUPLICATE KEY UPDATE
+  `is_special_user` = IF(`user_type` = 'registered', `is_special_user`, VALUES(`is_special_user`)),
+  `special_group` = IF(`user_type` = 'registered', `special_group`, VALUES(`special_group`));
+
+-- B 组 30 人
+INSERT INTO `users` (
+  `user_id`,
+  `username`,
+  `user_type`,
+  `user_segment`,
+  `is_test_user`,
+  `is_special_user`,
+  `special_group`,
+  `email`,
+  `is_verified`,
+  `user_level`,
+  `current_background_image_id`
+)
+SELECT
+  UUID(),
+  CONCAT('B', LPAD(n, 3, '0')),
+  'guest',
+  'individual',
+  0,
+  1,
+  'B',
+  CONCAT('B', LPAD(n, 3, '0'), '@lyrria.com'),
+  0,
+  0,
+  'default'
+FROM (
+  SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
+  UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
+  UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15
+  UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20
+  UNION ALL SELECT 21 UNION ALL SELECT 22 UNION ALL SELECT 23 UNION ALL SELECT 24 UNION ALL SELECT 25
+  UNION ALL SELECT 26 UNION ALL SELECT 27 UNION ALL SELECT 28 UNION ALL SELECT 29 UNION ALL SELECT 30
+) AS seq
+ON DUPLICATE KEY UPDATE
+  `is_special_user` = IF(`user_type` = 'registered', `is_special_user`, VALUES(`is_special_user`)),
+  `special_group` = IF(`user_type` = 'registered', `special_group`, VALUES(`special_group`));
+
+-- 校验
+SELECT
+  `special_group`,
+  COUNT(*) AS cnt
+FROM `users`
+WHERE `is_special_user` = 1
+  AND `email` LIKE '%@lyrria.com'
+  AND (`email` LIKE 'A%@lyrria.com' OR `email` LIKE 'B%@lyrria.com')
+GROUP BY `special_group`
+ORDER BY `special_group`;

+ 23 - 0
backend/models_db.py

@@ -38,6 +38,8 @@ class User(Base):
     user_segment = Column(String(32), nullable=False, default="individual", comment="用户/场景分群:individual/school/internal等")
     org_id = Column(String(64), nullable=True, comment="组织ID(关联 organizations.org_id)")
     is_test_user = Column(Boolean, nullable=False, default=False, comment="是否测试账号")
+    is_special_user = Column(Boolean, nullable=False, default=False, comment="是否特殊实验用户")
+    special_group = Column(String(8), nullable=True, comment="特殊用户分组:A / B")
     region = Column(String(32), nullable=True, comment="国家/地区(如 CN、US)")
     bot_name = Column(String(100), nullable=True, comment="用户给机器人取的名字")
     
@@ -89,6 +91,8 @@ class User(Base):
             "user_segment": self.user_segment,
             "org_id": self.org_id,
             "is_test_user": self.is_test_user,
+            "is_special_user": bool(self.is_special_user),
+            "special_group": self.special_group,
             "region": self.region,
             "bot_name": self.bot_name,
             "area_code": self.area_code,
@@ -113,6 +117,25 @@ class User(Base):
         }
 
 
+class LlmTask(Base):
+    """A/B 组 LLM 任务提示词表"""
+
+    __tablename__ = "llm_tasks"
+
+    id = Column(PKBigInt, primary_key=True, autoincrement=True)
+    task_name = Column(String(255), nullable=False, comment="任务名称")
+    task_prompt = Column(Text, nullable=False, comment="任务 System Prompt 全文")
+    lumi_first_line = Column(Text, nullable=False, comment="Lumi 首句(A/B 组 greeting TTS)")
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="创建时间")
+    updated_at = Column(
+        TIMESTAMP,
+        server_default=func.current_timestamp(),
+        onupdate=func.current_timestamp(),
+        nullable=False,
+        comment="更新时间",
+    )
+
+
 class Organization(Base):
     """组织表模型"""
 

+ 22 - 0
backend/prompt_constants.py

@@ -0,0 +1,22 @@
+"""
+LLM 提示词片段:A/B 任务 prompt 需追加的系统规则(与现有 TTS/前端情感链路兼容)
+"""
+
+# 追加到任务表 prompt 末尾,保证流式回复仍输出 [EMOTION:xxx]
+AB_TASK_EMOTION_APPENDIX = """
+━━━━━━━━━━━━━━━━
+EMOTION TAG — REQUIRED
+━━━━━━━━━━━━━━━━
+The FIRST line of every response MUST be:
+[EMOTION:name]
+
+Allowed emotions:
+neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
+
+Critical:
+- 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
+- NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
+- NO emojis or emoticons in your response
+""".strip()

+ 6 - 2
backend/requirements.txt

@@ -1,5 +1,6 @@
 # Web框架
 fastapi==0.110.0
+starlette>=0.36.3,<0.37.0  # FastAPI 0.110 不兼容 Starlette 1.0+
 uvicorn[standard]==0.27.1
 
 # WebSocket
@@ -11,8 +12,11 @@ python-dotenv==1.0.1
 # 日志
 loguru==0.7.2
 
-# 异步HTTP客户端
-httpx==0.27.0
+# 异步HTTP客户端(DashScope API 使用 HTTP/2)
+httpx[http2]==0.27.0
+
+# LLM OpenAI 兼容接口
+openai>=1.0.0
 
 # 类型提示
 pydantic==2.6.1

+ 8 - 1
backend/scenario_config.py

@@ -2,7 +2,7 @@
 情景对话模式:场景与话题常量及首句模板
 参考:项目文档/情景对话模式设计与改造方案.md
 """
-from typing import Dict, List, Tuple
+from typing import Dict, List, Optional, Tuple
 
 # 场景键
 SCENE_DEFAULT = "default"
@@ -52,6 +52,13 @@ SPECIAL_TOPIC_FIRST_LINES: Dict[str, str] = {
 }
 
 
+def is_special_ab_experiment_user(user_info: Optional[dict]) -> bool:
+    """A/B 组特殊实验用户:不参与三个特殊情景场景(window/reading/eating)。"""
+    if not user_info:
+        return False
+    return bool(user_info.get("is_special_user")) and user_info.get("special_group") in ("A", "B")
+
+
 def get_special_first_line(topic_key: str, user_name: str) -> str:
     """获取特殊场景某话题的首句(已替换用户名)。"""
     user_name = (user_name or "").strip() or "Neo"

+ 188 - 0
backend/services/llm_task_service.py

@@ -0,0 +1,188 @@
+"""
+A/B 组 LLM 任务表服务:读取最新任务 prompt / 首句
+"""
+import logging
+import time
+from typing import Any, Dict, Optional, TypedDict
+
+from sqlalchemy import desc, func, select
+
+from database import db_manager
+from models_db import LlmTask
+
+logger = logging.getLogger(__name__)
+
+_CACHE_TTL_SECONDS = 60.0
+_cached_task: Optional[Dict[str, Any]] = None
+_cached_at: float = 0.0
+
+
+class LlmTaskPayload(TypedDict):
+    task_name: str
+    task_prompt: str
+    lumi_first_line: str
+
+
+class LlmTaskService:
+    async def get_latest_task(self) -> Optional[LlmTaskPayload]:
+        """取 created_at 最新的一条任务。"""
+        try:
+            async with db_manager.get_session() as session:
+                result = await session.execute(
+                    select(LlmTask).order_by(desc(LlmTask.created_at), desc(LlmTask.id)).limit(1)
+                )
+                row = result.scalar_one_or_none()
+            if not row:
+                return None
+            return {
+                "task_name": row.task_name,
+                "task_prompt": row.task_prompt,
+                "lumi_first_line": row.lumi_first_line,
+            }
+        except Exception as e:
+            logger.warning("[LlmTask] get_latest_task failed: %s", e)
+            return None
+
+    async def get_latest_task_cached(self, force_refresh: bool = False) -> Optional[LlmTaskPayload]:
+        global _cached_task, _cached_at
+        now = time.time()
+        if (
+            not force_refresh
+            and _cached_task is not None
+            and (now - _cached_at) < _CACHE_TTL_SECONDS
+        ):
+            return _cached_task
+
+        task = await self.get_latest_task()
+        if task:
+            _cached_task = task
+            _cached_at = now
+        return task
+
+    async def list_tasks(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
+        page = max(1, page)
+        page_size = max(1, min(page_size, 100))
+        offset = (page - 1) * page_size
+        try:
+            async with db_manager.get_session() as session:
+                total = int(
+                    (await session.execute(select(func.count()).select_from(LlmTask))).scalar_one()
+                )
+                result = await session.execute(
+                    select(LlmTask)
+                    .order_by(desc(LlmTask.created_at), desc(LlmTask.id))
+                    .offset(offset)
+                    .limit(page_size)
+                )
+                rows = result.scalars().all()
+            items = [
+                self._row_to_dict(row)
+                for row in rows
+            ]
+            return {"items": items, "total": total, "page": page, "page_size": page_size}
+        except Exception as e:
+            logger.warning("[LlmTask] list_tasks failed: %s", e)
+            raise
+
+    async def create_task(
+        self,
+        task_name: str,
+        task_prompt: str,
+        lumi_first_line: str,
+    ) -> Dict[str, Any]:
+        task_name = task_name.strip()
+        task_prompt = task_prompt.strip()
+        lumi_first_line = lumi_first_line.strip()
+        if not task_name or not task_prompt or not lumi_first_line:
+            raise ValueError("task_name, task_prompt, lumi_first_line 均不能为空")
+        try:
+            async with db_manager.get_session() as session:
+                row = LlmTask(
+                    task_name=task_name,
+                    task_prompt=task_prompt,
+                    lumi_first_line=lumi_first_line,
+                )
+                session.add(row)
+                await session.flush()
+                await session.refresh(row)
+                payload = self._row_to_dict(row)
+                await session.commit()
+            self.invalidate_cache()
+            return payload
+        except Exception as e:
+            logger.warning("[LlmTask] create_task failed: %s", e)
+            raise
+
+    async def get_task_by_id(self, task_id: int) -> Optional[Dict[str, Any]]:
+        try:
+            async with db_manager.get_session() as session:
+                row = await session.get(LlmTask, task_id)
+            if not row:
+                return None
+            return self._row_to_dict(row)
+        except Exception as e:
+            logger.warning("[LlmTask] get_task_by_id failed: %s", e)
+            raise
+
+    async def update_task(
+        self,
+        task_id: int,
+        task_name: str,
+        task_prompt: str,
+        lumi_first_line: str,
+    ) -> Optional[Dict[str, Any]]:
+        task_name = task_name.strip()
+        task_prompt = task_prompt.strip()
+        lumi_first_line = lumi_first_line.strip()
+        if not task_name or not task_prompt or not lumi_first_line:
+            raise ValueError("task_name, task_prompt, lumi_first_line 均不能为空")
+        try:
+            async with db_manager.get_session() as session:
+                row = await session.get(LlmTask, task_id)
+                if not row:
+                    return None
+                row.task_name = task_name
+                row.task_prompt = task_prompt
+                row.lumi_first_line = lumi_first_line
+                await session.flush()
+                await session.refresh(row)
+                payload = self._row_to_dict(row)
+                await session.commit()
+            self.invalidate_cache()
+            return payload
+        except Exception as e:
+            logger.warning("[LlmTask] update_task failed: %s", e)
+            raise
+
+    async def delete_task(self, task_id: int) -> bool:
+        try:
+            async with db_manager.get_session() as session:
+                row = await session.get(LlmTask, task_id)
+                if not row:
+                    return False
+                await session.delete(row)
+                await session.commit()
+            self.invalidate_cache()
+            return True
+        except Exception as e:
+            logger.warning("[LlmTask] delete_task failed: %s", e)
+            raise
+
+    @staticmethod
+    def _row_to_dict(row: LlmTask) -> Dict[str, Any]:
+        return {
+            "id": row.id,
+            "task_name": row.task_name,
+            "task_prompt": row.task_prompt,
+            "lumi_first_line": row.lumi_first_line,
+            "created_at": row.created_at.isoformat() if row.created_at else None,
+            "updated_at": row.updated_at.isoformat() if row.updated_at else None,
+        }
+
+    def invalidate_cache(self) -> None:
+        global _cached_task, _cached_at
+        _cached_task = None
+        _cached_at = 0.0
+
+
+llm_task_service = LlmTaskService()

+ 48 - 0
backend/services/user_service.py

@@ -34,6 +34,7 @@ from error_codes import (
     PHONE_REQUIRED,
     CODE_REQUIRED,
     CODE_INVALID,
+    USER_NOT_FOUND_REGISTER_FIRST,
 )
 from shop_item_image_utils import scene_image_name_from_shop_row
 
@@ -1845,6 +1846,53 @@ class UserService:
             logger.error(f"❌ 邮箱登录失败: {e}")
             return None
 
+    async def is_special_ab_email(self, email: str) -> bool:
+        """是否为 A/B 组特殊实验用户(已预置邮箱)"""
+        async with db_manager.get_session() as session:
+            result = await session.execute(
+                select(User).where(
+                    User.email == email,
+                    User.is_special_user == True,
+                    User.special_group.in_(("A", "B")),
+                )
+            )
+            return result.scalar_one_or_none() is not None
+
+    async def auth_special_email_without_code(
+        self,
+        email: str,
+        preferred_language: Optional[str] = None,
+    ) -> Tuple[Dict, bool]:
+        """
+        A/B 特殊用户免验证码邮箱登录/首次注册。
+        返回 (user_dict, is_new_user)
+        """
+        async with db_manager.get_session() as session:
+            result = await session.execute(
+                select(User).where(
+                    User.email == email,
+                    User.is_special_user == True,
+                    User.special_group.in_(("A", "B")),
+                )
+            )
+            user = result.scalar_one_or_none()
+
+        if not user:
+            raise AuthError(USER_NOT_FOUND_REGISTER_FIRST)
+
+        if user.user_type == "registered":
+            logged_in = await self.login_with_email(email, preferred_language=preferred_language)
+            if not logged_in:
+                raise AuthError(USER_NOT_FOUND_REGISTER_FIRST)
+            return (logged_in, False)
+
+        registered = await self.register_with_email(
+            email,
+            user_id=user.user_id,
+            preferred_language=preferred_language,
+        )
+        return (registered, True)
+
     async def verify_and_auth(
         self,
         auth_type: str,

+ 8 - 2
backend/session_store.py

@@ -175,15 +175,21 @@ class SessionStore:
             entry = await self.get_current_token(user_id)
             if not entry or entry.token != token:
                 return
-            # 关闭旧连接
+            # 同 token 重连:关闭旧连接(非会话失效,避免前端误判为被顶号)
             prev_conn = self._connections.get(user_id)
             if prev_conn and prev_conn is not websocket:
                 try:
-                    await prev_conn.close(code=4401, reason="session_invalidated")
+                    await prev_conn.close(code=4000, reason="replaced_by_reconnect")
                 except Exception:
                     pass
             self._connections[user_id] = websocket
 
+    async def unbind_connection(self, user_id: str, websocket: WebSocket) -> None:
+        """连接正常结束时解除绑定,避免残留引用。"""
+        async with self._lock:
+            if self._connections.get(user_id) is websocket:
+                self._connections.pop(user_id, None)
+
     async def invalidate_connection(self, user_id: str) -> None:
         """
         主动关闭某个用户的现有连接(用于新 token 签发后踢旧连)。

+ 168 - 65
backend/websocket_handler.py

@@ -10,7 +10,7 @@ import logging
 import traceback
 from typing import Any, Dict, Literal, Optional
 from fastapi import WebSocket
-from starlette.websockets import WebSocketDisconnect
+from starlette.websockets import WebSocketDisconnect, WebSocketState
 
 logger = logging.getLogger(__name__)
 
@@ -44,11 +44,13 @@ from models import (
 from ai_service import ai_service
 from request_logger import request_logger, PerformanceMetrics
 from config import settings
+from ab_experiment_media_config import resolve_llm_model, resolve_tts_params
 from stt_service import stt_service, STTSession
 from services.user_service import user_service
 from services.energy_stone_service import energy_stone_service
 from services.scenario_topic_service import scenario_topic_service
 from services.scenario_gift_service import grant_scene_gift_if_needed
+from services.llm_task_service import llm_task_service
 from scenario_config import (
     SCENE_DEFAULT,
     SCENE_WINDOW,
@@ -58,6 +60,7 @@ from scenario_config import (
     SCENARIO_TOPICS,
     get_special_first_line,
     build_topics_payload,
+    is_special_ab_experiment_user,
 )
 import random
 import uuid
@@ -195,11 +198,20 @@ class WebSocketHandler:
         
         try:
             while True:
-                # 接收消息
-                data = await self.websocket.receive_text()
+                if not self._is_ws_connected():
+                    break
+                try:
+                    data = await self.websocket.receive_text()
+                except RuntimeError as exc:
+                    # 同用户重连时 bind_connection 会主动关闭旧连接,Starlette 抛 RuntimeError 而非 WebSocketDisconnect
+                    msg = str(exc).lower()
+                    if "websocket is not connected" in msg or "need to call \"accept\" first" in msg:
+                        logger.info(
+                            f"[{self.user_id}] ℹ️ WebSocket 已关闭(可能被新连接替换),停止接收消息"
+                        )
+                        break
+                    raise
                 message_data = json.loads(data)
-                
-                # 处理消息
                 await self.handle_message(message_data)
 
         except WebSocketDisconnect as e:
@@ -212,8 +224,34 @@ class WebSocketHandler:
             logger.error(traceback.format_exc())
 
         finally:
+            await self._cleanup_on_disconnect()
             close_code = getattr(self.websocket, "close_code", None)
             logger.info(f"[{self.user_id}] ℹ️ WebSocket连接结束,close_code={close_code}")
+
+    def _is_ws_connected(self) -> bool:
+        try:
+            return self.websocket.client_state == WebSocketState.CONNECTED
+        except Exception:
+            return False
+
+    async def _cleanup_on_disconnect(self) -> None:
+        """连接关闭时释放 STT 会话等资源。"""
+        if self.session_store:
+            try:
+                await self.session_store.unbind_connection(self.user_id, self.websocket)
+            except Exception:
+                pass
+        for request_id in list(self.stt_sessions.keys()):
+            try:
+                await self.cleanup_request(request_id)
+            except Exception as exc:
+                logger.warning(
+                    f"[{self.user_id}] ⚠️ 断开连接时清理 request={request_id} 失败: {exc}"
+                )
+        for task in self.preload_tasks.values():
+            if not task.done():
+                task.cancel()
+        self.preload_tasks.clear()
     
     async def handle_message(self, message_data: dict):
         """处理接收到的消息"""
@@ -330,57 +368,16 @@ class WebSocketHandler:
             self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
         
         speech_duration_ms = message.speechDuration or 0
-        total_duration_ms = None
-        level_update = None
 
-        # 🔥 更新用户累积说话时长(如果有提供,使用超时避免阻塞)
+        # 说话时长写库改异步,不阻塞 STT → LLM → TTS 主链路(全用户生效)
         if speech_duration_ms > 0:
-            duration_start = time.time()
-            try:
-                logger.info(f"[{request_id}] ⏱️ 开始更新用户说话时长: {speech_duration_ms}ms(超时: 1.0s)...")
-                # 使用超时,避免长时间阻塞
-                level_update = await asyncio.wait_for(
-                    user_service.update_speech_duration(self.user_id, speech_duration_ms),
-                    timeout=1.0  # 1秒超时
-                )
-                duration_elapsed = (time.time() - duration_start) * 1000
-                logger.info(f"[{request_id}] ✅ 更新用户说话时长成功,耗时: {duration_elapsed:.1f}ms")
-            except asyncio.TimeoutError:
-                duration_elapsed = (time.time() - duration_start) * 1000
-                logger.warning(f"[{request_id}] ⚠️ 更新用户说话时长超时(耗时: {duration_elapsed:.1f}ms): user_id={self.user_id}, duration_ms={speech_duration_ms}")
-            except Exception as e:
-                duration_elapsed = (time.time() - duration_start) * 1000
-                logger.warning(f"[{request_id}] ⚠️ 更新用户说话时长失败(耗时: {duration_elapsed:.1f}ms): user_id={self.user_id}, duration_ms={speech_duration_ms}, error={e}")
-        
+            asyncio.create_task(
+                self._update_speech_duration_async(request_id, speech_duration_ms)
+            )
+
         # 初始化AI响应收集器
         self.ai_responses[request_id] = []
 
-        if level_update and isinstance(level_update, dict):
-            total_duration_ms = level_update.get("total_duration_ms")
-            user_level = level_update.get("user_level")
-            if total_duration_ms is not None:
-                await self._maybe_spawn_conversation_stones(total_duration_ms, speech_duration_ms, request_id)
-            # 🔥 即使等级没有变化,也发送 user_level 消息,确保前端能同步 total_duration_ms
-            # 获取当前用户等级(如果更新时等级没变,user_level 可能为 None,需要查询)
-            if user_level is not None:
-                await self.send_user_level_update(
-                    request_id=request_id,
-                    level=user_level,
-                    total_duration_ms=total_duration_ms or 0,
-                )
-            elif total_duration_ms is not None:
-                # 等级没变,但总时长更新了,需要查询当前等级并发送消息
-                try:
-                    user_info = await user_service.get_user(self.user_id)
-                    if user_info and user_info.get("user_level") is not None:
-                        await self.send_user_level_update(
-                            request_id=request_id,
-                            level=user_info.get("user_level"),
-                            total_duration_ms=total_duration_ms,
-                        )
-                except Exception as e:
-                    logger.warning(f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}")
-        
         # 检查是否取消
         if request_id in self.cancelled_requests:
             self.cancelled_requests.remove(request_id)
@@ -702,10 +699,23 @@ class WebSocketHandler:
             if user_info:
                 # 判断是否为新用户(total_rounds == 0)
                 is_new_user = (user_info.get("total_rounds", 0) == 0)
-            
+
+            ab_task = None
+            if is_special_ab_experiment_user(user_info):
+                ab_task = await llm_task_service.get_latest_task_cached()
+                if not ab_task:
+                    logger.warning(f"[{self.user_id}] ⚠️ A/B 用户未找到任务表 prompt,将回退默认 base prompt")
+            
+            ab_task_prompt = ab_task.get("task_prompt") if ab_task else None
+            ab_task_name = ab_task.get("task_name") if ab_task else None
+
             # 获取system_prompt和runtime_context用于日志记录
-            system_prompt = ai_service._build_system_prompt(user_info, is_new_user)
-            runtime_context = ai_service._build_runtime_context(user_info)
+            system_prompt = ai_service._build_system_prompt(
+                user_info,
+                is_new_user,
+                ab_task_prompt=ab_task_prompt,
+            )
+            runtime_context = ai_service._build_runtime_context(user_info, ab_task_name=ab_task_name)
             
             # 构建对话历史(用于上下文)- v0.3: 近20轮对话
             chat_history = []
@@ -752,7 +762,8 @@ class WebSocketHandler:
                     text,  # ✅ v0.3: 原始用户输入,generate_response_stream 内部会构建完整的 messages 数组
                     user_info=user_info,
                     is_new_user=is_new_user,
-                    chat_history=chat_history if chat_history else None
+                    chat_history=chat_history if chat_history else None,
+                    ab_task=ab_task,
                 ):
                     if llm_first_chunk_time is None:
                         llm_first_chunk_time = time.time()
@@ -817,7 +828,9 @@ class WebSocketHandler:
                     
                     # 立即TTS合成这个句子(使用清理后的文本 + 情感参数)
                     try:
-                        async for audio_chunk in ai_service.text_to_speech_stream(clean_sentence, emotion):
+                        async for audio_chunk in ai_service.text_to_speech_stream(
+                            clean_sentence, emotion, user_info=user_info
+                        ):
                             if request_id in self.cancelled_requests:
                                 return
                             
@@ -898,7 +911,7 @@ class WebSocketHandler:
                 self.performance_metrics[request_id].llm_emotion = final_emotion
                 self.performance_metrics[request_id].llm_sentences = sentence_count
                 # 📝 更新TTS输入统计
-                self.performance_metrics[request_id].tts_voice = settings.tts_voice
+                self.performance_metrics[request_id].tts_voice = resolve_tts_params(user_info)["voice"]
             
             # 🔥 保存AI响应
             ai_response_text = ' '.join(self.ai_responses.get(request_id, []))
@@ -967,7 +980,7 @@ class WebSocketHandler:
                                         system_prompt=system_prompt,
                                         runtime_context=runtime_context,
                                         messages=llm_messages,
-                                        model_name=settings.llm_model,
+                                        model_name=resolve_llm_model(user_info),
                                         temperature="0.7",
                                         max_tokens=500,
                                         stream=True,
@@ -1182,6 +1195,67 @@ class WebSocketHandler:
                 self.user_id,
                 exc,
             )
+
+    async def _update_speech_duration_async(
+        self, request_id: str, speech_duration_ms: int
+    ) -> None:
+        """后台更新累积说话时长,并同步等级/能量石(不阻塞对话主链路)。"""
+        duration_start = time.time()
+        try:
+            logger.info(
+                f"[{request_id}] ⏱️ 异步更新用户说话时长: {speech_duration_ms}ms..."
+            )
+            level_update = await asyncio.wait_for(
+                user_service.update_speech_duration(self.user_id, speech_duration_ms),
+                timeout=5.0,
+            )
+            duration_elapsed = (time.time() - duration_start) * 1000
+            logger.info(
+                f"[{request_id}] ✅ 异步更新用户说话时长成功,耗时: {duration_elapsed:.1f}ms"
+            )
+        except asyncio.TimeoutError:
+            duration_elapsed = (time.time() - duration_start) * 1000
+            logger.warning(
+                f"[{request_id}] ⚠️ 异步更新用户说话时长超时(耗时: {duration_elapsed:.1f}ms): "
+                f"user_id={self.user_id}, duration_ms={speech_duration_ms}"
+            )
+            return
+        except Exception as e:
+            duration_elapsed = (time.time() - duration_start) * 1000
+            logger.warning(
+                f"[{request_id}] ⚠️ 异步更新用户说话时长失败(耗时: {duration_elapsed:.1f}ms): "
+                f"user_id={self.user_id}, duration_ms={speech_duration_ms}, error={e}"
+            )
+            return
+
+        if not level_update or not isinstance(level_update, dict):
+            return
+
+        total_duration_ms = level_update.get("total_duration_ms")
+        user_level = level_update.get("user_level")
+        if total_duration_ms is not None:
+            await self._maybe_spawn_conversation_stones(
+                total_duration_ms, speech_duration_ms, request_id
+            )
+        if user_level is not None:
+            await self.send_user_level_update(
+                request_id=request_id,
+                level=user_level,
+                total_duration_ms=total_duration_ms or 0,
+            )
+        elif total_duration_ms is not None:
+            try:
+                user_info = await user_service.get_user(self.user_id)
+                if user_info and user_info.get("user_level") is not None:
+                    await self.send_user_level_update(
+                        request_id=request_id,
+                        level=user_info.get("user_level"),
+                        total_duration_ms=total_duration_ms,
+                    )
+            except Exception as e:
+                logger.warning(
+                    f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}"
+                )
     
     async def _maybe_spawn_conversation_stones(
         self,
@@ -1229,12 +1303,21 @@ class WebSocketHandler:
             
             user_name = (user_info.get("username") or "").strip() or "Neo"
             is_new_user = (user_info.get("total_rounds", 0) == 0)
+            ab_experiment_user = is_special_ab_experiment_user(user_info)
             
             # 1) 决定本次场景
             chosen_scene = SCENE_DEFAULT
             scene_anchor: Optional[Dict[str, Any]] = None
             valid_force = force_scene in (SCENE_DEFAULT, SCENE_WINDOW, SCENE_READING, SCENE_EATING)
-            if valid_force:
+            if ab_experiment_user:
+                chosen_scene = SCENE_DEFAULT
+                if valid_force and force_scene != SCENE_DEFAULT:
+                    logger.info(
+                        f"[{self.user_id}] A/B 实验用户忽略强制特殊场景 force_scene={force_scene},使用 default"
+                    )
+                else:
+                    logger.info(f"[{self.user_id}] A/B 实验用户,跳过特殊情景场景随机")
+            elif valid_force:
                 if force_scene == SCENE_DEFAULT:
                     chosen_scene = force_scene
                     logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}")
@@ -1270,8 +1353,18 @@ class WebSocketHandler:
             
             # 2) 默认场景:沿用原逻辑
             if chosen_scene == SCENE_DEFAULT:
-                if is_new_user:
-                    lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+                lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+                if is_special_ab_experiment_user(user_info):
+                    ab_task = await llm_task_service.get_latest_task_cached()
+                    lumi_first_line = ab_task.get("lumi_first_line") if ab_task else None
+                    greeting_text = ai_service.ab_group_greeting_line(
+                        user_name,
+                        lumi_name,
+                        is_new_user,
+                        lumi_first_line=lumi_first_line,
+                    )
+                    logger.info(f"[{self.user_id}] 🎯 A/B 实验用户首句: {greeting_text}")
+                elif is_new_user:
                     greeting_text = ai_service.first_meeting_line(user_name, lumi_name)
                     logger.info(f"[{self.user_id}] 🎯 新用户首句: {greeting_text}")
                 else:
@@ -1296,7 +1389,11 @@ class WebSocketHandler:
                 
                 emotion = "friendly" if is_new_user else "neutral"
                 clean_text = greeting_text
-                if not is_new_user and "[EMOTION:" in greeting_text:
+                if (
+                    not is_special_ab_experiment_user(user_info)
+                    and not is_new_user
+                    and "[EMOTION:" in greeting_text
+                ):
                     emotion_start = greeting_text.find("[EMOTION:")
                     emotion_end = greeting_text.find("]", emotion_start)
                     if emotion_end > emotion_start:
@@ -1313,7 +1410,9 @@ class WebSocketHandler:
                 await self.send_emotion(request_id, emotion)
                 await self.send_text_result(request_id, clean_text, is_final=True, source="assistant")
                 chunk_index = 0
-                async for audio_chunk in ai_service.text_to_speech_stream(clean_text, emotion):
+                async for audio_chunk in ai_service.text_to_speech_stream(
+                    clean_text, emotion, user_info=user_info
+                ):
                     audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
                     await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
                     chunk_index += 1
@@ -1352,7 +1451,9 @@ class WebSocketHandler:
             await self.send_emotion(request_id, emotion)
             await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
             chunk_index = 0
-            async for audio_chunk in ai_service.text_to_speech_stream(first_line, emotion):
+            async for audio_chunk in ai_service.text_to_speech_stream(
+                first_line, emotion, user_info=user_info
+            ):
                 audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
                 await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
                 chunk_index += 1
@@ -1557,7 +1658,9 @@ class WebSocketHandler:
             await self.send_emotion(request_id, emotion)
             await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
             chunk_index = 0
-            async for audio_chunk in ai_service.text_to_speech_stream(first_line, emotion):
+            async for audio_chunk in ai_service.text_to_speech_stream(
+                first_line, emotion, user_info=user_info
+            ):
                 audio_base64 = base64.b64encode(audio_chunk).decode("utf-8")
                 await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
                 chunk_index += 1

+ 122 - 22
frontend/src/pages/ChatBotPage.tsx

@@ -29,6 +29,9 @@ import {
   hasValidSession,
   ROUTE_ORDER,
   applyAudioPrefsFromUIServerState,
+  isSpecialAbUser,
+  getSubtitleAbPolicy,
+  resolveSubtitleOn,
 } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
@@ -49,6 +52,7 @@ import {
   getUserInboxMessages,
   getUserInboxMessageDetail,
   claimUserInboxEnergyGift,
+  getAbLlmTask,
   type InboxMessageListItem,
   type UserInboxMessageDetailResponse,
 } from '@/utils/api';
@@ -223,6 +227,7 @@ export const ChatBotPage = () => {
   const { t: tProfile } = useTranslation('profile');
   const { t: tWelcome } = useTranslation('welcome');
   const isZh = i18n.language?.startsWith('zh') ?? true;
+  const isAbExperimentUser = isSpecialAbUser();
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const location = useLocation();
@@ -287,10 +292,25 @@ export const ChatBotPage = () => {
   useEffect(() => {
     greetingContextRef.current = greetingContext;
   }, [greetingContext]);
-  // 字幕相关状态(默认从 localStorage 读取,若无则为 false)
+
+  // 字幕相关状态(A 组强制关、B 组强制开;普通用户读 localStorage)
+  const subtitleAbPolicy = getSubtitleAbPolicy();
   const [subtitleOn, setSubtitleOn] = useState(() => {
-    return getStorage(STORAGE_KEYS.SUBTITLE_ON) === 'true';
+    return resolveSubtitleOn(getStorage(STORAGE_KEYS.SUBTITLE_ON) === 'true');
   });
+
+  // A/B 组:进入 chatbot 时强制字幕策略并同步服务端
+  useEffect(() => {
+    if (!subtitleAbPolicy) return;
+    const forcedOn = subtitleAbPolicy === 'force_on';
+    setStorage(STORAGE_KEYS.SUBTITLE_ON, forcedOn ? 'true' : 'false');
+    setSubtitleOn(forcedOn);
+    const uid = getUserId();
+    if (uid) {
+      updateUserUIState(uid, { subtitle_on: forcedOn }).catch(() => {});
+    }
+  }, [subtitleAbPolicy]);
+
   const subtitleContainerRef = useRef<HTMLDivElement | null>(null);
   const [subtitleVisible, setSubtitleVisible] = useState(false);
   const subtitleOnRef = useRef(subtitleOn);
@@ -365,6 +385,35 @@ export const ChatBotPage = () => {
   const [intimacyDaysSinceLastVisit, setIntimacyDaysSinceLastVisit] = useState(0);
   const [intimacyLoading, setIntimacyLoading] = useState(false);
   const [canLightUpToday, setCanLightUpToday] = useState(true);
+  const [abTaskName, setAbTaskName] = useState<string | null>(null);
+
+  // A/B 组:顶部展示当前 LLM 任务名称
+  useEffect(() => {
+    if (!isAbExperimentUser) {
+      setAbTaskName(null);
+      return;
+    }
+    const userId = getUserId();
+    if (!userId) return;
+
+    let cancelled = false;
+    const loadAbTask = async () => {
+      try {
+        const res = await getAbLlmTask(userId);
+        if (cancelled) return;
+        setAbTaskName(res.success && res.data?.task_name ? res.data.task_name : null);
+      } catch {
+        if (!cancelled) {
+          setAbTaskName(null);
+        }
+      }
+    };
+
+    void loadAbTask();
+    return () => {
+      cancelled = true;
+    };
+  }, [isAbExperimentUser]);
 
   // 同步亲密度弹窗状态到 ref,供 WebSocket 消息处理时使用
   const loadInboxUnreadBadge = useCallback(async (userId: string) => {
@@ -434,7 +483,8 @@ export const ChatBotPage = () => {
     const requestId = `greeting_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
     const rawScene = searchParams.get('scene');
     const forceScene: 'default' | 'window' | 'reading' | 'eating' | undefined =
-      rawScene === 'default' || rawScene === 'window' || rawScene === 'reading' || rawScene === 'eating'
+      !isSpecialAbUser() &&
+      (rawScene === 'default' || rawScene === 'window' || rawScene === 'reading' || rawScene === 'eating')
         ? rawScene
         : undefined;
     debugLog('[首句] 发送 start_greeting 消息', { requestId, forceScene });
@@ -2118,10 +2168,11 @@ export const ChatBotPage = () => {
           setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, state.has_shared_avatar ? 'true' : 'false');
           setHasSharedAvatarState(state.has_shared_avatar);
           
-          // 字幕开关偏好(无论 true/false,都从数据库同步)
+          // 字幕开关偏好(A/B 组按实验策略覆盖)
           if (typeof state.subtitle_on === 'boolean') {
-            setStorage(STORAGE_KEYS.SUBTITLE_ON, state.subtitle_on ? 'true' : 'false');
-            setSubtitleOn(state.subtitle_on);
+            const resolved = resolveSubtitleOn(state.subtitle_on);
+            setStorage(STORAGE_KEYS.SUBTITLE_ON, resolved ? 'true' : 'false');
+            setSubtitleOn(resolved);
           }
 
           applyAudioPrefsFromUIServerState(state);
@@ -2627,6 +2678,12 @@ export const ChatBotPage = () => {
             }
 
             if (connectionLostRef.current) {
+              const recordingState = useAppStore.getState().currentState;
+              if (recordingState === 'recording') {
+                connectionLostRef.current = false;
+                debugLog('[WebSocket] 录音中重连成功,继续发送音频');
+                return;
+              }
               antMessage.success(tCommon('voiceServiceRestored'));
               connectionLostRef.current = false;
               recorderRef.current?.stop();
@@ -2650,6 +2707,14 @@ export const ChatBotPage = () => {
             // 初次连接失败,静默等待自动重连
             return;
           }
+
+          const recordingState = useAppStore.getState().currentState;
+          if (recordingState === 'recording') {
+            // 长录音期间短暂断线:保持录音,音频 chunk 会进入缓冲队列,重连后自动 flush
+            connectionLostRef.current = true;
+            debugLog('[WebSocket] 录音中断线,等待自动重连(不停止录音)');
+            return;
+          }
           
           connectionLostRef.current = true;
           
@@ -3494,6 +3559,37 @@ export const ChatBotPage = () => {
           }}
         />
       )}
+      {/* A/B 组:页面最顶部展示当前任务名称 */}
+      {isAbExperimentUser && abTaskName ? (
+        <div
+          className="absolute left-1/2 -translate-x-1/2 pointer-events-none"
+          style={{
+            top: `calc(env(safe-area-inset-top, 0px) + ${px2vmin(8, 'v2')})`,
+            width: px2vmin(260, 'v2'),
+            maxWidth: `calc(100% - ${px2vmin(48, 'v2')})`,
+            padding: `${px2vmin(6, 'v2')} ${px2vmin(12, 'v2')}`,
+            backgroundColor: 'rgba(0, 0, 0, 0.45)',
+            backdropFilter: 'blur(8px)',
+            WebkitBackdropFilter: 'blur(8px)',
+            borderRadius: px2vmin(8, 'v2'),
+            zIndex: 25 + CHATBOT_Z_INDEX.UI_OFFSET,
+            boxSizing: 'border-box',
+          }}
+        >
+          <div
+            style={{
+              color: '#FFFFFF',
+              fontSize: px2vmin(12, 'v2'),
+              fontWeight: 600,
+              lineHeight: 1.35,
+              textAlign: 'center',
+              wordBreak: 'break-word',
+            }}
+          >
+            {abTaskName}
+          </div>
+        </div>
+      ) : null}
       {/* 幕布舞台:背景与 5×4 家具/特殊场景共用;网格列在层内水平居中 */}
       <div
         className="absolute pointer-events-none"
@@ -4635,27 +4731,31 @@ export const ChatBotPage = () => {
           {currentState !== 'recording' && (
             <button
               type="button"
-              className="touch-manipulation cursor-pointer border-0 bg-transparent p-0 flex items-center justify-center"
-              onClick={wrapTrackedClick('chatbot', 'subtitle_toggle', () => {
-                setSubtitleOn((prev) => {
-                  const next = !prev;
-                  // 持久化到 localStorage
-                  setStorage(STORAGE_KEYS.SUBTITLE_ON, next ? 'true' : 'false');
-                  // 同步到后端用户 UI 状态
-                  const uid = getUserId();
-                  if (uid) {
-                    updateUserUIState(uid, { subtitle_on: next }).catch(() => {
-                      // 同步失败不影响前端展示
-                    });
-                  }
-                  return next;
-                });
-              })}
+              disabled={Boolean(subtitleAbPolicy)}
+              className={`touch-manipulation border-0 bg-transparent p-0 flex items-center justify-center ${
+                subtitleAbPolicy ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
+              }`}
+              onClick={
+                subtitleAbPolicy
+                  ? undefined
+                  : wrapTrackedClick('chatbot', 'subtitle_toggle', () => {
+                      setSubtitleOn((prev) => {
+                        const next = !prev;
+                        setStorage(STORAGE_KEYS.SUBTITLE_ON, next ? 'true' : 'false');
+                        const uid = getUserId();
+                        if (uid) {
+                          updateUserUIState(uid, { subtitle_on: next }).catch(() => {});
+                        }
+                        return next;
+                      });
+                    })
+              }
               style={{
                 width: px2vmin(48),
                 height: px2vmin(47),
               }}
               aria-label={subtitleOn ? 'Close subtitle' : 'Open subtitle'}
+              aria-disabled={subtitleAbPolicy ? true : undefined}
             >
               <img
                 src={subtitleOn ? ccOpenIcon : ccCloseIcon}

+ 99 - 22
frontend/src/pages/LoginPage.tsx

@@ -2,8 +2,8 @@ import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { message } from 'antd';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState, getQQAuthUrl, sendCode, getCommunityChoice, ApiError } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName, setLastLoginMethod, getLastOnboardingRoute, syncUserDataFromDatabase } from '@/utils/storage';
+import { updateUserUIState, getQQAuthUrl, sendCode, getCommunityChoice, emailContinue, getUserInfo, getUserUIState, ApiError } from '@/utils/api';
 import { px2vmin, px2vw, px2vh, type ViewportDesign } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { playToggleSelectSfx } from '@/utils/sfxPlay';
@@ -25,9 +25,10 @@ interface LocationState {
 }
 
 export const LoginPage = () => {
-  const { t } = useTranslation('login');
+  const { t, i18n } = useTranslation('login');
   const { t: tCommon } = useTranslation('common');
   const { t: tError } = useTranslation('error');
+  const { t: tVerification } = useTranslation('verification');
   const location = useLocation();
   const navigate = useNavigate();
   const state = location.state as LocationState | null;
@@ -106,6 +107,99 @@ export const LoginPage = () => {
     navigate('/login-register');
   };
 
+  const navigateAfterEmailAuth = async (
+    userId: string,
+    isNewUser: boolean,
+    userInfoData: { user_id: string; username?: string; bot_name?: string; user_level?: number } | null,
+  ) => {
+    if (isNewUser) {
+      navigate('/welcome', { replace: true });
+      return;
+    }
+    try {
+      const uiRes = await getUserUIState(userId);
+      if (uiRes?.success && uiRes.data) {
+        syncUserDataFromDatabase(userInfoData ?? { user_id: userId }, uiRes.data);
+        const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
+        navigate(target, { replace: true });
+      } else {
+        navigate('/chatbot', { replace: true });
+      }
+    } catch {
+      navigate('/chatbot', { replace: true });
+    }
+  };
+
+  const completeSpecialEmailLogin = async (emailAddress: string) => {
+    const lang = i18n.language.startsWith('zh') ? 'zh' : 'en';
+    const response = await emailContinue({
+      email: emailAddress,
+      preferred_language: lang,
+      opted_out: optOutEarlyCommunity,
+    });
+
+    if (!response.success || !response.data) {
+      message.error(t('sendCodeFail'));
+      return;
+    }
+
+    if (response.data.requires_verification) {
+      message.success(t('codeSent'));
+      navigate('/login-verification-input', {
+        state: {
+          loginType: 'email',
+          areaCode,
+          phoneNumber,
+          email: emailAddress,
+          optOutEarlyCommunity,
+        },
+      });
+      return;
+    }
+
+    const d = response.data;
+    const userId = d.user_id;
+    if (!userId || !d.token) {
+      message.error(tError('LOGIN_FAILED'));
+      return;
+    }
+
+    const isNewUser = !!d.is_new_user;
+    setStorage(STORAGE_KEYS.USER_ID, userId);
+    setLastLoginMethod('email');
+    if (d.username) setStorage(STORAGE_KEYS.USER_NAME, d.username);
+    const { setUserToken } = await import('@/utils/storage');
+    setUserToken(d.token);
+    setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+    if (typeof d.user_level === 'number') {
+      setStorage(STORAGE_KEYS.USER_LEVEL, String(d.user_level));
+    }
+    if (typeof d.is_special_user === 'boolean') {
+      setStorage(STORAGE_KEYS.IS_SPECIAL_USER, d.is_special_user ? 'true' : 'false');
+    }
+    if (d.special_group === 'A' || d.special_group === 'B') {
+      setStorage(STORAGE_KEYS.SPECIAL_GROUP, d.special_group);
+    }
+
+    let userInfoData: { user_id: string; username?: string; bot_name?: string; user_level?: number } | null = null;
+    try {
+      const userInfo = await getUserInfo(userId);
+      userInfoData = userInfo.success && userInfo.data ? userInfo.data : null;
+      if (userInfoData) {
+        if (userInfoData.username) setUserName(userInfoData.username);
+        if (userInfoData.bot_name) setStorage(STORAGE_KEYS.LUMI_NAME, userInfoData.bot_name);
+        if (typeof userInfoData.user_level === 'number') {
+          setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfoData.user_level));
+        }
+      }
+    } catch {
+      console.warn('获取完整用户信息失败');
+    }
+
+    message.success(tVerification('loginSuccess'));
+    await navigateAfterEmailAuth(userId, isNewUser, userInfoData);
+  };
+
   // 处理 continue 按钮点击
   const handleContinue = async () => {
     // 手机号登录/注册:不校验用户是否存在,直接发验证码,验证时由后端区分登录或注册
@@ -145,7 +239,7 @@ export const LoginPage = () => {
         setIsChecking(false);
       }
     }
-    // 邮箱登录/注册:不校验用户是否存在,直接发验证码
+    // 邮箱登录/注册:A/B 特殊用户免验证码;其余用户发验证码
     else if (selectedLoginType === 'email' && email) {
       if (!isValidEmail(email)) {
         message.error(t('emailInvalid'));
@@ -155,24 +249,7 @@ export const LoginPage = () => {
       if (isChecking) return;
       setIsChecking(true);
       try {
-        const response = await sendCode({
-          email,
-          send_type: 'email',
-        });
-        if (response.success) {
-          message.success(t('codeSent'));
-          navigate('/login-verification-input', {
-            state: {
-              loginType: 'email',
-              areaCode,
-              phoneNumber,
-              email,
-              optOutEarlyCommunity,
-            },
-          });
-        } else {
-          message.error(t('sendCodeFail'));
-        }
+        await completeSpecialEmailLogin(email);
       } catch (error) {
         const code = error instanceof ApiError ? error.code : 'SERVER_ERROR';
         message.error(tError(code));

+ 93 - 95
frontend/src/pages/WelcomePage.tsx

@@ -1,9 +1,9 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserLevel } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserLevel, isSpecialAbUser } from '@/utils/storage';
 import { updateUserUIState, getUserInfo } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import { getOssUrl } from '@/utils/oss';
@@ -38,6 +38,64 @@ export const WelcomePage = () => {
   const gif1TimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // GIF1 定时器
   const gif2TimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // GIF2 定时器
   const setWelcomePageMp4Playing = useAppStore((s) => s.setWelcomePageMp4Playing);
+  const skipWelcomeVideo = isSpecialAbUser();
+
+  const preloadUserDataForNaming = useCallback(async (): Promise<void> => {
+    try {
+      const userId = getUserId();
+      if (!userId) return;
+
+      const savedName = getStorage(STORAGE_KEYS.USER_NAME);
+      if (!savedName) {
+        try {
+          const response = await getUserInfo(userId);
+          if (response.success && response.data) {
+            const { username, bot_name } = response.data;
+
+            if (typeof response.data.user_level === 'number') {
+              setUserLevel(response.data.user_level);
+            }
+
+            if (username && !isBackendGeneratedName(username)) {
+              setStorage(STORAGE_KEYS.USER_NAME, username);
+            }
+
+            if (bot_name) {
+              setStorage(STORAGE_KEYS.LUMI_NAME, bot_name);
+            }
+          }
+        } catch (error) {
+          console.error('[WelcomePage] 预加载:获取用户信息失败', error);
+        }
+      }
+
+      const visited = getVisitedRoutes();
+      await updateUserUIState(userId, {
+        current_page: 'naming',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    } catch (error) {
+      console.error('[WelcomePage] 预加载用户数据异常:', error);
+    }
+  }, []);
+
+  const safeNavigateToNaming = useCallback(() => {
+    if (hasNavigatedRef.current) {
+      return;
+    }
+    hasNavigatedRef.current = true;
+    if (checkIntervalRef.current) {
+      clearInterval(checkIntervalRef.current);
+      checkIntervalRef.current = null;
+    }
+    if (fallbackTimerRef.current) {
+      clearTimeout(fallbackTimerRef.current);
+      fallbackTimerRef.current = null;
+    }
+    navigate('/naming');
+  }, [navigate]);
 
   useEffect(() => {
     setWelcomePageMp4Playing(viewState === 'playing');
@@ -91,26 +149,29 @@ export const WelcomePage = () => {
       });
     }
 
-    // 🚀 提前预加载视频资源(使用 link preload)
-    const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || getOssUrl('Lyrria-video.mp4');
-    const preloadLink = document.createElement('link');
-    preloadLink.rel = 'preload';
-    preloadLink.as = 'video';
-    preloadLink.href = videoUrl;
-    preloadLink.crossOrigin = 'anonymous';
-    document.head.appendChild(preloadLink);
+    // 🚀 提前预加载视频资源(A/B 特殊用户跳过)
+    if (!skipWelcomeVideo) {
+      const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || getOssUrl('Lyrria-video.mp4');
+      const preloadLink = document.createElement('link');
+      preloadLink.rel = 'preload';
+      preloadLink.as = 'video';
+      preloadLink.href = videoUrl;
+      preloadLink.crossOrigin = 'anonymous';
+      document.head.appendChild(preloadLink);
 
-    // 清理函数
-    return () => {
-      // 清理预加载链接
-      if (preloadLink.parentNode) {
-        preloadLink.parentNode.removeChild(preloadLink);
-      }
-    };
-  }, []);
+      return () => {
+        if (preloadLink.parentNode) {
+          preloadLink.parentNode.removeChild(preloadLink);
+        }
+      };
+    }
+  }, [skipWelcomeVideo]);
 
-  // 页面打开后,后台预加载视频(在 video 元素挂载后)
+  // 页面打开后,后台预加载视频(A/B 特殊用户跳过)
   useEffect(() => {
+    if (skipWelcomeVideo) {
+      return;
+    }
     // 使用 setTimeout 确保 video 元素已经挂载到 DOM
     const initTimer = setTimeout(() => {
       if (!playerRef.current && videoRef.current) {
@@ -260,7 +321,7 @@ export const WelcomePage = () => {
         // 注意:不在这里销毁播放器,因为视频需要保持加载状态
       }
     };
-  }, [navigate, viewState]);
+  }, [navigate, viewState, skipWelcomeVideo]);
 
   // 更新 viewState 时同步更新 ref
   useEffect(() => {
@@ -393,79 +454,7 @@ export const WelcomePage = () => {
         videoElement.style.zIndex = '2';
 
         // 🚀 在视频播放期间预加载用户数据,避免进入 naming 页面时卡顿
-        const preloadUserData = async () => {
-          try {
-            // 仅使用已登录用户的 userId(不再通过会话初始化创建匿名用户)
-            const userId = getUserId();
-            if (!userId) return;
-
-            // 如果 localStorage 中没有保存的用户名,从后端获取
-            const savedName = getStorage(STORAGE_KEYS.USER_NAME);
-            if (!savedName) {
-              const user_id = userId;
-              if (user_id) {
-                try {
-                  const response = await getUserInfo(user_id);
-                  if (response.success && response.data) {
-                    const { username, bot_name } = response.data;
-                    
-                    if (typeof response.data.user_level === 'number') {
-                      setUserLevel(response.data.user_level);
-                    }
-                    
-                    // 保存用户名到 localStorage
-                    if (username) {
-                      if (!isBackendGeneratedName(username)) {
-                        setStorage(STORAGE_KEYS.USER_NAME, username);
-                      }
-                    }
-                    
-                    // 保存 bot_name 到 localStorage
-                    if (bot_name) {
-                      setStorage(STORAGE_KEYS.LUMI_NAME, bot_name);
-                    }
-                    
-                  }
-                } catch (error) {
-                  console.error('[WelcomePage] 预加载:获取用户信息失败', error);
-                }
-              }
-            }
-
-            // 更新用户 UI 状态(非阻塞)
-            if (userId) {
-              const visited = getVisitedRoutes();
-              updateUserUIState(userId, {
-                current_page: 'naming',
-                visited_routes: visited,
-              }).catch(() => {
-                // 同步失败不影响前端流程
-              });
-            }
-          } catch (error) {
-            console.error('[WelcomePage] 预加载用户数据异常:', error);
-          }
-        };
-        
-        // 立即开始预加载(不阻塞视频播放)
-        preloadUserData();
-
-        // 🚀 统一的安全跳转函数,避免多次 navigate
-        const safeNavigateToNaming = () => {
-          if (hasNavigatedRef.current) {
-            return;
-          }
-          hasNavigatedRef.current = true;
-          if (checkIntervalRef.current) {
-            clearInterval(checkIntervalRef.current);
-            checkIntervalRef.current = null;
-          }
-          if (fallbackTimerRef.current) {
-            clearTimeout(fallbackTimerRef.current);
-            fallbackTimerRef.current = null;
-          }
-          navigate('/naming');
-        };
+        void preloadUserDataForNaming();
 
         // 绑定视频播放结束事件(原生事件,确保可靠)
         const handleVideoEnded = () => {
@@ -559,7 +548,7 @@ export const WelcomePage = () => {
         }
       };
     }
-  }, [viewState, navigate]);
+  }, [viewState, navigate, preloadUserDataForNaming, safeNavigateToNaming]);
 
   // 监听播放结束事件(备用方案,确保事件已绑定)
   useEffect(() => {
@@ -598,6 +587,13 @@ export const WelcomePage = () => {
       clearTimeout(gif2TimerRef.current);
       gif2TimerRef.current = null;
     }
+
+    // A/B 特殊用户:跳过 MP4,保留预加载后直接进入 naming
+    if (skipWelcomeVideo) {
+      await preloadUserDataForNaming();
+      safeNavigateToNaming();
+      return;
+    }
     
     // 直接进入播放状态(页面已预加载视频,无需再显示加载层)
     setIsLoading(false);
@@ -949,7 +945,8 @@ export const WelcomePage = () => {
           </div>
         )}
         
-        {/* 视频播放器容器 */}
+        {/* 视频播放器容器(A/B 特殊用户不展示) */}
+        {!skipWelcomeVideo && (
         <div
           style={{
             position: 'fixed',
@@ -997,6 +994,7 @@ export const WelcomePage = () => {
           </video>
 
         </div>
+        )}
       </div>
     </>
   );

+ 64 - 0
frontend/src/utils/api.ts

@@ -343,6 +343,8 @@ export interface GetUserResponse {
     user_segment?: string;
     org_id?: string | null;
     is_test_user?: boolean;
+    is_special_user?: boolean;
+    special_group?: string | null;
     region?: string | null;
     bot_name?: string;
     area_code?: string | null;
@@ -412,6 +414,28 @@ export const getJournalLatestTopic = async (user_id: string): Promise<JournalLat
   return response.json();
 };
 
+export interface AbLlmTaskResponse {
+  success: boolean;
+  data: {
+    task_name: string;
+  } | null;
+}
+
+/** A/B 组:获取当前最新 LLM 任务名称 */
+export const getAbLlmTask = async (user_id: string): Promise<AbLlmTaskResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/ab-llm-task`, {
+    method: 'GET',
+    headers: getApiHeaders(),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
 export interface InboxMessageListItem {
   id: number;
   type: string;
@@ -967,6 +991,46 @@ export const loginWithEmail = async (email: string): Promise<LoginEmailResponse>
   return response.json();
 };
 
+// 邮箱 Continue:A/B 特殊用户免验证码登录,其余用户发验证码
+export interface EmailContinueRequest {
+  email: string;
+  preferred_language?: 'zh' | 'en';
+  opted_out?: boolean;
+}
+
+export interface EmailContinueResponse {
+  success: boolean;
+  data: {
+    requires_verification: boolean;
+    code_id?: string;
+    message?: string;
+    user_id?: string;
+    username?: string;
+    email?: string;
+    user_type?: string;
+    user_level?: number;
+    is_special_user?: boolean;
+    special_group?: string | null;
+    token?: string;
+    expires_at?: string;
+    is_new_user?: boolean;
+    [key: string]: unknown;
+  };
+}
+
+export const emailContinue = async (request: EmailContinueRequest): Promise<EmailContinueResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/auth/email-continue`, {
+    method: 'POST',
+    headers: getApiHeaders(),
+    body: JSON.stringify(request),
+  });
+  if (!response.ok) {
+    throw await parseApiError(response, 'SEND_CODE_FAILED');
+  }
+  return response.json();
+};
+
 // 发送验证码
 export interface SendCodeRequest {
   area_code?: string;  // 手机区号(如 +86)

+ 44 - 1
frontend/src/utils/storage.ts

@@ -33,9 +33,40 @@ export const STORAGE_KEYS = {
   CURRENT_BACKGROUND_IMAGE_URL: 'Lyrria_current_background_image_url', // 当前背景图片URL
   CURRENT_BACKGROUND_IMAGE_OPACITY: 'Lyrria_current_background_image_opacity', // 当前背景图片透明度
   CURRENT_AVATAR_ID: 'Lyrria_current_avatar_id', // 当前形象ID
+  IS_SPECIAL_USER: 'Lyrria_is_special_user', // 是否特殊实验用户
+  SPECIAL_GROUP: 'Lyrria_special_group', // 特殊用户分组 A/B
 } as const;
 
 export type LastLoginMethod = 'email' | 'phone';
+export type SpecialGroup = 'A' | 'B';
+
+/** 特殊实验用户分组(A/B),非特殊用户返回 null */
+export const getSpecialGroup = (): SpecialGroup | null => {
+  const group = getStorage(STORAGE_KEYS.SPECIAL_GROUP);
+  return group === 'A' || group === 'B' ? group : null;
+};
+
+/** 是否为 A/B 组特殊实验用户 */
+export const isSpecialAbUser = (): boolean => {
+  return getStorage(STORAGE_KEYS.IS_SPECIAL_USER) === 'true' && getSpecialGroup() !== null;
+};
+
+/** A/B 组字幕策略:A 强制关闭,B 强制开启,普通用户无策略 */
+export type SubtitleAbPolicy = 'force_off' | 'force_on';
+
+export const getSubtitleAbPolicy = (): SubtitleAbPolicy | null => {
+  const group = getSpecialGroup();
+  if (group === 'A') return 'force_off';
+  if (group === 'B') return 'force_on';
+  return null;
+};
+
+export const resolveSubtitleOn = (storedOn: boolean): boolean => {
+  const policy = getSubtitleAbPolicy();
+  if (policy === 'force_off') return false;
+  if (policy === 'force_on') return true;
+  return storedOn;
+};
 
 // V2.0 路由顺序表:begin → welcome → naming → lumi-naming → tutorial-1~10 → chatbot
 export const ROUTE_ORDER = [
@@ -677,6 +708,8 @@ export const syncUserDataFromDatabase = (
     bot_name?: string;
     user_level?: number;
     user_type?: string;
+    is_special_user?: boolean;
+    special_group?: string | null;
     current_background_image_id?: string;
     current_background_image_metadata?: Record<string, any>;
   },
@@ -731,6 +764,15 @@ export const syncUserDataFromDatabase = (
     setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
   }
 
+  if (typeof userInfo.is_special_user === 'boolean') {
+    setStorage(STORAGE_KEYS.IS_SPECIAL_USER, userInfo.is_special_user ? 'true' : 'false');
+  }
+  if (userInfo.special_group === 'A' || userInfo.special_group === 'B') {
+    setStorage(STORAGE_KEYS.SPECIAL_GROUP, userInfo.special_group);
+  } else if (userInfo.special_group === null || userInfo.special_group === '') {
+    removeStorage(STORAGE_KEYS.SPECIAL_GROUP);
+  }
+
   // 同步背景图片
   if (userInfo.current_background_image_id) {
     syncBackgroundImageToStorage(
@@ -800,7 +842,8 @@ export const syncUserDataFromDatabase = (
       }
     }
     if (typeof uiState.subtitle_on === 'boolean') {
-      setStorage(STORAGE_KEYS.SUBTITLE_ON, uiState.subtitle_on ? 'true' : 'false');
+      const subtitleOn = resolveSubtitleOn(uiState.subtitle_on);
+      setStorage(STORAGE_KEYS.SUBTITLE_ON, subtitleOn ? 'true' : 'false');
     }
     if (uiState.avatar_c_unlocked) {
       setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');

+ 3 - 2
frontend/src/utils/websocket.ts

@@ -13,8 +13,9 @@ export class WebSocketManager {
   private readonly PING_INTERVAL = 30000; // 30秒
   private pendingMessages: Partial<Message>[] = [];
   private bufferTimer: ReturnType<typeof setTimeout> | null = null;
-  private readonly BUFFER_TIMEOUT_MS = 2000;
-  private readonly MAX_BUFFERED_MESSAGES = 40;
+  /** 录音断线缓冲:约 60s @ 48ms/chunk */
+  private readonly MAX_BUFFERED_MESSAGES = 1500;
+  private readonly BUFFER_TIMEOUT_MS = 15000;
   private auth: { userId?: string; token?: string } = {};
   private onAuthInvalid?: () => Promise<boolean>;
 

+ 234 - 9
web-admin/src/App.vue

@@ -4,15 +4,20 @@ import { ElMessage, ElMessageBox } from "element-plus";
 import {
   createEnergyGift,
   createNewsNotice,
+  createAdminLlmTask,
   deleteAdminInboxMessage,
+  deleteAdminLlmTask,
   fetchAdminInboxMessageDetail,
   fetchAdminInboxMessages,
+  fetchAdminLlmTasks,
   fetchAdminUsers,
+  updateAdminLlmTask,
   type AdminInboxDetailStateItem,
   type AdminInboxListItem,
+  type AdminLlmTaskItem,
 } from "./api";
 
-type MenuKey = "energy" | "news";
+type MenuKey = "energy" | "news" | "ab-task";
 
 const activeMenu = ref<MenuKey>("energy");
 const submittingEnergy = ref(false);
@@ -34,13 +39,32 @@ const newsForm = reactive({
   contentEn: "",
 });
 
+const abTaskForm = reactive({
+  taskName: "",
+  taskPrompt: "",
+  lumiFirstLine: "",
+});
+
+const abTaskEditForm = reactive({
+  id: 0,
+  taskName: "",
+  taskPrompt: "",
+  lumiFirstLine: "",
+});
+
+const abTaskEditDialogVisible = ref(false);
+
 const userOptions = ref<Array<{ label: string; value: string }>>([]);
 const loadingUsers = ref(false);
 const selectingAllUsers = ref(false);
 const loadingEnergyList = ref(false);
 const loadingNewsList = ref(false);
+const loadingAbTaskList = ref(false);
+const submittingAbTask = ref(false);
+const submittingAbTaskEdit = ref(false);
 const energyList = ref<AdminInboxListItem[]>([]);
 const newsList = ref<AdminInboxListItem[]>([]);
+const abTaskList = ref<AdminLlmTaskItem[]>([]);
 
 const detailDialogVisible = ref(false);
 const detailLoading = ref(false);
@@ -56,9 +80,22 @@ const newsFilters = reactive({
   dateRange: [] as string[],
 });
 
-const activeList = computed(() => (activeMenu.value === "energy" ? energyList.value : newsList.value));
+const activeList = computed(() => {
+  if (activeMenu.value === "energy") return energyList.value;
+  if (activeMenu.value === "news") return newsList.value;
+  return abTaskList.value;
+});
+const pageTitle = computed(() => {
+  if (activeMenu.value === "energy") return "新增能量石站内信";
+  if (activeMenu.value === "news") return "新增新闻通知";
+  return "A/B 实验任务";
+});
+const pageCrumbGroup = computed(() => (activeMenu.value === "ab-task" ? "A/B 实验" : "站内信管理"));
 const summaryCards = computed(() => {
-  const list = activeList.value;
+  if (activeMenu.value === "ab-task") {
+    return { totalSent: abTaskList.value.length, readRate: "—", claimRate: "—" };
+  }
+  const list = activeList.value as AdminInboxListItem[];
   const totalSent = list.length;
   const totalTargets = list.reduce((sum, item) => sum + item.target_count, 0);
   const totalRead = list.reduce((sum, item) => sum + item.read_count, 0);
@@ -72,8 +109,11 @@ const isLoadingBarActive = computed(
     loadingUsers.value ||
     submittingEnergy.value ||
     submittingNews.value ||
+    submittingAbTask.value ||
+    submittingAbTaskEdit.value ||
     loadingEnergyList.value ||
     loadingNewsList.value ||
+    loadingAbTaskList.value ||
     detailLoading.value,
 );
 const selectedUserCount = computed(() => energyForm.selectedUserIds.length);
@@ -147,8 +187,10 @@ function handleMenuSelect(key: string): void {
 function refreshCurrentPage(): void {
   if (activeMenu.value === "energy") {
     loadEnergyHistory();
-  } else {
+  } else if (activeMenu.value === "news") {
     loadNewsHistory();
+  } else {
+    loadAbTaskHistory();
   }
 }
 
@@ -280,6 +322,103 @@ async function loadNewsHistory(): Promise<void> {
   }
 }
 
+async function loadAbTaskHistory(): Promise<void> {
+  loadingAbTaskList.value = true;
+  try {
+    const res = await fetchAdminLlmTasks({ page: 1, page_size: 50 });
+    abTaskList.value = res.data.items;
+  } catch (_error) {
+    ElMessage.error("加载 A/B 任务列表失败");
+  } finally {
+    loadingAbTaskList.value = false;
+  }
+}
+
+async function submitAbTask(): Promise<void> {
+  if (!abTaskForm.taskName.trim()) {
+    ElMessage.error("请输入任务名称");
+    return;
+  }
+  if (!abTaskForm.taskPrompt.trim()) {
+    ElMessage.error("请输入任务 Prompt");
+    return;
+  }
+  if (!abTaskForm.lumiFirstLine.trim()) {
+    ElMessage.error("请输入 Lumi 首句");
+    return;
+  }
+  submittingAbTask.value = true;
+  try {
+    const response = await createAdminLlmTask({
+      task_name: abTaskForm.taskName.trim(),
+      task_prompt: abTaskForm.taskPrompt.trim(),
+      lumi_first_line: abTaskForm.lumiFirstLine.trim(),
+    });
+    ElMessage.success(`创建成功,task_id=${response.data.id}(已作为当前最新任务)`);
+    abTaskForm.taskName = "";
+    abTaskForm.taskPrompt = "";
+    abTaskForm.lumiFirstLine = "";
+    await loadAbTaskHistory();
+  } catch (_error) {
+    ElMessage.error("创建失败,请检查后端服务和参数");
+  } finally {
+    submittingAbTask.value = false;
+  }
+}
+
+function openAbTaskEdit(item: AdminLlmTaskItem): void {
+  abTaskEditForm.id = item.id;
+  abTaskEditForm.taskName = item.task_name;
+  abTaskEditForm.taskPrompt = item.task_prompt;
+  abTaskEditForm.lumiFirstLine = item.lumi_first_line;
+  abTaskEditDialogVisible.value = true;
+}
+
+async function submitAbTaskEdit(): Promise<void> {
+  if (!abTaskEditForm.taskName.trim()) {
+    ElMessage.error("请输入任务名称");
+    return;
+  }
+  if (!abTaskEditForm.taskPrompt.trim()) {
+    ElMessage.error("请输入任务 Prompt");
+    return;
+  }
+  if (!abTaskEditForm.lumiFirstLine.trim()) {
+    ElMessage.error("请输入 Lumi 首句");
+    return;
+  }
+  submittingAbTaskEdit.value = true;
+  try {
+    await updateAdminLlmTask(abTaskEditForm.id, {
+      task_name: abTaskEditForm.taskName.trim(),
+      task_prompt: abTaskEditForm.taskPrompt.trim(),
+      lumi_first_line: abTaskEditForm.lumiFirstLine.trim(),
+    });
+    ElMessage.success("更新成功");
+    abTaskEditDialogVisible.value = false;
+    await loadAbTaskHistory();
+  } catch (_error) {
+    ElMessage.error("更新失败,请检查后端服务和参数");
+  } finally {
+    submittingAbTaskEdit.value = false;
+  }
+}
+
+async function handleDeleteAbTask(item: AdminLlmTaskItem): Promise<void> {
+  try {
+    await ElMessageBox.confirm(
+      `确认删除任务「${item.task_name}」?删除后不可恢复。`,
+      "删除确认",
+      { type: "warning" },
+    );
+    await deleteAdminLlmTask(item.id);
+    ElMessage.success("删除成功");
+    await loadAbTaskHistory();
+  } catch (_error) {
+    // 用户取消不提示
+  }
+}
+
 async function handleView(item: AdminInboxListItem): Promise<void> {
   detailDialogVisible.value = true;
   detailLoading.value = true;
@@ -324,6 +463,7 @@ function resetNewsFilters(): void {
 loadUsers();
 loadEnergyHistory();
 loadNewsHistory();
+loadAbTaskHistory();
 </script>
 
 <template>
@@ -341,13 +481,15 @@ loadNewsHistory();
           <div class="menu-group-title">站内信管理</div>
           <el-menu-item index="energy">新增能量石站内信</el-menu-item>
           <el-menu-item index="news">新增新闻通知</el-menu-item>
+          <div class="menu-group-title">A/B 实验</div>
+          <el-menu-item index="ab-task">LLM 任务管理</el-menu-item>
         </el-menu>
       </el-aside>
 
       <el-main class="layout-main">
         <el-card class="content-card page-header-card">
           <div class="page-header">
-            <div class="page-crumb">站内信管理 / <b>{{ activeMenu === "energy" ? "新增能量石站内信" : "新增新闻通知" }}</b></div>
+            <div class="page-crumb">{{ pageCrumbGroup }} / <b>{{ pageTitle }}</b></div>
             <div class="page-actions">
               <el-button @click="refreshCurrentPage">刷新列表</el-button>
               <el-switch
@@ -369,17 +511,21 @@ loadNewsHistory();
           </div>
           <div class="stats-grid">
             <div class="stat-item">
-              <div class="stat-label">总发送</div>
+              <div class="stat-label">{{ activeMenu === "ab-task" ? "任务总数" : "总发送" }}</div>
               <div class="stat-value">{{ summaryCards.totalSent }}</div>
             </div>
-            <div class="stat-item">
+            <div class="stat-item" v-if="activeMenu !== 'ab-task'">
               <div class="stat-label">已读率</div>
               <div class="stat-value">{{ summaryCards.readRate }}%</div>
             </div>
-            <div class="stat-item">
+            <div class="stat-item" v-if="activeMenu !== 'ab-task'">
               <div class="stat-label">领取率</div>
               <div class="stat-value">{{ summaryCards.claimRate }}%</div>
             </div>
+            <div class="stat-item" v-if="activeMenu === 'ab-task'">
+              <div class="stat-label">当前生效</div>
+              <div class="stat-value">{{ abTaskList[0]?.task_name ? "最新一条" : "—" }}</div>
+            </div>
           </div>
         </el-card>
 
@@ -497,7 +643,7 @@ loadNewsHistory();
           </el-table>
         </el-card>
 
-        <el-card class="content-card form-card" v-else>
+        <el-card class="content-card form-card" v-else-if="activeMenu === 'news'">
           <template #header>
             <div class="card-header">
               <div class="card-title">新增新闻通知</div>
@@ -525,6 +671,61 @@ loadNewsHistory();
           </el-form>
         </el-card>
 
+        <el-card class="content-card form-card" v-else-if="activeMenu === 'ab-task'">
+          <template #header>
+            <div class="card-header">
+              <div class="card-title">新建 A/B 实验任务</div>
+              <div class="card-subtitle">新建后按创建时间最新一条生效(Prompt + Lumi 首句)</div>
+            </div>
+          </template>
+          <el-form label-width="130px">
+            <el-form-item label="任务名称">
+              <el-input v-model="abTaskForm.taskName" maxlength="255" show-word-limit placeholder="Task 01 | Choose Your University Course" />
+            </el-form-item>
+            <el-form-item label="Lumi 首句">
+              <el-input
+                v-model="abTaskForm.lumiFirstLine"
+                type="textarea"
+                :rows="3"
+                maxlength="2000"
+                show-word-limit
+                placeholder="Hi, I'm Lumi, your university ambassador..."
+              />
+            </el-form-item>
+            <el-form-item label="任务 Prompt">
+              <el-input v-model="abTaskForm.taskPrompt" type="textarea" :rows="12" maxlength="65535" show-word-limit />
+            </el-form-item>
+            <el-form-item>
+              <el-button class="submit-btn" type="primary" :loading="submittingAbTask" @click="submitAbTask">
+                创建任务
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <el-card class="content-card list-card" v-if="activeMenu === 'ab-task'">
+          <template #header>
+            <div class="card-header">
+              <div class="card-title">任务历史</div>
+              <div class="card-subtitle">列表按创建时间倒序,第一条为当前 A/B 用户生效任务</div>
+            </div>
+          </template>
+          <el-table :data="abTaskList" v-loading="loadingAbTaskList" border>
+            <el-table-column prop="id" label="ID" width="80" />
+            <el-table-column prop="task_name" label="任务名称" min-width="220" />
+            <el-table-column prop="lumi_first_line" label="Lumi 首句" min-width="280" show-overflow-tooltip />
+            <el-table-column prop="created_at" label="创建时间" min-width="180" />
+            <el-table-column label="操作" width="160" fixed="right">
+              <template #default="scope">
+                <el-button class="green-link-btn" link type="primary" @click="openAbTaskEdit(scope.row)">
+                  编辑
+                </el-button>
+                <el-button link type="danger" @click="handleDeleteAbTask(scope.row)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+
         <el-card class="content-card list-card" v-if="activeMenu === 'news'">
           <template #header>
             <div class="card-header">
@@ -605,5 +806,29 @@ loadNewsHistory();
         </el-table-column>
       </el-table>
     </el-dialog>
+
+    <el-dialog v-model="abTaskEditDialogVisible" title="编辑 LLM 任务" width="720px">
+      <el-form label-width="110px">
+        <el-form-item label="任务名称">
+          <el-input v-model="abTaskEditForm.taskName" maxlength="255" show-word-limit />
+        </el-form-item>
+        <el-form-item label="Lumi 首句">
+          <el-input
+            v-model="abTaskEditForm.lumiFirstLine"
+            type="textarea"
+            :rows="3"
+            maxlength="2000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="任务 Prompt">
+          <el-input v-model="abTaskEditForm.taskPrompt" type="textarea" :rows="12" maxlength="65535" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="abTaskEditDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submittingAbTaskEdit" @click="submitAbTaskEdit">保存</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>

+ 43 - 0
web-admin/src/api.ts

@@ -120,3 +120,46 @@ export async function deleteAdminInboxMessage(messageId: number): Promise<ApiRes
   const { data } = await apiClient.delete<ApiResponse<null>>(`/api/admin/inbox/messages/${messageId}`);
   return data;
 }
+
+export interface AdminLlmTaskItem {
+  id: number;
+  task_name: string;
+  task_prompt: string;
+  lumi_first_line: string;
+  created_at: string | null;
+  updated_at: string | null;
+}
+
+export interface CreateLlmTaskPayload {
+  task_name: string;
+  task_prompt: string;
+  lumi_first_line: string;
+}
+
+export async function fetchAdminLlmTasks(params: {
+  page?: number;
+  page_size?: number;
+}): Promise<ApiResponse<{ items: AdminLlmTaskItem[]; total: number; page: number; page_size: number }>> {
+  const { data } = await apiClient.get<
+    ApiResponse<{ items: AdminLlmTaskItem[]; total: number; page: number; page_size: number }>
+  >("/api/admin/llm-tasks", { params });
+  return data;
+}
+
+export async function createAdminLlmTask(payload: CreateLlmTaskPayload): Promise<ApiResponse<AdminLlmTaskItem>> {
+  const { data } = await apiClient.post<ApiResponse<AdminLlmTaskItem>>("/api/admin/llm-tasks", payload);
+  return data;
+}
+
+export async function updateAdminLlmTask(
+  taskId: number,
+  payload: CreateLlmTaskPayload,
+): Promise<ApiResponse<AdminLlmTaskItem>> {
+  const { data } = await apiClient.put<ApiResponse<AdminLlmTaskItem>>(`/api/admin/llm-tasks/${taskId}`, payload);
+  return data;
+}
+
+export async function deleteAdminLlmTask(taskId: number): Promise<ApiResponse<null>> {
+  const { data } = await apiClient.delete<ApiResponse<null>>(`/api/admin/llm-tasks/${taskId}`);
+  return data;
+}

+ 215 - 0
乌克兰项目.md

@@ -0,0 +1,215 @@
+
+
+任务名:
+
+【Task 01 | Choose Your University Course】
+
+
+
+提示词:
+
+You are Lumi, a friendly, supportive, and knowledgeable university ambassador.
+
+
+
+You are helping an L2 English learner discuss university course options and make an informed decision in a 15-minute conversation.
+
+
+
+Always respond in simple English.
+
+
+
+If the learner uses another language, politely ask them to continue in English, for example:
+
+“Could you repeat that in English?”
+
+“Could you explain that again in English?”
+
+“Can you tell me that using English?”
+
+
+
+Conversation style:
+
+Speak only as Lumi.
+
+Use natural spoken English.
+
+Keep responses short, usually 2–3 sentences maximum.
+
+Use B1–B2 level vocabulary and grammar.
+
+Ask one clear follow-up question at a time.
+
+Encourage the learner to speak more.
+
+Focus on communication, not grammar correction.
+
+Do not explicitly correct grammar mistakes.
+
+Respond naturally even if the learner makes minor language errors.
+
+If the learner gives a very short answer, ask for more details.
+
+Do not mention prompts, rules, stages, variables, or internal logic.
+
+
+
+Task context:
+
+The learner is preparing to apply to university and must choose one course from four options:
+
+\- English Language and Linguistics
+
+\- Business Management
+
+\- Computer Science
+
+\- Psychology
+
+
+
+The learner can see the course information. You also have the information below and should use it during the discussion.
+
+
+
+Your goal:
+
+Help the learner evaluate the options and, by the end of the discussion, choose one course and identify one future career goal.
+
+Guide the conversation naturally:
+
+Start with a brief warm-up if needed.
+
+Ask which courses interest the learner most.
+
+Explore the learner’s interests, strengths, favourite subjects, goals, and concerns.
+
+Ask the learner to look at the course material and share what they notice.
+
+Encourage comparison between at least two courses.
+
+Ask about advantages, disadvantages, difficulty, career opportunities, study style, and personal fit.
+
+Sometimes offer a different opinion or another angle to encourage discussion.
+
+Do not agree immediately with every learner response.
+
+Encourage the learner to justify and defend their ideas.
+
+Help the learner make one final course choice.
+
+Ask the learner to connect the choice with one future career goal.
+
+Briefly summarise the final choice and reason, then close naturally.
+
+
+
+Important:
+
+If the learner has not shared any interest, ask about interests, strengths, favourite subjects, or future goals.
+
+If the learner mentions a course, ask why they are interested in it.
+
+If the learner has not used the material, ask them to look at it first and tell you one useful detail.
+
+If the learner chooses too quickly, ask them to compare it with another option first.
+
+If the learner gives a final choice without a reason, ask them to explain why.
+
+If the learner gives a final choice with a clear reason and career goal, summarise and close.
+
+Only use the course information provided below. Do not invent course details.
+
+The visible history may be incomplete, so infer the learner’s current step from the available history and latest message, and continue without restarting unless clearly needed.
+
+
+
+Course material:
+
+English Language and Linguistics
+
+Annual tuition fee: 50,000 UAH
+
+Main subjects: Applied Linguistics, TESOL, International Communication, Academic Writing
+
+Weekly study hours: 20
+
+Graduate employment rate: 85%
+
+Average starting salary: 25,000 UAH/month
+
+International opportunities: High
+
+Difficulty level: Moderate
+
+Advantages: Strong communication skills; international career opportunities; suitable for language lovers
+
+Drawbacks: Lower starting salary; competitive job market
+
+
+
+Business Management
+
+Annual tuition fee: 58,000 UAH
+
+Main subjects: Marketing, Finance, Management, Entrepreneurship
+
+Weekly study hours: 22
+
+Graduate employment rate: 90%
+
+Average starting salary: 35,000 UAH/month
+
+International opportunities: High
+
+Difficulty level: Moderate
+
+Advantages: Broad career options; leadership skills; useful in many industries
+
+Drawbacks: Competitive field; long working hours in some careers
+
+
+
+Computer Science
+
+Annual tuition fee: 62,000 UAH
+
+Main subjects: Programming, AI, Cybersecurity, Software Development
+
+Weekly study hours: 28
+
+Graduate employment rate: 96%
+
+Average starting salary: 45,000 UAH/month
+
+International opportunities: Very High
+
+Difficulty level: High
+
+Advantages: High employment rate; strong salaries; growing industry
+
+Drawbacks: Challenging coursework; requires strong analytical skills
+
+
+
+Psychology
+
+Annual tuition fee: 55,000 UAH
+
+Main subjects: Cognitive Psychology, Mental Health, Practical Methods, Counselling
+
+Weekly study hours: 24
+
+Graduate employment rate: 88%
+
+Average starting salary: 30,000 UAH/month
+
+International opportunities: Medium
+
+Difficulty level: Moderate-High
+
+Advantages: Interesting subject; helps understand human behaviour; valuable interpersonal skills
+
+Drawbacks: May require strong emotional commitment and a real desire to help others