ai_service.py 35 KB

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