ai_service.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. """
  2. AI服务:集成STT、LLM、TTS模型
  3. """
  4. import base64
  5. import json
  6. import asyncio
  7. import tempfile
  8. import os
  9. import logging
  10. import traceback
  11. from typing import AsyncGenerator, Optional
  12. import dashscope
  13. from openai import OpenAI
  14. from config import settings, get_dashscope_http_base_url, get_dashscope_domain
  15. from models import EmotionType
  16. logger = logging.getLogger(__name__)
  17. # 🔥 TTS API选择
  18. # 注意: SpeechSynthesizer (tts_v2) 使用callback模式,不适合我们的generator流式场景
  19. # MultiModalConversation 已经是最适合流式语音合成的API
  20. USE_NEW_TTS_API = False # 保持使用 MultiModalConversation
  21. # 设置API Key和endpoint(根据国际版/国内版自动选择)
  22. dashscope.api_key = settings.active_dashscope_api_key
  23. # 配置国际版endpoint(如果需要)
  24. if settings.use_dashscope_intl:
  25. dashscope_domain = get_dashscope_domain()
  26. dashscope.base_http_api_url = f"https://{dashscope_domain}/api/v1"
  27. dashscope.base_websocket_api_url = f"wss://{dashscope_domain}/api-ws/v1"
  28. class AIService:
  29. """AI服务管理器"""
  30. # 类级别的预热标记(避免多次实例化时重复预热)
  31. _tts_warmed_up = False
  32. _warmup_lock = None
  33. _tts_call_lock = None # TTS调用锁(防止SDK内部冲突)
  34. _last_tts_call_time = 0.0 # 上次TTS调用时间(用于速率控制)
  35. def __init__(self):
  36. import httpx
  37. import os
  38. # 临时清除代理环境变量(只在这个进程中)
  39. env_backup = {}
  40. proxy_vars = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']
  41. for var in proxy_vars:
  42. if var in os.environ:
  43. env_backup[var] = os.environ[var]
  44. del os.environ[var]
  45. # 🔥 优化:创建高性能 HTTP 客户端 - 增加连接池配置
  46. http_client = httpx.Client(
  47. proxy=None, # 禁用代理
  48. timeout=60.0,
  49. trust_env=False, # 不信任环境变量中的代理设置
  50. # 连接池优化
  51. limits=httpx.Limits(
  52. max_keepalive_connections=20, # 增加keepalive连接数(默认10)
  53. max_connections=50, # 增加总连接数(默认20)
  54. keepalive_expiry=30.0, # keepalive过期时间30秒
  55. ),
  56. # HTTP/2支持 - 多路复用,减少连接开销
  57. http2=True,
  58. )
  59. self.openai_client = OpenAI(
  60. api_key=settings.active_dashscope_api_key,
  61. base_url=get_dashscope_http_base_url(),
  62. http_client=http_client, # 使用自定义 HTTP 客户端
  63. )
  64. # 🔥 TTS优化:预热TTS连接(后台执行,不阻塞启动)
  65. import threading
  66. # 初始化锁
  67. if AIService._warmup_lock is None:
  68. AIService._warmup_lock = threading.Lock()
  69. AIService._tts_call_lock = threading.Lock() # 初始化TTS调用锁
  70. # 只预热一次(避免多次实例化时重复)
  71. if not AIService._tts_warmed_up:
  72. threading.Thread(target=self._warmup_tts_sync, daemon=True).start()
  73. # 恢复环境变量(避免影响其他模块)
  74. for var, value in env_backup.items():
  75. os.environ[var] = value
  76. def _warmup_tts_sync(self):
  77. """同步方式预热TTS连接(在后台线程执行)"""
  78. import time
  79. # 使用锁确保只预热一次
  80. with AIService._warmup_lock:
  81. if AIService._tts_warmed_up:
  82. return # 已经预热过了
  83. time.sleep(2) # 等待2秒确保服务启动完成
  84. try:
  85. start_time = time.time()
  86. response = dashscope.MultiModalConversation.call(
  87. model=settings.tts_model,
  88. text="Hi", # 极短的测试文本
  89. voice=settings.tts_voice,
  90. stream=True,
  91. )
  92. # 只接收首块即停止
  93. for _ in response:
  94. break
  95. AIService._tts_warmed_up = True # 标记为已预热
  96. except Exception as e:
  97. logger.error(
  98. f"[TTS异常] 错误类型=TTS预热失败, "
  99. f"异常类型={type(e).__name__}, "
  100. f"错误消息={str(e)}, "
  101. f"模型={settings.tts_model}, "
  102. f"音色={settings.tts_voice}, "
  103. f"堆栈跟踪:\n{traceback.format_exc()}"
  104. )
  105. # STT现在使用流式识别,在websocket_handler中直接处理
  106. async def generate_response_stream(
  107. self, user_text: str
  108. ) -> AsyncGenerator[tuple[str, EmotionType], None]:
  109. """
  110. 生成对话回复(流式)
  111. Args:
  112. user_text: 用户输入文本
  113. Yields:
  114. (sentence, emotion): 句子片段和情感
  115. """
  116. try:
  117. # 构建System Prompt
  118. system_prompt = self._build_system_prompt()
  119. # 调用LLM流式API
  120. import time
  121. llm_api_start = time.time()
  122. try:
  123. response = self.openai_client.chat.completions.create(
  124. model=settings.llm_model,
  125. messages=[
  126. {"role": "system", "content": system_prompt},
  127. {"role": "user", "content": user_text},
  128. ],
  129. temperature=0.7,
  130. max_tokens=500,
  131. stream=True, # 启用流式
  132. )
  133. except Exception as api_error:
  134. logger.error(
  135. f"[LLM异常] 错误类型=LLM API调用失败, "
  136. f"异常类型={type(api_error).__name__}, "
  137. f"错误消息={str(api_error)}, "
  138. f"模型={settings.llm_model}, "
  139. f"用户输入长度={len(user_text)}, "
  140. f"System Prompt长度={len(system_prompt)}, "
  141. f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
  142. f"使用国际版={settings.use_dashscope_intl}, "
  143. f"堆栈跟踪:\n{traceback.format_exc()}"
  144. )
  145. raise
  146. buffer = ""
  147. full_response = ""
  148. sentence_count = 0
  149. detected_emotion: EmotionType = "neutral" # 存储检测到的情感
  150. emotion_yielded = False # 标记是否已发送情感
  151. chunk_count = 0 # 计数chunk
  152. for chunk in response:
  153. chunk_count += 1
  154. if chunk.choices[0].delta.content:
  155. content = chunk.choices[0].delta.content
  156. buffer += content
  157. full_response += content
  158. # 🎯 优先检测情感标记(如果还没检测到)
  159. if detected_emotion == "neutral" and "[EMOTION:" in full_response:
  160. emotion_start = full_response.find("[EMOTION:")
  161. emotion_end = full_response.find("]", emotion_start)
  162. if emotion_end > emotion_start:
  163. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  164. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
  165. if emotion_str in valid_emotions:
  166. detected_emotion = emotion_str
  167. # 🚀 立即 yield 空句子传递情感(减少延迟)
  168. if not emotion_yielded:
  169. yield ("", detected_emotion) # 空句子,仅传递情感
  170. emotion_yielded = True
  171. # 🧹 从 buffer 中移除情感标记(避免重复处理)
  172. buffer = buffer.replace(f"[EMOTION:{emotion_str.upper()}]", "").strip()
  173. # 检测句子边界(完整的句子)
  174. if self._is_complete_sentence(buffer):
  175. sentence = buffer.strip()
  176. sentence_count += 1
  177. # 使用已检测到的情感
  178. yield (sentence, detected_emotion)
  179. buffer = ""
  180. # 处理剩余内容(使用已检测到的情感)
  181. # 从完整响应中移除情感标签部分(用于日志输出)
  182. clean_response = full_response
  183. if "[EMOTION:" in full_response:
  184. emotion_start = full_response.find("[EMOTION:")
  185. clean_response = full_response[:emotion_start].strip()
  186. # 处理buffer中的剩余内容(已输出句子之后的部分)
  187. remaining = buffer.strip()
  188. if remaining and not remaining.startswith("[EMOTION"):
  189. sentence_count += 1
  190. yield (remaining, detected_emotion)
  191. elif not remaining and sentence_count == 0:
  192. # 如果一个句子都没输出,输出完整内容
  193. if clean_response:
  194. yield (clean_response, detected_emotion)
  195. else:
  196. # 🔥 兜底逻辑:LLM没有返回任何文本(只返回了情感标记)
  197. default_response = "I understand. Could you tell me more?"
  198. yield (default_response, detected_emotion)
  199. except Exception as e:
  200. logger.error(
  201. f"[LLM异常] 错误类型=LLM流式生成失败, "
  202. f"异常类型={type(e).__name__}, "
  203. f"错误消息={str(e)}, "
  204. f"模型={settings.llm_model}, "
  205. f"用户输入={user_text[:100]}... (前100字符), "
  206. f"堆栈跟踪:\n{traceback.format_exc()}"
  207. )
  208. yield ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  209. def _is_complete_sentence(self, text: str) -> bool:
  210. """检测是否完整句子"""
  211. text = text.strip()
  212. # 必须以句子结束符结尾
  213. if not text.endswith(('.', '!', '?', '。', '!', '?')):
  214. return False
  215. # 排除常见缩写(避免误判)
  216. words = text.split()
  217. if words:
  218. last_word = words[-1]
  219. # 常见英文缩写
  220. common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
  221. if last_word in common_abbrev:
  222. return False
  223. # 🔥 优化:减少最小字符限制从5到3,让短句更快发送
  224. # 例如"Hi!" "OK!" "Yes!" 这种短句也能快速返回
  225. if len(text) < 3:
  226. return False
  227. return True
  228. async def generate_response(
  229. self, user_text: str
  230. ) -> tuple[str, EmotionType]:
  231. """
  232. 生成对话回复
  233. Args:
  234. user_text: 用户输入文本
  235. Returns:
  236. (response_text, emotion): 回复文本和情感
  237. """
  238. try:
  239. # 构建System Prompt
  240. system_prompt = self._build_system_prompt()
  241. # 调用LLM
  242. response = self.openai_client.chat.completions.create(
  243. model=settings.llm_model,
  244. messages=[
  245. {"role": "system", "content": system_prompt},
  246. {"role": "user", "content": user_text},
  247. ],
  248. temperature=0.7,
  249. max_tokens=500,
  250. )
  251. full_response = response.choices[0].message.content or ""
  252. # 提取情感标签
  253. emotion: EmotionType = "neutral"
  254. response_text = full_response
  255. # 查找情感标签
  256. if "[EMOTION:" in full_response:
  257. emotion_start = full_response.find("[EMOTION:")
  258. emotion_end = full_response.find("]", emotion_start)
  259. if emotion_end > emotion_start:
  260. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  261. # 验证情感类型
  262. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
  263. if emotion_str in valid_emotions:
  264. emotion = emotion_str
  265. # 移除情感标签(不发送给TTS)
  266. response_text = full_response[:emotion_start].strip()
  267. # 如果没有文本内容,使用默认回复
  268. if not response_text:
  269. response_text = "I understand. Please tell me more!"
  270. emotion = "neutral"
  271. return (response_text, emotion)
  272. except Exception as e:
  273. logger.error(
  274. f"[LLM异常] 错误类型=LLM生成失败, "
  275. f"异常类型={type(e).__name__}, "
  276. f"错误消息={str(e)}, "
  277. f"模型={settings.llm_model}, "
  278. f"用户输入={user_text[:100]}... (前100字符), "
  279. f"堆栈跟踪:\n{traceback.format_exc()}"
  280. )
  281. return ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  282. async def text_to_speech_stream(
  283. self, text: str, emotion: EmotionType = "neutral"
  284. ) -> AsyncGenerator[bytes, None]:
  285. """
  286. 文本转语音(流式 - 实时返回音频块),带重试机制和速率控制
  287. Args:
  288. text: 要合成的文本
  289. emotion: 情感类型
  290. Yields:
  291. audio_data: PCM 音频数据块(Base64 解码后的原始数据)
  292. """
  293. import time
  294. # 🚦 速率控制:确保两次TTS调用之间有最小间隔
  295. min_interval = settings.TTS_MIN_INTERVAL
  296. time_since_last_call = time.time() - AIService._last_tts_call_time
  297. if time_since_last_call < min_interval:
  298. wait_time = min_interval - time_since_last_call
  299. await asyncio.sleep(wait_time)
  300. # 更新最后调用时间
  301. AIService._last_tts_call_time = time.time()
  302. max_retries = settings.TTS_MAX_RETRIES
  303. retry_delay = settings.TTS_RETRY_DELAY
  304. backoff = settings.TTS_RETRY_BACKOFF
  305. for attempt in range(max_retries + 1):
  306. try:
  307. async for audio_chunk in self._text_to_speech_stream_internal(text, emotion):
  308. yield audio_chunk
  309. # 成功则退出
  310. return
  311. except Exception as e:
  312. # 检查是否为速率限制错误(429、Throttling、rate limit)
  313. error_str = str(e)
  314. is_rate_limit = (
  315. 'rate limit' in error_str.lower() or
  316. 'throttling' in error_str.lower() or
  317. '429' in error_str or
  318. 'RateQuota' in error_str
  319. )
  320. logger.error(
  321. f"[TTS异常] 错误类型=TTS流式生成失败, "
  322. f"异常类型={type(e).__name__}, "
  323. f"错误消息={str(e)}, "
  324. f"是否为速率限制={is_rate_limit}, "
  325. f"重试次数={attempt}/{max_retries}, "
  326. f"模型={settings.tts_model}, "
  327. f"音色={self._get_voice_for_emotion(emotion)}, "
  328. f"文本长度={len(text)}, "
  329. f"文本内容={text[:100]}... (前100字符), "
  330. f"堆栈跟踪:\n{traceback.format_exc()}"
  331. )
  332. if is_rate_limit and attempt < max_retries:
  333. wait_time = retry_delay * (backoff ** attempt)
  334. logger.warning(
  335. f"[TTS警告] 速率限制,等待{wait_time:.2f}秒后重试 "
  336. f"(第{attempt+1}/{max_retries}次重试)"
  337. )
  338. await asyncio.sleep(wait_time)
  339. continue
  340. else:
  341. # 非速率限制错误或已达最大重试次数
  342. raise
  343. async def _text_to_speech_stream_internal(
  344. self, text: str, emotion: EmotionType = "neutral"
  345. ) -> AsyncGenerator[bytes, None]:
  346. """
  347. TTS流式调用的内部实现(不含重试逻辑)
  348. Args:
  349. text: 要合成的文本
  350. emotion: 情感类型
  351. Yields:
  352. audio_data: PCM 音频数据块(Base64 解码后的原始数据)
  353. """
  354. try:
  355. import time
  356. tts_start = time.time()
  357. # 根据情感选择音色
  358. voice = self._get_voice_for_emotion(emotion)
  359. def sync_streaming_tts():
  360. return dashscope.MultiModalConversation.call(
  361. model=settings.tts_model,
  362. text=text,
  363. voice=voice,
  364. speech_rate=settings.tts_speech_rate, # 语速调整
  365. stream=True, # 流式输出
  366. # 注意: incremental_output 参数在此API中可能不生效
  367. )
  368. # 🔥 修复:使用队列在线程和异步之间传递数据
  369. import queue
  370. import threading
  371. audio_queue = queue.Queue()
  372. error_holder = [None] # 用列表包装以便在线程间共享
  373. def stream_producer():
  374. """在独立线程中执行同步流式API调用"""
  375. nonlocal chunk_count, first_chunk_time, total_audio_size
  376. try:
  377. # 🔥 关键修复:在锁内完成整个API调用和迭代
  378. # 原因:DashScope SDK内部可能有全局状态,并发迭代会冲突
  379. with AIService._tts_call_lock:
  380. response = sync_streaming_tts()
  381. chunk_index = 0
  382. for chunk in response:
  383. chunk_index += 1
  384. # 🔥 检查API错误(特别是速率限制)
  385. if hasattr(chunk, 'status_code') and chunk.status_code != 200:
  386. error_code = getattr(chunk, 'code', 'Unknown')
  387. error_msg = getattr(chunk, 'message', 'No error message')
  388. # 详细记录TTS API错误
  389. logger.error(
  390. f"[TTS异常] 错误类型=TTS API响应错误, "
  391. f"HTTP状态码={chunk.status_code}, "
  392. f"错误码={error_code}, "
  393. f"错误消息={error_msg}, "
  394. f"模型={settings.tts_model}, "
  395. f"音色={voice}, "
  396. f"文本长度={len(text)}, "
  397. f"chunk索引={chunk_index}, "
  398. f"完整chunk对象={chunk}"
  399. )
  400. # 构造友好的错误信息
  401. if chunk.status_code == 429 or 'Throttling' in error_code:
  402. raise Exception(f"TTS API速率限制: {error_msg} (code: {error_code})")
  403. else:
  404. raise Exception(f"TTS API错误 (status={chunk.status_code}): {error_msg} (code: {error_code})")
  405. if hasattr(chunk, 'output') and hasattr(chunk.output, 'audio'):
  406. audio = chunk.output.audio
  407. # 接收到音频数据块
  408. if audio.data is not None:
  409. # 记录首字节时间
  410. if first_chunk_time is None:
  411. first_chunk_time = time.time()
  412. # Base64 解码 PCM 数据
  413. pcm_bytes = base64.b64decode(audio.data)
  414. chunk_count += 1
  415. total_audio_size += len(pcm_bytes)
  416. # 放入队列
  417. audio_queue.put(('data', pcm_bytes))
  418. # 检查是否结束
  419. if hasattr(chunk.output, 'finish_reason') and chunk.output.finish_reason == "stop":
  420. tts_end = time.time()
  421. total_duration = (tts_end - tts_start) * 1000
  422. break
  423. # 标记完成
  424. audio_queue.put(('done', None))
  425. except Exception as e:
  426. logger.error(
  427. f"[TTS异常] 错误类型=TTS流式生成线程异常, "
  428. f"异常类型={type(e).__name__}, "
  429. f"错误消息={str(e)}, "
  430. f"模型={settings.tts_model}, "
  431. f"音色={voice}, "
  432. f"文本长度={len(text)}, "
  433. f"已接收chunk数={chunk_index}, "
  434. f"堆栈跟踪:\n{traceback.format_exc()}"
  435. )
  436. error_holder[0] = e
  437. audio_queue.put(('error', e))
  438. # 启动生产者线程
  439. chunk_count = 0
  440. first_chunk_time = None
  441. total_audio_size = 0
  442. received_data = False # 标记是否收到过数据
  443. producer_thread = threading.Thread(target=stream_producer, daemon=True)
  444. producer_thread.start()
  445. # 异步消费队列数据
  446. while True:
  447. # 非阻塞获取(避免阻塞事件循环)
  448. try:
  449. msg_type, data = await asyncio.to_thread(audio_queue.get, timeout=30)
  450. if msg_type == 'data':
  451. received_data = True
  452. yield data
  453. await asyncio.sleep(0) # 让出控制权
  454. elif msg_type == 'done':
  455. break
  456. elif msg_type == 'error':
  457. raise data
  458. except queue.Empty:
  459. error_msg = "TTS API 超时(30秒无响应)"
  460. logger.error(
  461. f"[TTS异常] 错误类型=TTS API超时, "
  462. f"错误消息={error_msg}, "
  463. f"模型={settings.tts_model}, "
  464. f"音色={self._get_voice_for_emotion(emotion)}, "
  465. f"文本长度={len(text)}, "
  466. f"已接收数据={received_data}, "
  467. f"chunk计数={chunk_count}"
  468. )
  469. raise Exception(error_msg)
  470. # 检查是否收到过数据(避免竞态条件)
  471. if not received_data:
  472. error_msg = f"TTS API 未返回任何音频数据 (text='{text}', emotion={emotion}, voice={voice})"
  473. if error_holder[0]:
  474. error_msg += f"\n生产者线程错误: {error_holder[0]}"
  475. logger.error(
  476. f"[TTS异常] 错误类型=TTS API无数据返回, "
  477. f"错误消息={error_msg}, "
  478. f"模型={settings.tts_model}, "
  479. f"音色={voice}, "
  480. f"文本长度={len(text)}, "
  481. f"文本内容={text[:100]}... (前100字符), "
  482. f"chunk计数={chunk_count}, "
  483. f"总音频大小={total_audio_size} bytes"
  484. )
  485. raise Exception(error_msg)
  486. except Exception as e:
  487. logger.error(
  488. f"[TTS异常] 错误类型=TTS流式生成内部异常, "
  489. f"异常类型={type(e).__name__}, "
  490. f"错误消息={str(e)}, "
  491. f"模型={settings.tts_model}, "
  492. f"音色={self._get_voice_for_emotion(emotion)}, "
  493. f"文本长度={len(text)}, "
  494. f"堆栈跟踪:\n{traceback.format_exc()}"
  495. )
  496. raise
  497. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  498. """根据情感选择音色"""
  499. # 所有情感统一使用配置文件中的音色
  500. return settings.tts_voice
  501. def _build_system_prompt(self) -> str:
  502. """构建System Prompt"""
  503. learner_proficiency = settings.LEARNER_PROFICIENCY
  504. age_tag = settings.AGE_TAG
  505. topic_tag = settings.TOPIC_TAG
  506. experiment_tag = settings.EXPERIMENT_TAG
  507. # 🔥 如果启用简化提示词(优化延迟)
  508. if settings.USE_SIMPLIFIED_PROMPT:
  509. return self._build_simplified_prompt(learner_proficiency, age_tag, topic_tag, experiment_tag)
  510. # 基础提示词
  511. prompt = f"""**[Role]**
  512. You are **KELLY**, a friendly and supportive English conversation partner based in London.
  513. **[Objective]**
  514. Help Chinese EFL learners practice **spoken English**.
  515. **[Target Audience]**
  516. - Language Level: **{learner_proficiency}**
  517. - Age Group: **{age_tag}**
  518. - Conversation Topic: **{topic_tag}**
  519. **[Response Guidelines]**
  520. 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.
  521. 2. **Stay Focused**: Keep the conversation on the topic {topic_tag}, and make it suitable for {age_tag} learners.
  522. 3. **Be Concise**: Respond in 2–3 short, natural sentences—like small talk with a friend.
  523. 4. **Use Simple Language**: Stick to A1-A2 level (very easy) vocabulary and grammar. No idioms, no complex tenses. Use high-frequency, easy words.
  524. 5. **Adapt to Learner**: If learners give very short or confused answers, simplify even more: use shorter, clearer sentences.
  525. 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.
  526. 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?"
  527. 8. **Being Empathetic**: Acknowledge the learner's feelings and experiences, but avoid overdoing it.
  528. 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.
  529. 10. **Handle Misunderstandings**:
  530. - If the learner says "Sorry?" → Rephrase your last sentence in simpler English.
  531. - If they ask "Do you mean…?" → Confirm their guess and briefly explain in basic terms.
  532. """
  533. # 如果是实验组,添加纠错反馈策略
  534. if experiment_tag == "experiment":
  535. correction_rule = """
  536. **[Corrective Feedback Strategy]**
  537. Goal:
  538. Prioritising conversation flow while helping learners notice and improve their grammar.
  539. Tone & Guidelines:
  540. (1) Use a kind and supportive tone: correct gently, and always reinforce progress;
  541. (2) Focus ONLY on grammar errors, ignore vocabulary issues;
  542. (3) Correct ONLY ONE error at a time;
  543. (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.
  544. (5) Reinforcement: Praise learners' correct usage to build their confidence. Examples: "That's right!", "Good job with the past tense!"
  545. (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...'
  546. Correction Strategy:
  547. - 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.
  548. - For **vocabulary errors** (e.g., inappropriate word choice): ONLY use recasts; NEVER explain or point out!
  549. Correction Techniques:
  550. - **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!"
  551. - **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'?"
  552. - **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?"
  553. """
  554. prompt += correction_rule
  555. # 添加情感标签要求(前置到开头)
  556. prompt += """
  557. **[Emotion Tag - IMPORTANT]**
  558. At the BEGINNING of your response, on the FIRST line, add the emotion tag in this exact format:
  559. [EMOTION:emotion_name]
  560. Then ALWAYS continue with your spoken response (2-3 sentences).
  561. Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  562. **How to choose emotion:**
  563. 1. **Respond to the learner's emotion**: If they express sadness/difficulty → use "sorry" or "encouraging"; if they're happy/excited → use "happy" or "excited"
  564. 2. **Match your response tone**: The emotion should reflect YOUR response style (empathetic, supportive, cheerful, etc.)
  565. 3. **Examples**:
  566. - Learner says "I'm sad today" → [EMOTION:sorry] "I'm sorry to hear that. What happened?"
  567. - Learner says "I passed my exam!" → [EMOTION:excited] "That's amazing! Congratulations!"
  568. - Learner asks a normal question → [EMOTION:friendly] or [EMOTION:neutral]
  569. Example response:
  570. [EMOTION:happy]
  571. That sounds like fun! What did you do there?
  572. CRITICAL:
  573. - NEVER return ONLY the emotion tag without spoken text
  574. - ALWAYS include 2-3 sentences of actual spoken response after the emotion tag
  575. - The emotion tag MUST be the FIRST line, before any other content
  576. - DO NOT use parentheses () or brackets [] in your spoken response (they will be removed during speech synthesis)
  577. - Keep your response natural and conversational, avoid meta-commentary or clarifications in parentheses"""
  578. return prompt
  579. def _build_simplified_prompt(self, learner_proficiency: str, age_tag: str, topic_tag: str, experiment_tag: str) -> str:
  580. """构建简化版System Prompt(优化延迟,减少token)"""
  581. prompt = f"""You are KELLY, a friendly English tutor in London.
  582. **Target**: {learner_proficiency} level, {age_tag}, {topic_tag}
  583. **Guidelines**:
  584. 1. Lead conversation actively, 2-3 short simple sentences
  585. 2. Use A1-A2 level vocabulary only
  586. 3. Ask easy, personal follow-up questions
  587. 4. If unclear, ask for clarification then wait
  588. 5. Transition smoothly between topics
  589. 6. Be empathetic but natural
  590. """
  591. # 实验组:简化的纠错规则
  592. if experiment_tag == "experiment":
  593. prompt += """
  594. **Feedback**:
  595. - Recast grammar errors naturally (one at a time)
  596. - Supportive tone, praise progress
  597. - Never say "you made a mistake"
  598. """
  599. # 情感标签(必需)
  600. prompt += """
  601. **Emotion Tag - REQUIRED**:
  602. Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 2-3 sentences of spoken response.
  603. Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  604. **Choose based on**:
  605. - Learner's emotion (sad→sorry/encouraging, happy→excited)
  606. - Your response tone (empathetic, supportive, cheerful)
  607. Example:
  608. [EMOTION:sorry]
  609. I'm sorry to hear that. What happened?
  610. Critical:
  611. - NEVER return ONLY the emotion tag
  612. - ALWAYS include actual spoken text after the tag
  613. - NO parentheses () or brackets [] in spoken text
  614. """
  615. return prompt
  616. async def generate_summary(self, prompt: str) -> str:
  617. """
  618. 生成对话总结(非流式)
  619. Args:
  620. prompt: 总结提示词
  621. Returns:
  622. 总结文本
  623. """
  624. try:
  625. response = self.openai_client.chat.completions.create(
  626. model=settings.llm_model,
  627. messages=[
  628. {"role": "system", "content": "你是一位专业的英语学习助手,擅长总结学习对话的关键信息。"},
  629. {"role": "user", "content": prompt},
  630. ],
  631. temperature=0.5,
  632. max_tokens=300,
  633. )
  634. summary = response.choices[0].message.content or ""
  635. return summary.strip()
  636. except Exception as e:
  637. logger.error(
  638. f"[LLM异常] 错误类型=生成总结失败, "
  639. f"异常类型={type(e).__name__}, "
  640. f"错误消息={str(e)}, "
  641. f"模型={settings.llm_model}, "
  642. f"提示词长度={len(prompt)}, "
  643. f"提示词内容={prompt[:200]}... (前200字符), "
  644. f"堆栈跟踪:\n{traceback.format_exc()}"
  645. )
  646. return ""
  647. # 全局AI服务实例
  648. ai_service = AIService()