|
|
@@ -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()
|