ai_service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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: 音频数据块
  223. """
  224. try:
  225. print(f"\n========== TTS 开始 ==========")
  226. print(f"要合成的文本: {text}")
  227. print(f"情感: {emotion}")
  228. # 根据情感选择音色或参数
  229. voice = self._get_voice_for_emotion(emotion)
  230. print(f"使用音色: {voice}")
  231. print(f"TTS模型: {settings.tts_model}")
  232. # 在线程池中执行同步调用,避免事件循环冲突
  233. # 添加重试机制以应对网络不稳定
  234. def sync_tts():
  235. max_retries = 3
  236. retry_delay = 1 # 秒
  237. for attempt in range(max_retries):
  238. try:
  239. return dashscope.audio.qwen_tts.SpeechSynthesizer.call(
  240. model=settings.tts_model,
  241. text=text,
  242. voice=voice,
  243. )
  244. except Exception as e:
  245. if attempt < max_retries - 1:
  246. print(f"TTS调用失败(尝试 {attempt + 1}/{max_retries}): {e}")
  247. print(f"等待{retry_delay}秒后重试...")
  248. import time
  249. time.sleep(retry_delay)
  250. retry_delay *= 2 # 指数退避
  251. else:
  252. raise
  253. print("调用 DashScope TTS API...")
  254. # 调用TTS API
  255. response = await asyncio.to_thread(sync_tts)
  256. print(f"TTS API 响应类型: {type(response)}")
  257. # 获取音频数据
  258. # response.output 包含 audio 字典,其中有 data、url、id、expires_at
  259. audio_bytes = None
  260. if hasattr(response, 'output'):
  261. output = response.output
  262. print(f"output 类型: {type(output)}")
  263. # 打印finish_reason用于调试
  264. if isinstance(output, dict):
  265. print(f"finish_reason: {output.get('finish_reason', 'N/A')}")
  266. audio_info = output.get('audio', {})
  267. elif hasattr(output, 'finish_reason'):
  268. print(f"finish_reason: {output.finish_reason}")
  269. audio_info = output.get('audio', {}) if isinstance(output, dict) else getattr(output, 'audio', {})
  270. else:
  271. audio_info = {}
  272. # 检查是否有URL(新版API返回URL)
  273. if isinstance(audio_info, dict) and 'url' in audio_info and audio_info['url']:
  274. audio_url = audio_info['url']
  275. print(f"从URL下载音频: {audio_url}")
  276. # 下载音频数据
  277. import aiohttp
  278. async with aiohttp.ClientSession() as session:
  279. async with session.get(audio_url) as resp:
  280. if resp.status == 200:
  281. audio_bytes = await resp.read()
  282. print(f"音频下载成功,大小: {len(audio_bytes)} 字节")
  283. else:
  284. raise Exception(f"下载音频失败,HTTP状态码: {resp.status}")
  285. # 检查是否有直接的数据(旧版API)
  286. elif isinstance(audio_info, dict) and 'data' in audio_info and audio_info['data']:
  287. audio_data = audio_info['data']
  288. if isinstance(audio_data, bytes):
  289. audio_bytes = audio_data
  290. print(f"音频数据(bytes): {len(audio_bytes)} 字节")
  291. elif isinstance(audio_data, str):
  292. audio_bytes = base64.b64decode(audio_data)
  293. print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
  294. # 如果audio_info直接是bytes或str
  295. elif isinstance(audio_info, bytes):
  296. audio_bytes = audio_info
  297. print(f"音频数据(bytes): {len(audio_bytes)} 字节")
  298. elif isinstance(audio_info, str):
  299. audio_bytes = base64.b64decode(audio_info)
  300. print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
  301. if audio_bytes:
  302. # 将音频数据分块返回
  303. chunk_size = 3200 # 每块3200字节
  304. chunk_count = (len(audio_bytes) + chunk_size - 1) // chunk_size
  305. print(f"将分为 {chunk_count} 个数据块发送")
  306. for i in range(0, len(audio_bytes), chunk_size):
  307. chunk = audio_bytes[i:i + chunk_size]
  308. yield chunk
  309. await asyncio.sleep(0) # 让出控制权
  310. print(f"========== TTS 成功 ==========\n")
  311. else:
  312. error_msg = "TTS API未返回音频数据"
  313. print(f"错误: {error_msg}")
  314. print(f"========== TTS 失败 ==========\n")
  315. raise Exception(error_msg)
  316. except Exception as e:
  317. print(f"========== TTS 错误 ==========")
  318. print(f"错误: {e}")
  319. import traceback
  320. traceback.print_exc()
  321. print(f"========== TTS 错误结束 ==========\n")
  322. raise
  323. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  324. """根据情感选择音色"""
  325. # 情感到音色的映射
  326. voice_map = {
  327. "neutral": "Cherry",
  328. "happy": "Cherry",
  329. "sad": "Cherry",
  330. "excited": "Cherry",
  331. "angry": "Cherry",
  332. "surprised": "Cherry",
  333. "thoughtful": "Cherry",
  334. "encouraging": "Cherry",
  335. }
  336. return voice_map.get(emotion, settings.tts_voice)
  337. # 全局AI服务实例
  338. ai_service = AIService()