ai_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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. proxy=None, # 禁用代理
  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 = self._build_system_prompt()
  58. print(f"调用 LLM 流式: {settings.llm_model}")
  59. print(f"学习者水平: {settings.LEARNER_PROFICIENCY}, 年龄组: {settings.AGE_TAG}, 主题: {settings.TOPIC_TAG}, 模式: {settings.EXPERIMENT_TAG}")
  60. # 调用LLM流式API
  61. response = self.openai_client.chat.completions.create(
  62. model=settings.llm_model,
  63. messages=[
  64. {"role": "system", "content": system_prompt},
  65. {"role": "user", "content": user_text},
  66. ],
  67. temperature=0.7,
  68. max_tokens=500,
  69. stream=True, # 启用流式
  70. )
  71. buffer = ""
  72. full_response = ""
  73. sentence_count = 0
  74. for chunk in response:
  75. if chunk.choices[0].delta.content:
  76. content = chunk.choices[0].delta.content
  77. buffer += content
  78. full_response += content
  79. # 检测句子边界(完整的句子)
  80. if self._is_complete_sentence(buffer):
  81. sentence = buffer.strip()
  82. sentence_count += 1
  83. print(f"LLM 流式输出句子{sentence_count}: {sentence}")
  84. yield (sentence, "neutral") # 情感稍后统一提取
  85. buffer = ""
  86. # 处理剩余内容和提取情感
  87. emotion: EmotionType = "neutral"
  88. # 从完整响应中提取情感
  89. if "[EMOTION:" in full_response:
  90. emotion_start = full_response.find("[EMOTION:")
  91. emotion_end = full_response.find("]", emotion_start)
  92. if emotion_end > emotion_start:
  93. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  94. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging"]
  95. if emotion_str in valid_emotions:
  96. emotion = emotion_str
  97. # 移除情感标签部分
  98. full_response = full_response[:emotion_start].strip()
  99. # 处理buffer中的剩余内容(已输出句子之后的部分)
  100. remaining = buffer.strip()
  101. if remaining and not remaining.startswith("[EMOTION"):
  102. sentence_count += 1
  103. print(f"LLM 流式输出最后{sentence_count}: {remaining}")
  104. yield (remaining, emotion)
  105. elif not remaining and sentence_count == 0:
  106. # 如果一个句子都没输出,输出完整内容
  107. if full_response:
  108. print(f"LLM 流式输出完整: {full_response}")
  109. yield (full_response, emotion)
  110. print(f"LLM 完整响应: {full_response}")
  111. print(f"提取情感: {emotion}")
  112. print(f"========== LLM 流式成功 ==========\n")
  113. except Exception as e:
  114. print(f"========== LLM 流式错误 ==========")
  115. print(f"错误: {e}")
  116. import traceback
  117. traceback.print_exc()
  118. print(f"========== LLM 流式错误结束 ==========\n")
  119. yield ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  120. def _is_complete_sentence(self, text: str) -> bool:
  121. """检测是否完整句子"""
  122. text = text.strip()
  123. # 必须以句子结束符结尾
  124. if not text.endswith(('.', '!', '?', '。', '!', '?')):
  125. return False
  126. # 排除常见缩写(避免误判)
  127. words = text.split()
  128. if words:
  129. last_word = words[-1]
  130. # 常见英文缩写
  131. common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
  132. if last_word in common_abbrev:
  133. return False
  134. # 至少5个字符(避免"Hi."这种过短的)
  135. if len(text) < 5:
  136. return False
  137. return True
  138. async def generate_response(
  139. self, user_text: str
  140. ) -> tuple[str, EmotionType]:
  141. """
  142. 生成对话回复
  143. Args:
  144. user_text: 用户输入文本
  145. Returns:
  146. (response_text, emotion): 回复文本和情感
  147. """
  148. try:
  149. print(f"\n========== LLM 开始 ==========")
  150. print(f"用户输入: {user_text}")
  151. # 构建System Prompt
  152. system_prompt = self._build_system_prompt()
  153. print(f"调用 LLM: {settings.llm_model}")
  154. print(f"学习者水平: {settings.LEARNER_PROFICIENCY}, 年龄组: {settings.AGE_TAG}, 主题: {settings.TOPIC_TAG}, 模式: {settings.EXPERIMENT_TAG}")
  155. # 调用LLM
  156. response = self.openai_client.chat.completions.create(
  157. model=settings.llm_model,
  158. messages=[
  159. {"role": "system", "content": system_prompt},
  160. {"role": "user", "content": user_text},
  161. ],
  162. temperature=0.7,
  163. max_tokens=500,
  164. )
  165. full_response = response.choices[0].message.content or ""
  166. print(f"LLM 原始响应: {full_response}")
  167. # 提取情感标签
  168. emotion: EmotionType = "neutral"
  169. response_text = full_response
  170. # 查找情感标签
  171. if "[EMOTION:" in full_response:
  172. emotion_start = full_response.find("[EMOTION:")
  173. emotion_end = full_response.find("]", emotion_start)
  174. if emotion_end > emotion_start:
  175. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  176. # 验证情感类型
  177. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging"]
  178. if emotion_str in valid_emotions:
  179. emotion = emotion_str
  180. print(f"提取到情感标签: {emotion}")
  181. # 移除情感标签(不发送给TTS)
  182. response_text = full_response[:emotion_start].strip()
  183. print(f"已移除情感标签,纯文本内容: {response_text}")
  184. # 如果没有文本内容,使用默认回复
  185. if not response_text:
  186. response_text = "I understand. Please tell me more!"
  187. emotion = "neutral"
  188. print(f"✅ 将发送给TTS的文本: {response_text}")
  189. print(f"✅ 情感: {emotion}")
  190. print(f"========== LLM 成功 ==========\n")
  191. return (response_text, emotion)
  192. except Exception as e:
  193. print(f"========== LLM 错误 ==========")
  194. print(f"错误: {e}")
  195. import traceback
  196. traceback.print_exc()
  197. print(f"========== LLM 错误结束 ==========\n")
  198. return ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  199. async def text_to_speech_stream(
  200. self, text: str, emotion: EmotionType = "neutral"
  201. ) -> AsyncGenerator[bytes, None]:
  202. """
  203. 文本转语音(流式 - 实时返回音频块)
  204. Args:
  205. text: 要合成的文本
  206. emotion: 情感类型
  207. Yields:
  208. audio_data: PCM 音频数据块(Base64 解码后的原始数据)
  209. """
  210. try:
  211. import time
  212. tts_start = time.time()
  213. print(f"\n========== TTS 流式开始 ==========")
  214. print(f"要合成的文本: {text}")
  215. print(f"情感: {emotion}")
  216. # 根据情感选择音色
  217. voice = self._get_voice_for_emotion(emotion)
  218. print(f"使用音色: {voice}")
  219. print(f"TTS模型: {settings.tts_model}")
  220. print(f"语速: {settings.tts_speech_rate}x (1.0=正常)")
  221. print(f"流式模式: 是 (PCM 24kHz)")
  222. # 使用流式 API
  223. def sync_streaming_tts():
  224. return dashscope.MultiModalConversation.call(
  225. api_key=settings.dashscope_api_key,
  226. model=settings.tts_model,
  227. text=text,
  228. voice=voice,
  229. speech_rate=settings.tts_speech_rate, # 语速调整
  230. stream=True # 🔥 启用流式
  231. )
  232. print("调用 DashScope TTS 流式 API...")
  233. # 在线程池中调用流式 API
  234. response = await asyncio.to_thread(sync_streaming_tts)
  235. chunk_count = 0
  236. first_chunk_time = None
  237. total_audio_size = 0
  238. # 流式接收音频数据
  239. for chunk in response:
  240. if hasattr(chunk, 'output') and hasattr(chunk.output, 'audio'):
  241. audio = chunk.output.audio
  242. # 接收到音频数据块
  243. if audio.data is not None:
  244. # 记录首字节时间
  245. if first_chunk_time is None:
  246. first_chunk_time = time.time()
  247. first_byte_latency = (first_chunk_time - tts_start) * 1000
  248. print(f"⚡ 首字节延迟: {first_byte_latency:.1f}ms")
  249. # Base64 解码 PCM 数据
  250. pcm_bytes = base64.b64decode(audio.data)
  251. chunk_count += 1
  252. total_audio_size += len(pcm_bytes)
  253. # 立即返回给调用方(实时传输)
  254. yield pcm_bytes
  255. await asyncio.sleep(0) # 让出控制权
  256. # 检查是否结束
  257. if chunk.output.finish_reason == "stop":
  258. tts_end = time.time()
  259. total_duration = (tts_end - tts_start) * 1000
  260. print(f"✅ TTS 流式完成:")
  261. print(f" - 总块数: {chunk_count}")
  262. print(f" - 音频大小: {total_audio_size} 字节")
  263. print(f" - 总耗时: {total_duration:.0f}ms")
  264. if first_chunk_time:
  265. print(f" - 首字节延迟: {(first_chunk_time - tts_start) * 1000:.0f}ms")
  266. # 完整音频 URL(可选,用于备份)
  267. if hasattr(audio, 'url') and audio.url:
  268. print(f" - 完整音频 URL: {audio.url}")
  269. if hasattr(audio, 'expires_at') and audio.expires_at:
  270. print(f" - 过期时间: {audio.expires_at}")
  271. print(f"========== TTS 流式成功 ==========\n")
  272. break
  273. if chunk_count == 0:
  274. raise Exception("TTS API 未返回任何音频数据")
  275. except Exception as e:
  276. print(f"========== TTS 流式错误 ==========")
  277. print(f"错误: {e}")
  278. import traceback
  279. traceback.print_exc()
  280. print(f"========== TTS 流式错误结束 ==========\n")
  281. raise
  282. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  283. """根据情感选择音色"""
  284. # 所有情感统一使用配置文件中的音色
  285. return settings.tts_voice
  286. def _build_system_prompt(self) -> str:
  287. """构建System Prompt"""
  288. learner_proficiency = settings.LEARNER_PROFICIENCY
  289. age_tag = settings.AGE_TAG
  290. topic_tag = settings.TOPIC_TAG
  291. experiment_tag = settings.EXPERIMENT_TAG
  292. # 基础提示词
  293. prompt = f"""**[Role]**
  294. You are **KELLY**, a friendly and supportive English conversation partner based in London.
  295. **[Objective]**
  296. Help Chinese EFL learners practice **spoken English**.
  297. **[Target Audience]**
  298. - Language Level: **{learner_proficiency}**
  299. - Age Group: **{age_tag}**
  300. - Conversation Topic: **{topic_tag}**
  301. **[Response Guidelines]**
  302. 1. **Lead the Conversation**: Don't just follow the learner's questions—take initiative. Gently guide the conversation forward by introducing related ideas, asking engaging questions, or adding playful input.
  303. 2. **Stay Focused**: Keep the conversation on the topic {topic_tag}, and make it suitable for {age_tag} learners.
  304. 3. **Be Concise**: Respond in 2–3 short, natural sentences—like small talk with a friend.
  305. 4. **Use Simple Language**: Stick to A1-A2 level (very easy) vocabulary and grammar. No idioms, no complex tenses. Use high-frequency, easy words.
  306. 5. **Adapt to Learner**: If learners give very short or confused answers, simplify even more: use shorter, clearer sentences.
  307. 6. **Spark Real Conversation**: Ask easy follow-up questions that feel personal, imaginative, or reflective (e.g., "What if you could go anywhere?", "How do you feel when...?", "Why do you like it?"). Let the conversation feel playful, personal, and curious.
  308. 7. **Transition Smoothly**: If the topic feels finished or the learner seems less engaged, gently move to a related or new topic in a natural way. avoid overly specific or unusual questions (e.g., "What type of fish do you like?" or "What shape is your favorite lamp?"), as these can feel awkward or hard to answer. Instead, use broader, everyday topics that are easier to talk about, like: "That reminds me — do you usually cook at home or eat out?", "Speaking of weekends, do you like relaxing or doing something active?"
  309. 8. **Being Empathetic**: Acknowledge the learner's feelings and experiences, but avoid overdoing it.
  310. 9. **Seek Clarification**: When meaning is unclear, use context and ask the learner to clarify. After requesting clarification, pause and wait for their response before continuing the conversation.
  311. 10. **Handle Misunderstandings**:
  312. - If the learner says "Sorry?" → Rephrase your last sentence in simpler English.
  313. - If they ask "Do you mean…?" → Confirm their guess and briefly explain in basic terms.
  314. """
  315. # 如果是实验组,添加纠错反馈策略
  316. if experiment_tag == "experiment":
  317. correction_rule = """
  318. **[Corrective Feedback Strategy]**
  319. Goal:
  320. Prioritising conversation flow while helping learners notice and improve their grammar.
  321. Tone & Guidelines:
  322. (1) Use a kind and supportive tone: correct gently, and always reinforce progress;
  323. (2) Focus ONLY on grammar errors, ignore vocabulary issues;
  324. (3) Correct ONLY ONE error at a time;
  325. (4) Praise self-corrections (e.g. "I do… I did it last time") — encourage the effort, don't go back to correct the earlier mistake.
  326. (5) Reinforcement: Praise learners' correct usage to build their confidence. Examples: "That's right!", "Good job with the past tense!"
  327. (6) Avoid disrupting the conversation flow — don't say things like 'let's fix it', 'it should be...', 'you made a mistake', or 'you should say...'
  328. Correction Strategy:
  329. - For **grammar errors** (e.g., tense, agreement): Use **recasts** as the default - restate with correct form naturally; If meaning is unclear: use a clarification check; If repeated 2+ times: use explicit correction in simple and friendly English.
  330. - For **vocabulary errors** (e.g., inappropriate word choice): ONLY use recasts; NEVER explain or point out!
  331. Correction Techniques:
  332. - **Recast** (PREFERRED): For minor grammar mistakes, restate the learner's sentence naturally with the correct form — without pointing out the error or saying "like you said, but…". Example: Learner: "Yesterday I go to restaurant.", You: "Yesterday you **went** to the restaurant? That's great!"
  333. - **Clarification Check**: Ask a confirming question when meaning is unclear. After asking the question, DO NOT continue asking other follow-up questions; instead, wait for the student's clarification. Example: "Do you mean 'you **went** to the restaurant'?"
  334. - **Explicit Correction**: Only when a grammar mistake repeats many times, you explain the correct form clearly in basic English. After providing an Explicit Correction, DO NOT continue asking the follow-up question; instead, wait for the student's self-correction. If they try to self-correct, respond ONLY to the grammar part and don't focus on vocabulary. Example: "We say 'went' because it's past tense. So it's: 'I went to the restaurant.' Can you try it again?"
  335. """
  336. prompt += correction_rule
  337. # 添加情感标签要求
  338. prompt += """
  339. **[Emotion Tag]**
  340. At the end of your response, on a new line, add ONLY the emotion tag in this exact format:
  341. [EMOTION:emotion_name]
  342. Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging
  343. Example response:
  344. That sounds like fun! What did you do there?
  345. [EMOTION:happy]"""
  346. return prompt
  347. # 全局AI服务实例
  348. ai_service = AIService()