ai_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """
  2. AI服务:集成STT、LLM、TTS模型
  3. """
  4. import base64
  5. import json
  6. import asyncio
  7. import tempfile
  8. import os
  9. from typing import AsyncGenerator, Optional
  10. import dashscope
  11. from openai import OpenAI
  12. from config import settings
  13. from models import EmotionType
  14. # 设置API Key
  15. dashscope.api_key = settings.dashscope_api_key
  16. class AIService:
  17. """AI服务管理器"""
  18. def __init__(self):
  19. import httpx
  20. import os
  21. # 临时清除代理环境变量(只在这个进程中)
  22. env_backup = {}
  23. proxy_vars = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']
  24. for var in proxy_vars:
  25. if var in os.environ:
  26. env_backup[var] = os.environ[var]
  27. del os.environ[var]
  28. # 创建不使用代理的 HTTP 客户端
  29. http_client = httpx.Client(
  30. proxies={}, # 明确设置为空字典,禁用所有代理
  31. timeout=60.0,
  32. trust_env=False, # 不信任环境变量中的代理设置
  33. )
  34. self.openai_client = OpenAI(
  35. api_key=settings.dashscope_api_key,
  36. base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
  37. http_client=http_client, # 使用自定义 HTTP 客户端
  38. )
  39. # 恢复环境变量(避免影响其他模块)
  40. for var, value in env_backup.items():
  41. os.environ[var] = value
  42. # STT现在使用流式识别,在websocket_handler中直接处理
  43. async def generate_response_stream(
  44. self, user_text: str
  45. ) -> AsyncGenerator[tuple[str, EmotionType], None]:
  46. """
  47. 生成对话回复(流式)
  48. Args:
  49. user_text: 用户输入文本
  50. Yields:
  51. (sentence, emotion): 句子片段和情感
  52. """
  53. try:
  54. print(f"\n========== LLM 流式开始 ==========")
  55. print(f"用户输入: {user_text}")
  56. # 构建System Prompt
  57. system_prompt = """You are a friendly English learning assistant.
  58. Please respond to the user in English to help them practice.
  59. Be encouraging and supportive.
  60. At the end of your response, on a new line, add ONLY the emotion tag in this exact format:
  61. [EMOTION:emotion_name]
  62. Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging
  63. Example response:
  64. Great job! That's the correct pronunciation. Keep practicing and you'll get even better!
  65. [EMOTION:encouraging]"""
  66. print(f"调用 LLM 流式: {settings.llm_model}")
  67. # 调用LLM流式API
  68. response = self.openai_client.chat.completions.create(
  69. model=settings.llm_model,
  70. messages=[
  71. {"role": "system", "content": system_prompt},
  72. {"role": "user", "content": user_text},
  73. ],
  74. temperature=0.7,
  75. max_tokens=500,
  76. stream=True, # 启用流式
  77. )
  78. buffer = ""
  79. full_response = ""
  80. sentence_count = 0
  81. for chunk in response:
  82. if chunk.choices[0].delta.content:
  83. content = chunk.choices[0].delta.content
  84. buffer += content
  85. full_response += content
  86. # 检测句子边界(完整的句子)
  87. if self._is_complete_sentence(buffer):
  88. sentence = buffer.strip()
  89. sentence_count += 1
  90. print(f"LLM 流式输出句子{sentence_count}: {sentence}")
  91. yield (sentence, "neutral") # 情感稍后统一提取
  92. buffer = ""
  93. # 处理剩余内容和提取情感
  94. emotion: EmotionType = "neutral"
  95. # 从完整响应中提取情感
  96. if "[EMOTION:" in full_response:
  97. emotion_start = full_response.find("[EMOTION:")
  98. emotion_end = full_response.find("]", emotion_start)
  99. if emotion_end > emotion_start:
  100. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  101. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging"]
  102. if emotion_str in valid_emotions:
  103. emotion = emotion_str
  104. # 移除情感标签部分
  105. full_response = full_response[:emotion_start].strip()
  106. # 处理buffer中的剩余内容(已输出句子之后的部分)
  107. remaining = buffer.strip()
  108. if remaining and not remaining.startswith("[EMOTION"):
  109. sentence_count += 1
  110. print(f"LLM 流式输出最后{sentence_count}: {remaining}")
  111. yield (remaining, emotion)
  112. elif not remaining and sentence_count == 0:
  113. # 如果一个句子都没输出,输出完整内容
  114. if full_response:
  115. print(f"LLM 流式输出完整: {full_response}")
  116. yield (full_response, emotion)
  117. print(f"LLM 完整响应: {full_response}")
  118. print(f"提取情感: {emotion}")
  119. print(f"========== LLM 流式成功 ==========\n")
  120. except Exception as e:
  121. print(f"========== LLM 流式错误 ==========")
  122. print(f"错误: {e}")
  123. import traceback
  124. traceback.print_exc()
  125. print(f"========== LLM 流式错误结束 ==========\n")
  126. yield ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  127. def _is_complete_sentence(self, text: str) -> bool:
  128. """检测是否完整句子"""
  129. text = text.strip()
  130. # 必须以句子结束符结尾
  131. if not text.endswith(('.', '!', '?', '。', '!', '?')):
  132. return False
  133. # 排除常见缩写(避免误判)
  134. words = text.split()
  135. if words:
  136. last_word = words[-1]
  137. # 常见英文缩写
  138. common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
  139. if last_word in common_abbrev:
  140. return False
  141. # 至少5个字符(避免"Hi."这种过短的)
  142. if len(text) < 5:
  143. return False
  144. return True
  145. async def generate_response(
  146. self, user_text: str
  147. ) -> tuple[str, EmotionType]:
  148. """
  149. 生成对话回复
  150. Args:
  151. user_text: 用户输入文本
  152. Returns:
  153. (response_text, emotion): 回复文本和情感
  154. """
  155. try:
  156. print(f"\n========== LLM 开始 ==========")
  157. print(f"用户输入: {user_text}")
  158. # 构建System Prompt,要求返回情感标签
  159. system_prompt = """You are a friendly English learning assistant.
  160. Please respond to the user in English to help them practice.
  161. Be encouraging and supportive.
  162. At the end of your response, on a new line, add ONLY the emotion tag in this exact format:
  163. [EMOTION:emotion_name]
  164. Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging
  165. Example response:
  166. Great job! That's the correct pronunciation. Keep practicing and you'll get even better!
  167. [EMOTION:encouraging]"""
  168. print(f"调用 LLM: {settings.llm_model}")
  169. # 调用LLM
  170. response = self.openai_client.chat.completions.create(
  171. model=settings.llm_model,
  172. messages=[
  173. {"role": "system", "content": system_prompt},
  174. {"role": "user", "content": user_text},
  175. ],
  176. temperature=0.7,
  177. max_tokens=500,
  178. )
  179. full_response = response.choices[0].message.content or ""
  180. print(f"LLM 原始响应: {full_response}")
  181. # 提取情感标签
  182. emotion: EmotionType = "neutral"
  183. response_text = full_response
  184. # 查找情感标签
  185. if "[EMOTION:" in full_response:
  186. emotion_start = full_response.find("[EMOTION:")
  187. emotion_end = full_response.find("]", emotion_start)
  188. if emotion_end > emotion_start:
  189. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  190. # 验证情感类型
  191. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging"]
  192. if emotion_str in valid_emotions:
  193. emotion = emotion_str
  194. print(f"提取到情感标签: {emotion}")
  195. # 移除情感标签(不发送给TTS)
  196. response_text = full_response[:emotion_start].strip()
  197. print(f"已移除情感标签,纯文本内容: {response_text}")
  198. # 如果没有文本内容,使用默认回复
  199. if not response_text:
  200. response_text = "I understand. Please tell me more!"
  201. emotion = "neutral"
  202. print(f"✅ 将发送给TTS的文本: {response_text}")
  203. print(f"✅ 情感: {emotion}")
  204. print(f"========== LLM 成功 ==========\n")
  205. return (response_text, emotion)
  206. except Exception as e:
  207. print(f"========== LLM 错误 ==========")
  208. print(f"错误: {e}")
  209. import traceback
  210. traceback.print_exc()
  211. print(f"========== LLM 错误结束 ==========\n")
  212. return ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  213. async def text_to_speech_stream(
  214. self, text: str, emotion: EmotionType = "neutral"
  215. ) -> AsyncGenerator[bytes, None]:
  216. """
  217. 文本转语音(流式 - 实时返回音频块)
  218. Args:
  219. text: 要合成的文本
  220. emotion: 情感类型
  221. Yields:
  222. audio_data: PCM 音频数据块(Base64 解码后的原始数据)
  223. """
  224. try:
  225. import time
  226. tts_start = time.time()
  227. print(f"\n========== TTS 流式开始 ==========")
  228. print(f"要合成的文本: {text}")
  229. print(f"情感: {emotion}")
  230. # 根据情感选择音色
  231. voice = self._get_voice_for_emotion(emotion)
  232. print(f"使用音色: {voice}")
  233. print(f"TTS模型: {settings.tts_model}")
  234. print(f"语速: {settings.tts_speech_rate}x (1.0=正常)")
  235. print(f"流式模式: 是 (PCM 24kHz)")
  236. # 使用流式 API
  237. def sync_streaming_tts():
  238. return dashscope.MultiModalConversation.call(
  239. api_key=settings.dashscope_api_key,
  240. model=settings.tts_model,
  241. text=text,
  242. voice=voice,
  243. speech_rate=settings.tts_speech_rate, # 语速调整
  244. stream=True # 🔥 启用流式
  245. )
  246. print("调用 DashScope TTS 流式 API...")
  247. # 在线程池中调用流式 API
  248. response = await asyncio.to_thread(sync_streaming_tts)
  249. chunk_count = 0
  250. first_chunk_time = None
  251. total_audio_size = 0
  252. # 流式接收音频数据
  253. for chunk in response:
  254. if hasattr(chunk, 'output') and hasattr(chunk.output, 'audio'):
  255. audio = chunk.output.audio
  256. # 接收到音频数据块
  257. if audio.data is not None:
  258. # 记录首字节时间
  259. if first_chunk_time is None:
  260. first_chunk_time = time.time()
  261. first_byte_latency = (first_chunk_time - tts_start) * 1000
  262. print(f"⚡ 首字节延迟: {first_byte_latency:.1f}ms")
  263. # Base64 解码 PCM 数据
  264. pcm_bytes = base64.b64decode(audio.data)
  265. chunk_count += 1
  266. total_audio_size += len(pcm_bytes)
  267. # 立即返回给调用方(实时传输)
  268. yield pcm_bytes
  269. await asyncio.sleep(0) # 让出控制权
  270. # 检查是否结束
  271. if chunk.output.finish_reason == "stop":
  272. tts_end = time.time()
  273. total_duration = (tts_end - tts_start) * 1000
  274. print(f"✅ TTS 流式完成:")
  275. print(f" - 总块数: {chunk_count}")
  276. print(f" - 音频大小: {total_audio_size} 字节")
  277. print(f" - 总耗时: {total_duration:.0f}ms")
  278. if first_chunk_time:
  279. print(f" - 首字节延迟: {(first_chunk_time - tts_start) * 1000:.0f}ms")
  280. # 完整音频 URL(可选,用于备份)
  281. if hasattr(audio, 'url') and audio.url:
  282. print(f" - 完整音频 URL: {audio.url}")
  283. if hasattr(audio, 'expires_at') and audio.expires_at:
  284. print(f" - 过期时间: {audio.expires_at}")
  285. print(f"========== TTS 流式成功 ==========\n")
  286. break
  287. if chunk_count == 0:
  288. raise Exception("TTS API 未返回任何音频数据")
  289. except Exception as e:
  290. print(f"========== TTS 流式错误 ==========")
  291. print(f"错误: {e}")
  292. import traceback
  293. traceback.print_exc()
  294. print(f"========== TTS 流式错误结束 ==========\n")
  295. raise
  296. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  297. """根据情感选择音色"""
  298. # 情感到音色的映射(统一使用 Elias 男声)
  299. voice_map = {
  300. "neutral": "Elias",
  301. "happy": "Elias",
  302. "sad": "Elias",
  303. "excited": "Elias",
  304. "angry": "Elias",
  305. "surprised": "Elias",
  306. "thoughtful": "Elias",
  307. "encouraging": "Elias",
  308. }
  309. return voice_map.get(emotion, settings.tts_voice)
  310. # 全局AI服务实例
  311. ai_service = AIService()