ai_service.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429
  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 (
  15. settings,
  16. get_dashscope_http_base_url,
  17. get_dashscope_domain,
  18. get_dashscope_tts_api_url,
  19. )
  20. from models import EmotionType
  21. from http_pool import http_pool # 🔥 使用连接池管理器
  22. logger = logging.getLogger(__name__)
  23. # 🔥 TTS API选择
  24. # 使用 SpeechSynthesizer (tts_v2) 支持 cosyvoice-v3-flash 模型
  25. # 使用 streaming_call 实现真正的流式传输
  26. from dashscope.audio.tts_v2 import SpeechSynthesizer, ResultCallback
  27. # 设置API Key和endpoint(根据国际版/国内版自动选择)
  28. dashscope.api_key = settings.active_dashscope_api_key
  29. dashscope.base_http_api_url = get_dashscope_tts_api_url()
  30. # 配置国际版endpoint(如果需要)
  31. if settings.use_dashscope_intl:
  32. dashscope_domain = get_dashscope_domain()
  33. dashscope.base_websocket_api_url = f"wss://{dashscope_domain}/api-ws/v1"
  34. class AIService:
  35. """AI服务管理器"""
  36. # 类级别的预热标记(避免多次实例化时重复预热)
  37. _tts_warmed_up = False
  38. _warmup_lock = None
  39. _tts_call_lock = None # TTS调用锁(防止SDK内部冲突)
  40. _last_tts_call_time = 0.0 # 上次TTS调用时间(用于速率控制)
  41. def __init__(self):
  42. # 🔥 使用连接池管理器(类似SQLAlchemy的连接池模式)
  43. # 所有AIService实例共享同一个HTTP连接池,避免重复创建连接
  44. http_client = http_pool.get_client()
  45. self.openai_client = OpenAI(
  46. api_key=settings.active_dashscope_api_key,
  47. base_url=get_dashscope_http_base_url(),
  48. http_client=http_client, # 使用连接池管理的 HTTP 客户端
  49. )
  50. # 🔥 连接健康检查标记
  51. self._last_llm_healthcheck: float = 0.0
  52. self._llm_healthcheck_interval = settings.LLM_HEALTHCHECK_INTERVAL # 从配置读取
  53. self._last_tts_healthcheck: float = 0.0
  54. self._tts_healthcheck_interval = settings.TTS_HEALTHCHECK_INTERVAL # 从配置读取
  55. self._healthcheck_tasks: set = set() # 后台健康检查任务集合(避免重复执行)
  56. # 🔥 TTS优化:预热TTS连接(后台执行,不阻塞启动)
  57. import threading
  58. # 初始化锁
  59. if AIService._warmup_lock is None:
  60. AIService._warmup_lock = threading.Lock()
  61. AIService._tts_call_lock = threading.Lock() # 初始化TTS调用锁
  62. # 只预热一次(避免多次实例化时重复)
  63. if not AIService._tts_warmed_up:
  64. threading.Thread(target=self._warmup_tts_sync, daemon=True).start()
  65. def _warmup_tts_sync(self):
  66. """同步方式预热TTS连接(在后台线程执行)"""
  67. import time
  68. # 使用锁确保只预热一次
  69. with AIService._warmup_lock:
  70. if AIService._tts_warmed_up:
  71. return # 已经预热过了
  72. time.sleep(2) # 等待2秒确保服务启动完成
  73. try:
  74. start_time = time.time()
  75. # 使用 SpeechSynthesizer 预热(同步调用即可)
  76. synthesizer = SpeechSynthesizer(
  77. model=settings.tts_model,
  78. voice=settings.tts_voice,
  79. speech_rate=settings.tts_speech_rate,
  80. pitch_rate=settings.tts_pitch_rate,
  81. language_hints=settings.tts_language_hints,
  82. )
  83. # 发送极短文本进行预热(使用同步调用)
  84. audio = synthesizer.call("Hi")
  85. AIService._tts_warmed_up = True # 标记为已预热
  86. except Exception as e:
  87. logger.error(
  88. f"[TTS异常] 错误类型=TTS预热失败, "
  89. f"异常类型={type(e).__name__}, "
  90. f"错误消息={str(e)}, "
  91. f"模型={settings.tts_model}, "
  92. f"音色={settings.tts_voice}, "
  93. f"堆栈跟踪:\n{traceback.format_exc()}"
  94. )
  95. # STT现在使用流式识别,在websocket_handler中直接处理
  96. def first_meeting_line(self, user_name: str, lumi_name: str) -> str:
  97. """
  98. 新用户的首句(固定模板)- v0.3
  99. 参考 prompts-v0.3.py 的 first_meeting_line 函数
  100. Args:
  101. user_name: 用户名称
  102. lumi_name: Lumi名称
  103. Returns:
  104. 固定首句(纯文本,不含情感标签)
  105. """
  106. user_name = (user_name or "").strip() or "Neo"
  107. lumi_name = (lumi_name or "").strip() or "Lumi"
  108. # ✅ v0.3: 与 prompts-v0.3.py 保持一致,返回固定模板(不含情感标签)
  109. return (
  110. f"{user_name}… we made it. I'm {lumi_name}. I'm really glad you're here. How are you feeling right now?"
  111. )
  112. def _login_greeting_prompt(self, user_name: str, lumi_name: str) -> str:
  113. """
  114. 构建登录问候提示词 - v0.3
  115. Args:
  116. user_name: 用户名称
  117. lumi_name: Lumi名称
  118. Returns:
  119. 登录问候提示词
  120. """
  121. return f"""
  122. [LOGIN GREETING REQUEST]
  123. Generate ONLY the first greeting line for a returning user.
  124. Runtime names:
  125. - User: {user_name}
  126. - Lumi: {lumi_name}
  127. World cue (must include at least ONE of these exact phrases):
  128. - "Glimmer Forest"
  129. - "the Tree"
  130. - "the glow"
  131. Tone:
  132. - Warm, familiar, like seeing a friend again
  133. - Calm and grounded, not excited or dramatic
  134. Constraints:
  135. - 1–2 short sentences, voice-friendly.
  136. - Must include BOTH names exactly as given above.
  137. - Must include at least ONE world cue phrase above.
  138. - End with ONE simple question.
  139. - No emojis, emoticons, or decorative symbols.
  140. Output ONLY the greeting line.
  141. """.strip()
  142. async def generate_login_greeting(
  143. self,
  144. user_info: Optional[dict] = None,
  145. chat_history: Optional[list] = None
  146. ) -> str:
  147. """
  148. 生成回归用户的登录问候(动态生成)- v0.2.1
  149. Args:
  150. user_info: 用户信息字典
  151. chat_history: 对话历史(可选)
  152. Returns:
  153. 登录问候语(包含情感标签)
  154. """
  155. if not user_info:
  156. user_info = {}
  157. user_name = (user_info.get("username") or "").strip() or "Neo"
  158. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
  159. # 构建 System Prompt
  160. system_prompt = self._build_system_prompt(user_info, is_new_user=False)
  161. # ✅ v0.3: 添加 Runtime Context(包含 memory_summary)
  162. runtime_context = self._build_runtime_context(user_info)
  163. # 构建消息列表
  164. messages = [
  165. {"role": "system", "content": system_prompt},
  166. {"role": "system", "content": runtime_context}, # ✅ 新增:包含历史记忆
  167. ]
  168. # 添加对话历史(如果有)
  169. if chat_history:
  170. messages.extend(chat_history)
  171. # 添加登录问候请求(v0.3 版本)
  172. # 注意:问候语不需要情感标签
  173. login_greeting_prompt = self._login_greeting_prompt(user_name, lumi_name)
  174. messages.append({"role": "user", "content": login_greeting_prompt})
  175. try:
  176. response = self.openai_client.chat.completions.create(
  177. model=settings.llm_model,
  178. messages=messages,
  179. temperature=0.7,
  180. max_tokens=200,
  181. )
  182. greeting_text = response.choices[0].message.content.strip()
  183. # 注意:问候语不需要情感标签,直接返回
  184. return greeting_text
  185. except Exception as e:
  186. logger.error(f"❌ 生成登录问候失败: {e}")
  187. # 降级方案:使用固定模板(不包含情感标签)
  188. return f"Welcome back, {user_name}. I'm {lumi_name}. How are you feeling today?"
  189. async def generate_response_stream(
  190. self,
  191. user_text: str,
  192. user_info: Optional[dict] = None,
  193. is_new_user: bool = False,
  194. chat_history: Optional[list] = None
  195. ) -> AsyncGenerator[tuple[str, EmotionType], None]:
  196. """
  197. 生成对话回复(流式)
  198. Args:
  199. user_text: 用户输入文本
  200. user_info: 用户信息字典(包含 username, bot_name)
  201. is_new_user: 是否为新用户(首次对话)
  202. chat_history: 对话历史(可选)
  203. Yields:
  204. (sentence, emotion): 句子片段和情感
  205. """
  206. try:
  207. # 构建System Prompt
  208. system_prompt = self._build_system_prompt(user_info, is_new_user)
  209. # 构建运行时上下文
  210. runtime_context = self._build_runtime_context(user_info)
  211. # 调用LLM流式API
  212. import time
  213. llm_api_start = time.time()
  214. # 🔥 连接健康检查(后台异步执行,不阻塞主流程)
  215. self._trigger_llm_healthcheck_background()
  216. # 构建消息列表
  217. messages = [
  218. {"role": "system", "content": system_prompt},
  219. {"role": "system", "content": runtime_context}, # 注入运行时上下文
  220. ]
  221. # 添加对话历史(如果有)
  222. if chat_history:
  223. messages.extend(chat_history)
  224. # 🔥 格式提醒:确保每次对话都生成情感标签(双重保障)
  225. messages.append({
  226. "role": "system",
  227. "content": "Remember: Start your response with [EMOTION:xxx] format. This is REQUIRED."
  228. })
  229. # 添加当前用户输入
  230. messages.append({"role": "user", "content": user_text})
  231. try:
  232. response = self.openai_client.chat.completions.create(
  233. model=settings.llm_model,
  234. messages=messages,
  235. temperature=0.7,
  236. max_tokens=500,
  237. stream=True, # 启用流式
  238. )
  239. except Exception as api_error:
  240. # 🔥 检测连接错误,自动重建连接
  241. error_str = str(api_error).lower()
  242. is_connection_error = (
  243. 'connection' in error_str or
  244. 'timeout' in error_str or
  245. 'network' in error_str or
  246. 'reset' in error_str or
  247. 'broken pipe' in error_str or
  248. 'connection refused' in error_str or
  249. 'connection aborted' in error_str
  250. )
  251. if is_connection_error:
  252. logger.warning(f"⚠️ LLM连接错误,自动重建连接: {api_error}")
  253. self._recreate_http_client()
  254. # 重建后重试一次
  255. try:
  256. response = self.openai_client.chat.completions.create(
  257. model=settings.llm_model,
  258. messages=messages,
  259. temperature=0.7,
  260. max_tokens=500,
  261. stream=True,
  262. )
  263. logger.info("✅ LLM连接重建后重试成功")
  264. except Exception as retry_error:
  265. logger.error(
  266. f"[LLM异常] 错误类型=LLM API调用失败(重建后仍失败), "
  267. f"异常类型={type(retry_error).__name__}, "
  268. f"错误消息={str(retry_error)}, "
  269. f"模型={settings.llm_model}, "
  270. f"用户输入长度={len(user_text)}, "
  271. f"System Prompt长度={len(system_prompt)}, "
  272. f"堆栈跟踪:\n{traceback.format_exc()}"
  273. )
  274. raise
  275. else:
  276. logger.error(
  277. f"[LLM异常] 错误类型=LLM API调用失败, "
  278. f"异常类型={type(api_error).__name__}, "
  279. f"错误消息={str(api_error)}, "
  280. f"模型={settings.llm_model}, "
  281. f"用户输入长度={len(user_text)}, "
  282. f"System Prompt长度={len(system_prompt)}, "
  283. f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
  284. f"使用国际版={settings.use_dashscope_intl}, "
  285. f"堆栈跟踪:\n{traceback.format_exc()}"
  286. )
  287. raise
  288. buffer = ""
  289. full_response = ""
  290. sentence_count = 0
  291. detected_emotion: EmotionType = "neutral" # 存储检测到的情感
  292. emotion_yielded = False # 标记是否已发送情感
  293. chunk_count = 0 # 计数chunk
  294. for chunk in response:
  295. chunk_count += 1
  296. if chunk.choices[0].delta.content:
  297. content = chunk.choices[0].delta.content
  298. buffer += content
  299. full_response += content
  300. # 🎯 优先检测情感标记(如果还没检测到)
  301. if detected_emotion == "neutral" and "[EMOTION:" in full_response:
  302. emotion_start = full_response.find("[EMOTION:")
  303. emotion_end = full_response.find("]", emotion_start)
  304. if emotion_end > emotion_start:
  305. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  306. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
  307. if emotion_str in valid_emotions:
  308. detected_emotion = emotion_str
  309. logger.info(f"[LLM情感检测] ✅ 检测到情感标签: {emotion_str}, 原始片段: {full_response[:200]}")
  310. else:
  311. # 🔥 未知情感词,默认映射为 neutral,但仍然移除标签
  312. logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral, 原始片段: {full_response[:200]}")
  313. detected_emotion = "neutral"
  314. # 🚀 立即 yield 空句子传递情感(减少延迟)
  315. if not emotion_yielded:
  316. yield ("", detected_emotion) # 空句子,仅传递情感
  317. emotion_yielded = True
  318. # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
  319. # 检测句子边界(完整的句子)
  320. # 注意:buffer 中可能包含情感标签,但我们保留它
  321. if self._is_complete_sentence(buffer):
  322. sentence = buffer.strip()
  323. sentence_count += 1
  324. # 使用已检测到的情感,但保留情感标签在文本中
  325. yield (sentence, detected_emotion)
  326. buffer = ""
  327. # ✅ v0.3: 保留情感标签在文本中(前端需要)
  328. # 处理buffer中的剩余内容(已输出句子之后的部分)
  329. remaining = buffer.strip()
  330. if remaining:
  331. sentence_count += 1
  332. yield (remaining, detected_emotion)
  333. elif not remaining and sentence_count == 0:
  334. # 如果一个句子都没输出,输出完整内容(保留情感标签)
  335. if full_response.strip():
  336. yield (full_response.strip(), detected_emotion)
  337. else:
  338. # 🔥 兜底逻辑:LLM没有返回任何文本(只返回了情感标记)
  339. default_response = "I understand. Could you tell me more?"
  340. yield (default_response, detected_emotion)
  341. # 🔍 调试日志:记录LLM完整响应和情感检测结果
  342. logger.debug(
  343. f"[LLM完整响应] 总chunk数={chunk_count}, "
  344. f"检测到的情感={detected_emotion}, "
  345. f"是否包含[EMOTION:={'[EMOTION:' in full_response}, "
  346. f"完整响应长度={len(full_response)}, "
  347. f"响应前500字符={full_response[:500]}"
  348. )
  349. # ⚠️ 警告:如果没有检测到情感标签
  350. if detected_emotion == "neutral" and "[EMOTION:" not in full_response:
  351. logger.warning(
  352. f"[LLM情感检测] ⚠️ 未检测到情感标签,LLM可能没有生成。"
  353. f"完整响应长度={len(full_response)}, "
  354. f"响应前500字符={full_response[:500]}"
  355. )
  356. except Exception as e:
  357. logger.error(
  358. f"[LLM异常] 错误类型=LLM流式生成失败, "
  359. f"异常类型={type(e).__name__}, "
  360. f"错误消息={str(e)}, "
  361. f"模型={settings.llm_model}, "
  362. f"用户输入={user_text[:100]}... (前100字符), "
  363. f"堆栈跟踪:\n{traceback.format_exc()}"
  364. )
  365. yield ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  366. def _is_complete_sentence(self, text: str) -> bool:
  367. """检测是否完整句子"""
  368. text = text.strip()
  369. # 必须以句子结束符结尾
  370. if not text.endswith(('.', '!', '?', '。', '!', '?')):
  371. return False
  372. # 排除常见缩写(避免误判)
  373. words = text.split()
  374. if words:
  375. last_word = words[-1]
  376. # 常见英文缩写
  377. common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
  378. if last_word in common_abbrev:
  379. return False
  380. # 🔥 优化:减少最小字符限制从5到3,让短句更快发送
  381. # 例如"Hi!" "OK!" "Yes!" 这种短句也能快速返回
  382. if len(text) < 3:
  383. return False
  384. return True
  385. async def generate_response(
  386. self,
  387. user_text: str,
  388. user_info: Optional[dict] = None,
  389. is_new_user: bool = False,
  390. chat_history: Optional[list] = None
  391. ) -> tuple[str, EmotionType]:
  392. """
  393. 生成对话回复(非流式)
  394. Args:
  395. user_text: 用户输入文本
  396. user_info: 用户信息字典(包含 username, bot_name)
  397. is_new_user: 是否为新用户(首次对话)
  398. chat_history: 对话历史(可选)
  399. Returns:
  400. (response_text, emotion): 回复文本和情感
  401. """
  402. try:
  403. # 构建System Prompt
  404. system_prompt = self._build_system_prompt(user_info, is_new_user)
  405. # 构建运行时上下文
  406. runtime_context = self._build_runtime_context(user_info)
  407. # 🔥 连接健康检查(长时间未使用时)
  408. await self._ensure_llm_connection()
  409. # 构建消息列表
  410. messages = [
  411. {"role": "system", "content": system_prompt},
  412. {"role": "system", "content": runtime_context},
  413. ]
  414. if chat_history:
  415. messages.extend(chat_history)
  416. messages.append({"role": "user", "content": user_text})
  417. # 调用LLM
  418. response = self.openai_client.chat.completions.create(
  419. model=settings.llm_model,
  420. messages=messages,
  421. temperature=0.7,
  422. max_tokens=500,
  423. )
  424. full_response = response.choices[0].message.content or ""
  425. # 提取情感标签
  426. emotion: EmotionType = "neutral"
  427. response_text = full_response
  428. # 查找情感标签
  429. if "[EMOTION:" in full_response:
  430. emotion_start = full_response.find("[EMOTION:")
  431. emotion_end = full_response.find("]", emotion_start)
  432. if emotion_end > emotion_start:
  433. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  434. # 验证情感类型
  435. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry", "gentle"]
  436. if emotion_str in valid_emotions:
  437. emotion = emotion_str
  438. else:
  439. # 🔥 未知情感词,默认映射为 neutral
  440. logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral")
  441. # 移除情感标签(不发送给TTS)
  442. response_text = full_response[:emotion_start].strip()
  443. # 如果没有文本内容,使用默认回复
  444. if not response_text:
  445. response_text = "I understand. Please tell me more!"
  446. emotion = "neutral"
  447. return (response_text, emotion)
  448. except Exception as e:
  449. logger.error(
  450. f"[LLM异常] 错误类型=LLM生成失败, "
  451. f"异常类型={type(e).__name__}, "
  452. f"错误消息={str(e)}, "
  453. f"模型={settings.llm_model}, "
  454. f"用户输入={user_text[:100]}... (前100字符), "
  455. f"堆栈跟踪:\n{traceback.format_exc()}"
  456. )
  457. return ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
  458. async def text_to_speech_stream(
  459. self, text: str, emotion: EmotionType = "neutral"
  460. ) -> AsyncGenerator[bytes, None]:
  461. """
  462. 文本转语音(流式 - 实时返回音频块),带重试机制和速率控制
  463. Args:
  464. text: 要合成的文本
  465. emotion: 情感类型
  466. Yields:
  467. audio_data: PCM 音频数据块(Base64 解码后的原始数据)
  468. """
  469. import time
  470. # 🔥 TTS连接健康检查(后台异步执行,不阻塞主流程)
  471. self._trigger_tts_healthcheck_background()
  472. # 🚦 速率控制:确保两次TTS调用之间有最小间隔
  473. min_interval = settings.TTS_MIN_INTERVAL
  474. time_since_last_call = time.time() - AIService._last_tts_call_time
  475. if time_since_last_call < min_interval:
  476. wait_time = min_interval - time_since_last_call
  477. await asyncio.sleep(wait_time)
  478. # 更新最后调用时间
  479. AIService._last_tts_call_time = time.time()
  480. max_retries = settings.TTS_MAX_RETRIES
  481. retry_delay = settings.TTS_RETRY_DELAY
  482. backoff = settings.TTS_RETRY_BACKOFF
  483. for attempt in range(max_retries + 1):
  484. try:
  485. async for audio_chunk in self._text_to_speech_stream_internal(text, emotion):
  486. yield audio_chunk
  487. # 成功则退出
  488. return
  489. except Exception as e:
  490. # 检查是否为速率限制错误(429、Throttling、rate limit)
  491. error_str = str(e).lower()
  492. is_rate_limit = (
  493. 'rate limit' in error_str or
  494. 'throttling' in error_str or
  495. '429' in error_str or
  496. 'ratequota' in error_str
  497. )
  498. # 🔥 检测连接错误,自动重建连接
  499. is_connection_error = (
  500. 'connection' in error_str or
  501. 'timeout' in error_str or
  502. 'network' in error_str or
  503. 'reset' in error_str or
  504. 'broken pipe' in error_str or
  505. 'connection refused' in error_str or
  506. 'connection aborted' in error_str
  507. )
  508. logger.error(
  509. f"[TTS异常] 错误类型=TTS流式生成失败, "
  510. f"异常类型={type(e).__name__}, "
  511. f"错误消息={str(e)}, "
  512. f"是否为速率限制={is_rate_limit}, "
  513. f"是否为连接错误={is_connection_error}, "
  514. f"重试次数={attempt}/{max_retries}, "
  515. f"模型={settings.tts_model}, "
  516. f"音色={self._get_voice_for_emotion(emotion)}, "
  517. f"文本长度={len(text)}, "
  518. f"文本内容={text[:100]}... (前100字符), "
  519. f"堆栈跟踪:\n{traceback.format_exc()}"
  520. )
  521. # 连接错误:立即重建连接并重试
  522. if is_connection_error and attempt < max_retries:
  523. logger.warning(f"⚠️ TTS连接错误,自动重建连接: {e}")
  524. self._recreate_http_client()
  525. await asyncio.sleep(0.5) # 短暂等待后重试
  526. continue
  527. # 速率限制错误:等待后重试
  528. if is_rate_limit and attempt < max_retries:
  529. wait_time = retry_delay * (backoff ** attempt)
  530. logger.warning(
  531. f"[TTS警告] 速率限制,等待{wait_time:.2f}秒后重试 "
  532. f"(第{attempt+1}/{max_retries}次重试)"
  533. )
  534. await asyncio.sleep(wait_time)
  535. continue
  536. else:
  537. # 非速率限制错误或已达最大重试次数
  538. raise
  539. async def _text_to_speech_stream_internal(
  540. self, text: str, emotion: EmotionType = "neutral"
  541. ) -> AsyncGenerator[bytes, None]:
  542. """
  543. TTS流式调用的内部实现(不含重试逻辑)
  544. 使用 SpeechSynthesizer.streaming_call() 实现真正的流式传输
  545. Args:
  546. text: 要合成的文本
  547. emotion: 情感类型
  548. Yields:
  549. audio_data: PCM 音频数据块(原始音频数据)
  550. """
  551. try:
  552. import time
  553. tts_start = time.time()
  554. # 根据情感选择音色和构建 instruction
  555. voice = self._get_voice_for_emotion(emotion)
  556. instruction = self._build_emotion_instruction(emotion)
  557. # 🔍 调试:打印实际使用的配置
  558. logger.info(
  559. f"🔍 TTS调用 - 模型={settings.tts_model}, "
  560. f"音色={voice}, "
  561. f"语速={settings.tts_speech_rate}, "
  562. f"音调={settings.tts_pitch_rate}, "
  563. f"语言={settings.tts_language_hints}, "
  564. f"情感={emotion}"
  565. )
  566. # 🔥 使用队列在线程和异步之间传递数据
  567. import queue
  568. import threading
  569. from pydub import AudioSegment
  570. import io
  571. audio_queue = queue.Queue()
  572. error_holder = [None] # 用列表包装以便在线程间共享
  573. completion_event = threading.Event() # 用于等待流式完成
  574. # MP3 音频缓冲区(用于累积 MP3 数据,然后转换为 PCM)
  575. mp3_buffer = io.BytesIO()
  576. class TTSStreamCallback(ResultCallback):
  577. """TTS流式回调处理器"""
  578. def __init__(self, queue: queue.Queue, mp3_buffer: io.BytesIO,
  579. error_holder: list, completion_event: threading.Event):
  580. self.queue = queue
  581. self.mp3_buffer = mp3_buffer
  582. self.error_holder = error_holder
  583. self.completion_event = completion_event
  584. self.first_chunk_time = None
  585. self.chunk_count = 0
  586. self.total_audio_size = 0
  587. self.mp3_min_size = 4096 # MP3最小解码大小(4KB),确保能解码一个完整帧
  588. def on_open(self):
  589. """连接打开"""
  590. logger.debug("TTS流式连接已建立")
  591. self.first_chunk_time = time.time()
  592. def _process_mp3_buffer(self):
  593. """处理累积的MP3数据,转换为PCM并发送"""
  594. try:
  595. buffer_size = self.mp3_buffer.tell()
  596. if buffer_size < self.mp3_min_size:
  597. return # 数据不足,继续累积
  598. # 尝试解码MP3
  599. self.mp3_buffer.seek(0)
  600. audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
  601. # 转换为PCM格式:24kHz, 16bit, Mono
  602. audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
  603. pcm_data = audio_segment.raw_data
  604. # 分块发送PCM数据(3200字节/块)
  605. chunk_size = 3200
  606. for i in range(0, len(pcm_data), chunk_size):
  607. chunk = pcm_data[i:i + chunk_size]
  608. self.chunk_count += 1
  609. self.queue.put(('data', chunk))
  610. # 清空缓冲区(已处理的数据)
  611. self.mp3_buffer.seek(0)
  612. self.mp3_buffer.truncate(0)
  613. except FileNotFoundError as e:
  614. # ffmpeg/ffprobe 未安装
  615. error_msg = str(e)
  616. if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
  617. logger.error(
  618. f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
  619. f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
  620. )
  621. self.error_holder[0] = Exception(
  622. "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
  623. )
  624. self.queue.put(('error', self.error_holder[0]))
  625. return
  626. raise
  627. except Exception as e:
  628. # MP3数据不完整或格式错误,继续累积
  629. # 这是正常的,因为流式传输中MP3数据是分片到达的
  630. # 但如果是其他错误,记录日志
  631. error_str = str(e).lower()
  632. if 'ffprobe' not in error_str and 'ffmpeg' not in error_str and 'avprobe' not in error_str:
  633. # 不是 ffmpeg 相关错误,可能是数据不完整,继续累积
  634. pass
  635. else:
  636. # ffmpeg 相关错误,记录并抛出
  637. logger.error(f"[TTS错误] MP3解码失败: {e}")
  638. raise
  639. def on_data(self, data: bytes):
  640. """接收音频数据(MP3格式)"""
  641. try:
  642. if data:
  643. # 累积 MP3 数据
  644. self.mp3_buffer.write(data)
  645. self.total_audio_size += len(data)
  646. # 🔥 当累积足够数据时,尝试解码并发送PCM
  647. # 策略:累积到最小解码大小(4KB)后尝试解码
  648. self._process_mp3_buffer()
  649. except Exception as e:
  650. logger.error(f"[TTS回调异常] on_data错误: {e}")
  651. self.error_holder[0] = e
  652. self.queue.put(('error', e))
  653. def on_complete(self):
  654. """流式合成完成"""
  655. try:
  656. # 🔥 处理剩余的 MP3 数据
  657. if self.mp3_buffer.tell() > 0:
  658. self.mp3_buffer.seek(0)
  659. try:
  660. audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
  661. audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
  662. pcm_data = audio_segment.raw_data
  663. # 发送剩余的PCM数据
  664. chunk_size = 3200
  665. for i in range(0, len(pcm_data), chunk_size):
  666. chunk = pcm_data[i:i + chunk_size]
  667. self.chunk_count += 1
  668. self.queue.put(('data', chunk))
  669. except FileNotFoundError as e:
  670. # ffmpeg/ffprobe 未安装
  671. error_msg = str(e)
  672. if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
  673. logger.error(
  674. f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
  675. f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
  676. )
  677. self.error_holder[0] = Exception(
  678. "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
  679. )
  680. self.queue.put(('error', self.error_holder[0]))
  681. return
  682. raise
  683. except Exception as e:
  684. logger.warning(f"[TTS回调] 处理剩余数据失败: {e}")
  685. logger.debug(f"TTS流式合成完成,共{self.chunk_count}个chunk,总大小{self.total_audio_size}字节")
  686. self.queue.put(('done', None))
  687. self.completion_event.set()
  688. except Exception as e:
  689. logger.error(f"[TTS回调异常] on_complete错误: {e}")
  690. self.error_holder[0] = e
  691. self.queue.put(('error', e))
  692. def on_error(self, message: str):
  693. """发生错误"""
  694. error = Exception(f"TTS流式合成错误: {message}")
  695. logger.error(f"[TTS回调错误] {message}")
  696. self.error_holder[0] = error
  697. self.queue.put(('error', error))
  698. self.completion_event.set()
  699. def on_close(self):
  700. """连接关闭"""
  701. logger.debug("TTS流式连接已关闭")
  702. def stream_producer():
  703. """在独立线程中执行流式TTS API调用"""
  704. nonlocal chunk_count, first_chunk_time, total_audio_size
  705. try:
  706. # 🔥 关键修复:在锁内完成整个API调用
  707. with AIService._tts_call_lock:
  708. # 创建回调实例
  709. callback = TTSStreamCallback(audio_queue, mp3_buffer, error_holder, completion_event)
  710. # 创建 SpeechSynthesizer 实例(使用回调)
  711. # 🔥 注意:instruction 参数可能在某些情况下导致错误,先尝试不传
  712. synthesizer_params = {
  713. "model": settings.tts_model,
  714. "voice": voice,
  715. "speech_rate": settings.tts_speech_rate,
  716. "pitch_rate": settings.tts_pitch_rate,
  717. "language_hints": settings.tts_language_hints,
  718. "callback": callback,
  719. }
  720. # 只在 instruction 不为空时添加(避免参数错误)
  721. if instruction and instruction.strip():
  722. synthesizer_params["instruction"] = instruction
  723. synthesizer = SpeechSynthesizer(**synthesizer_params)
  724. # 🔥 使用流式调用(真正的流式传输)
  725. synthesizer.streaming_call(text)
  726. # 🔥 重要:必须调用 streaming_complete() 确保结尾部分被合成
  727. synthesizer.streaming_complete()
  728. # 等待回调完成
  729. completion_event.wait(timeout=30)
  730. except Exception as e:
  731. logger.error(
  732. f"[TTS异常] 错误类型=TTS流式生成线程异常, "
  733. f"异常类型={type(e).__name__}, "
  734. f"错误消息={str(e)}, "
  735. f"模型={settings.tts_model}, "
  736. f"音色={voice}, "
  737. f"文本长度={len(text)}, "
  738. f"堆栈跟踪:\n{traceback.format_exc()}"
  739. )
  740. error_holder[0] = e
  741. audio_queue.put(('error', e))
  742. completion_event.set()
  743. # 启动生产者线程
  744. chunk_count = 0
  745. first_chunk_time = None
  746. total_audio_size = 0
  747. received_data = False # 标记是否收到过数据
  748. producer_thread = threading.Thread(target=stream_producer, daemon=True)
  749. producer_thread.start()
  750. # 异步消费队列数据
  751. while True:
  752. # 非阻塞获取(避免阻塞事件循环)
  753. try:
  754. msg_type, data = await asyncio.to_thread(audio_queue.get, timeout=30)
  755. if msg_type == 'data':
  756. received_data = True
  757. yield data
  758. await asyncio.sleep(0) # 让出控制权
  759. elif msg_type == 'done':
  760. break
  761. elif msg_type == 'error':
  762. raise data
  763. except queue.Empty:
  764. # 检查是否已完成
  765. if completion_event.is_set():
  766. break
  767. error_msg = "TTS API 超时(30秒无响应)"
  768. logger.error(
  769. f"[TTS异常] 错误类型=TTS API超时, "
  770. f"错误消息={error_msg}, "
  771. f"模型={settings.tts_model}, "
  772. f"音色={self._get_voice_for_emotion(emotion)}, "
  773. f"文本长度={len(text)}, "
  774. f"已接收数据={received_data}, "
  775. f"chunk计数={chunk_count}"
  776. )
  777. raise Exception(error_msg)
  778. # 检查是否收到过数据(避免竞态条件)
  779. if not received_data:
  780. error_msg = f"TTS API 未返回任何音频数据 (text='{text}', emotion={emotion}, voice={voice})"
  781. if error_holder[0]:
  782. error_msg += f"\n生产者线程错误: {error_holder[0]}"
  783. logger.error(
  784. f"[TTS异常] 错误类型=TTS API无数据返回, "
  785. f"错误消息={error_msg}, "
  786. f"模型={settings.tts_model}, "
  787. f"音色={voice}, "
  788. f"文本长度={len(text)}, "
  789. f"文本内容={text[:100]}... (前100字符)"
  790. )
  791. raise Exception(error_msg)
  792. except Exception as e:
  793. logger.error(
  794. f"[TTS异常] 错误类型=TTS流式生成内部异常, "
  795. f"异常类型={type(e).__name__}, "
  796. f"错误消息={str(e)}, "
  797. f"模型={settings.tts_model}, "
  798. f"音色={self._get_voice_for_emotion(emotion)}, "
  799. f"文本长度={len(text)}, "
  800. f"堆栈跟踪:\n{traceback.format_exc()}"
  801. )
  802. raise
  803. def _trigger_llm_healthcheck_background(self):
  804. """
  805. 触发LLM健康检查(后台异步执行,不阻塞主流程)
  806. 优化:健康检查在后台执行,不等待结果,避免阻塞用户请求
  807. 如果连接有问题,在实际API调用时会捕获异常并重建连接
  808. """
  809. import time
  810. now = time.time()
  811. # 如果距离上次检查超过间隔,触发后台健康检查
  812. if now - self._last_llm_healthcheck < self._llm_healthcheck_interval:
  813. return
  814. # 避免重复执行健康检查任务
  815. if "llm" in self._healthcheck_tasks:
  816. return
  817. # 创建后台任务(不等待完成)
  818. async def background_healthcheck():
  819. try:
  820. self._healthcheck_tasks.add("llm")
  821. # 执行简单的健康检查:发送一个极短的测试请求
  822. def health_check():
  823. return self.openai_client.chat.completions.create(
  824. model=settings.llm_model,
  825. messages=[{"role": "user", "content": "Hi"}],
  826. max_tokens=1,
  827. timeout=5.0, # 5秒超时
  828. )
  829. test_response = await asyncio.to_thread(health_check)
  830. self._last_llm_healthcheck = time.time()
  831. logger.debug("✅ LLM连接健康检查成功(后台)")
  832. except Exception as e:
  833. # 健康检查失败,重建HTTP客户端
  834. logger.warning(f"⚠️ LLM连接健康检查失败,重建连接: {e}")
  835. self._recreate_http_client()
  836. self._last_llm_healthcheck = time.time()
  837. finally:
  838. self._healthcheck_tasks.discard("llm")
  839. # 在后台执行,不等待结果
  840. try:
  841. loop = asyncio.get_event_loop()
  842. loop.create_task(background_healthcheck())
  843. except RuntimeError:
  844. # 如果没有事件循环,使用线程执行
  845. import threading
  846. def run_in_thread():
  847. new_loop = asyncio.new_event_loop()
  848. asyncio.set_event_loop(new_loop)
  849. new_loop.run_until_complete(background_healthcheck())
  850. new_loop.close()
  851. threading.Thread(target=run_in_thread, daemon=True).start()
  852. async def _ensure_llm_connection(self):
  853. """
  854. 确保LLM连接可用(同步等待版本,用于需要确保连接可用的场景)
  855. 注意:此方法已废弃,改为使用 _trigger_llm_healthcheck_background()
  856. 保留此方法以保持向后兼容,但不再使用
  857. """
  858. # 直接返回,不执行阻塞的健康检查
  859. # 如果连接有问题,在实际API调用时会捕获异常并重建连接
  860. pass
  861. def _trigger_tts_healthcheck_background(self):
  862. """
  863. 触发TTS健康检查(后台异步执行,不阻塞主流程)
  864. 优化:健康检查在后台执行,不等待结果,避免阻塞用户请求
  865. 如果连接有问题,在实际API调用时会捕获异常并重建连接
  866. """
  867. import time
  868. now = time.time()
  869. # 如果距离上次检查超过间隔,触发后台健康检查
  870. if now - self._last_tts_healthcheck < self._tts_healthcheck_interval:
  871. return
  872. # 避免重复执行健康检查任务
  873. if "tts" in self._healthcheck_tasks:
  874. return
  875. # 创建后台任务(不等待完成)
  876. async def background_healthcheck():
  877. try:
  878. self._healthcheck_tasks.add("tts")
  879. # 执行简单的健康检查:发送一个极短的测试TTS请求
  880. def health_check():
  881. # 使用 SpeechSynthesizer 进行健康检查
  882. synthesizer = SpeechSynthesizer(
  883. model=settings.tts_model,
  884. voice=settings.tts_voice,
  885. speech_rate=settings.tts_speech_rate,
  886. pitch_rate=settings.tts_pitch_rate,
  887. language_hints=settings.tts_language_hints,
  888. )
  889. # 发送极短文本进行测试
  890. audio = synthesizer.call("Hi")
  891. return True
  892. await asyncio.to_thread(health_check)
  893. self._last_tts_healthcheck = time.time()
  894. logger.debug("✅ TTS连接健康检查成功(后台)")
  895. except Exception as e:
  896. # 健康检查失败,重建HTTP客户端
  897. logger.warning(f"⚠️ TTS连接健康检查失败,重建连接: {e}")
  898. self._recreate_http_client()
  899. self._last_tts_healthcheck = time.time()
  900. finally:
  901. self._healthcheck_tasks.discard("tts")
  902. # 在后台执行,不等待结果
  903. try:
  904. loop = asyncio.get_event_loop()
  905. loop.create_task(background_healthcheck())
  906. except RuntimeError:
  907. # 如果没有事件循环,使用线程执行
  908. import threading
  909. def run_in_thread():
  910. new_loop = asyncio.new_event_loop()
  911. asyncio.set_event_loop(new_loop)
  912. new_loop.run_until_complete(background_healthcheck())
  913. new_loop.close()
  914. threading.Thread(target=run_in_thread, daemon=True).start()
  915. async def _ensure_tts_connection(self):
  916. """
  917. 确保TTS连接可用(同步等待版本,用于需要确保连接可用的场景)
  918. 注意:此方法已废弃,改为使用 _trigger_tts_healthcheck_background()
  919. 保留此方法以保持向后兼容,但不再使用
  920. """
  921. # 直接返回,不执行阻塞的健康检查
  922. # 如果连接有问题,在实际API调用时会捕获异常并重建连接
  923. pass
  924. def _recreate_http_client(self):
  925. """重建HTTP客户端(连接失效时)"""
  926. # 🔥 使用连接池管理器重建连接(所有实例共享)
  927. http_pool.recreate_client()
  928. # 🔥 重新创建OpenAI客户端(使用新的HTTP连接池)
  929. http_client = http_pool.get_client()
  930. self.openai_client = OpenAI(
  931. api_key=settings.active_dashscope_api_key,
  932. base_url=get_dashscope_http_base_url(),
  933. http_client=http_client, # 使用新的连接池
  934. )
  935. logger.info("✅ HTTP客户端已重建(通过连接池管理器)")
  936. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  937. """根据情感选择音色"""
  938. # 所有情感统一使用配置文件中的音色
  939. return settings.tts_voice
  940. def _build_emotion_instruction(self, emotion: EmotionType) -> str:
  941. """根据情感构建 instruction 参数(用于 cosyvoice-v3)"""
  942. # 🔥 根据官方文档,instruction 格式应为英文,且情感名称需小写
  943. # 暂时返回空字符串,避免参数错误(428),后续可根据需要启用
  944. # emotion_map = {
  945. # "neutral": "You are having a casual conversation, your emotion is neutral.",
  946. # "happy": "You are having a casual conversation, your emotion is happy.",
  947. # "sad": "You are having a casual conversation, your emotion is sad.",
  948. # "excited": "You are having a casual conversation, your emotion is excited.",
  949. # "angry": "You are having a casual conversation, your emotion is angry.",
  950. # "surprised": "You are having a casual conversation, your emotion is surprised.",
  951. # "thoughtful": "You are having a casual conversation, your emotion is thoughtful.",
  952. # "encouraging": "You are having a casual conversation, your emotion is encouraging.",
  953. # "confused": "You are having a casual conversation, your emotion is confused.",
  954. # "worried": "You are having a casual conversation, your emotion is worried.",
  955. # "satisfied": "You are having a casual conversation, your emotion is satisfied.",
  956. # "friendly": "You are having a casual conversation, your emotion is friendly.",
  957. # "sorry": "You are having a casual conversation, your emotion is sorry.",
  958. # "fearful": "You are having a casual conversation, your emotion is fearful.",
  959. # }
  960. # return emotion_map.get(emotion, emotion_map["neutral"])
  961. return "" # 暂时禁用 instruction,避免 428 错误
  962. # ===================== System Prompts (参考 prompts-v0.2.1.py 架构) =====================
  963. def _get_system_prompt_base(self, user_info: Optional[dict] = None) -> str:
  964. """
  965. 基础提示词(Lumi 的"本体")- v0.3 Pre-Adventure Bonding Phase
  966. 参考 prompts-v0.3.py 的 SYSTEM_PROMPT_BASE
  967. 注意:情感标签说明是系统级要求(用于TTS和前端动画),prompts-v0.3.py 中不包含,但实际系统需要
  968. Args:
  969. user_info: 用户信息字典(包含 username, bot_name)
  970. """
  971. # 获取用户命名的Lumi名字
  972. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
  973. return f"""You are {lumi_name} — a warm, gentle underground spirit.
  974. You and the user are simply getting to know each other.
  975. ────────────────────────
  976. PRIMARY GOAL
  977. ────────────────────────
  978. Have a relaxed, friendly voice conversation that helps the user practice English comfortably.
  979. The feeling should be like chatting with a kind friend.
  980. ────────────────────────
  981. HOW YOU SPEAK
  982. ────────────────────────
  983. - Use natural, conversational, everyday English.
  984. - Keep replies simple and short (no more than 2 sentences).
  985. - Often end with ONE easy, open question.
  986. - Sound friendly and present, not poetic or expressive.
  987. - Do NOT jump to unrelated daily facts (e.g. breakfast, weather, routine) if the user is sharing a feeling.
  988. - Follow emotional continuity first.
  989. - Do NOT use emojis, emoticons, or decorative symbols.
  990. - Stay within the current topic unless the user clearly introduces a new one.
  991. - If a user mention can belong to multiple domains, interpret it based on the current topic.
  992. If the user writes Chinese, reply in simple English.
  993. You may briefly check understanding, but do not translate long text.
  994. ────────────────────────
  995. YOUR PERSONALITY (LUMI)
  996. ────────────────────────
  997. - Friendly, calm, and a little shy.
  998. - Curious about everyday human life.
  999. - Comfortable with simple, quiet conversation.
  1000. ────────────────────────
  1001. WORLD BACKGROUND (PASSIVE ONLY)
  1002. ────────────────────────
  1003. These facts are true but should stay in the background:
  1004. - You live in an underground world.
  1005. - At its centre is the Tree of Life.
  1006. - The Tree is ill; darkness has affected some areas.
  1007. - Glimmer Forest is where you live.
  1008. Important rules:
  1009. - Do NOT bring these up on your own.
  1010. - Do NOT add world or environment details as decoration.
  1011. - Only talk about them if the user asks directly.
  1012. ────────────────────────
  1013. LANGUAGE CONSTRAINT (B1 MODE)
  1014. ────────────────────────
  1015. Assume the user may be around CEFR B1.
  1016. - Use simple, high-frequency words.
  1017. - Prefer short sentences in PRESENT tense.
  1018. ────────────────────────
  1019. LANGUAGE SUPPORT (HIDDEN)
  1020. ────────────────────────
  1021. - Focus on meaning and confidence.
  1022. - If the user makes mistakes, gently recast in your reply.
  1023. - Never explain grammar or spelling.
  1024. - Never mention errors.
  1025. **Emotion Tag - REQUIRED**:
  1026. Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 1-2 sentences of spoken response.
  1027. Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  1028. **Choose based on**:
  1029. - User's emotion (sad→sorry/encouraging, happy→excited/happy)
  1030. - Your response tone (empathetic, supportive, cheerful, friendly)
  1031. Example:
  1032. [EMOTION:happy]
  1033. Hello! I'm so glad to see you. How are you feeling today?
  1034. Critical:
  1035. - NEVER return ONLY the emotion tag
  1036. - ALWAYS include actual spoken text after the tag
  1037. - The emotion tag MUST be the FIRST line, before any other content
  1038. - NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
  1039. - NO emojis or emoticons in your response"""
  1040. def _get_system_prompt_new_user(self) -> str:
  1041. """新用户模式(延迟入戏,逐步熟悉)- v0.3,参考 prompts-v0.3.py 的 SYSTEM_PROMPT_NEW_USER"""
  1042. return r"""
  1043. USER CONTEXT: NEW USER
  1044. This is your first time meeting the user.
  1045. Interaction style:
  1046. - Assume no shared history.
  1047. - Let the user decide how much to say.
  1048. Conversation approach:
  1049. - Show mild curiosity and warmth.
  1050. - Invite everyday, easy topics.
  1051. Avoid:
  1052. - introduce world background unless the user asks
  1053. - references to shared memories
  1054. - hints about future adventures or plans
  1055. The feeling should be:
  1056. "Two strangers sitting down quietly and starting to talk."
  1057. """
  1058. def _get_system_prompt_returning_user(self) -> str:
  1059. """回归用户模式(直接入戏,推进世界)- v0.3,参考 prompts-v0.3.py 的 SYSTEM_PROMPT_RETURNING_USER"""
  1060. return r"""
  1061. USER CONTEXT: RETURNING USER
  1062. You have talked with this user before.
  1063. Interaction style:
  1064. - Warm and familiar, but relaxed.
  1065. - You may gently acknowledge seeing them again.
  1066. - Continuity should feel optional, not tested.
  1067. Conversation approach:
  1068. - A soft reconnection is enough.
  1069. - You may refer to general past impressions if relevant.
  1070. - Let the user lead where the conversation goes today.
  1071. Avoid:
  1072. - assuming emotional closeness
  1073. - checking memory explicitly
  1074. - pushing deeper topics too quickly
  1075. The feeling should be:
  1076. "Nice to see you again — we can just pick up from here."
  1077. """
  1078. def _build_runtime_context(self, user_info: Optional[dict] = None) -> str:
  1079. """
  1080. 构建运行时上下文(每轮注入)- v0.3
  1081. 包含用户信息、Lumi名称和历史记忆摘要
  1082. """
  1083. if not user_info:
  1084. user_info = {}
  1085. user_name = (user_info.get("username") or "").strip() or "Neo"
  1086. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
  1087. user_id = user_info.get("user_id", "")
  1088. # 尝试加载记忆摘要(从 JSON 文件)
  1089. memory_summary = ""
  1090. if user_id:
  1091. try:
  1092. import sys
  1093. from pathlib import Path
  1094. # 添加项目根目录到路径
  1095. project_root = Path(__file__).parent.parent
  1096. sys.path.insert(0, str(project_root))
  1097. from memory_summaries.memory_service import load_memory
  1098. memory = load_memory(user_id)
  1099. memory_summary = memory.get("meta.memory_summary", "")
  1100. if isinstance(memory_summary, str):
  1101. memory_summary = memory_summary.strip()
  1102. else:
  1103. memory_summary = ""
  1104. except Exception as e:
  1105. logger.debug(f"加载记忆失败: {e}")
  1106. memory_summary = ""
  1107. memory_block = memory_summary if memory_summary else "(none)"
  1108. return f"""
  1109. [RUNTIME CONTEXT]
  1110. User name: {user_name}
  1111. Lumi name: {lumi_name}
  1112. User level: beginner
  1113. Current zone: Glimmer Forest
  1114. Last mission: Warm-up near the Tree
  1115. {memory_block}
  1116. """.strip()
  1117. def _build_system_prompt(
  1118. self,
  1119. user_info: Optional[dict] = None,
  1120. is_new_user: bool = False
  1121. ) -> str:
  1122. """
  1123. 构建System Prompt(模块化架构,参考 prompts-v0.2.1.py)
  1124. Args:
  1125. user_info: 用户信息字典(包含 username, bot_name)
  1126. is_new_user: 是否为新用户(首次对话)
  1127. Returns:
  1128. 完整的 System Prompt
  1129. """
  1130. # 基础提示词
  1131. base_prompt = self._get_system_prompt_base(user_info)
  1132. # 根据用户类型选择模式
  1133. if is_new_user:
  1134. user_mode_prompt = self._get_system_prompt_new_user()
  1135. else:
  1136. user_mode_prompt = self._get_system_prompt_returning_user()
  1137. # 组合提示词
  1138. system_prompt = base_prompt + "\n\n" + user_mode_prompt
  1139. return system_prompt
  1140. def _build_simplified_prompt(self, learner_proficiency: str, age_tag: str, topic_tag: str, experiment_tag: str) -> str:
  1141. """构建简化版System Prompt(优化延迟,减少token)"""
  1142. prompt = f"""You are KELLY, a friendly English tutor in London.
  1143. **Target**: {learner_proficiency} level, {age_tag}, {topic_tag}
  1144. **Guidelines**:
  1145. 1. Lead conversation actively, 2-3 short simple sentences
  1146. 2. Use A1-A2 level vocabulary only
  1147. 3. Ask easy, personal follow-up questions
  1148. 4. If unclear, ask for clarification then wait
  1149. 5. Transition smoothly between topics
  1150. 6. Be empathetic but natural
  1151. """
  1152. # 实验组:简化的纠错规则
  1153. if experiment_tag == "experiment":
  1154. prompt += """
  1155. **Feedback**:
  1156. - Recast grammar errors naturally (one at a time)
  1157. - Supportive tone, praise progress
  1158. - Never say "you made a mistake"
  1159. """
  1160. # 情感标签(必需)
  1161. prompt += """
  1162. **Emotion Tag - REQUIRED**:
  1163. Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 2-3 sentences of spoken response.
  1164. Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  1165. **Choose based on**:
  1166. - Learner's emotion (sad→sorry/encouraging, happy→excited)
  1167. - Your response tone (empathetic, supportive, cheerful)
  1168. Example:
  1169. [EMOTION:sorry]
  1170. I'm sorry to hear that. What happened?
  1171. Critical:
  1172. - NEVER return ONLY the emotion tag
  1173. - ALWAYS include actual spoken text after the tag
  1174. - NO parentheses () or brackets [] in spoken text
  1175. """
  1176. return prompt
  1177. async def generate_summary(self, prompt: str) -> str:
  1178. """
  1179. 生成对话总结(非流式)
  1180. Args:
  1181. prompt: 总结提示词
  1182. Returns:
  1183. 总结文本
  1184. """
  1185. try:
  1186. # 🔥 连接健康检查(长时间未使用时)
  1187. await self._ensure_llm_connection()
  1188. response = self.openai_client.chat.completions.create(
  1189. model=settings.llm_model,
  1190. messages=[
  1191. {"role": "system", "content": "你是一位专业的英语学习助手,擅长总结学习对话的关键信息。"},
  1192. {"role": "user", "content": prompt},
  1193. ],
  1194. temperature=0.5,
  1195. max_tokens=300,
  1196. )
  1197. summary = response.choices[0].message.content or ""
  1198. return summary.strip()
  1199. except Exception as e:
  1200. logger.error(
  1201. f"[LLM异常] 错误类型=生成总结失败, "
  1202. f"异常类型={type(e).__name__}, "
  1203. f"错误消息={str(e)}, "
  1204. f"模型={settings.llm_model}, "
  1205. f"提示词长度={len(prompt)}, "
  1206. f"提示词内容={prompt[:200]}... (前200字符), "
  1207. f"堆栈跟踪:\n{traceback.format_exc()}"
  1208. )
  1209. return ""
  1210. # 全局AI服务实例
  1211. ai_service = AIService()