ai_service.py 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461
  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. 登录问候语(纯文本,不含情感标签;由 _login_greeting_prompt 约束)
  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. extra_body=settings.llm_openai_extra_body,
  182. )
  183. greeting_text = response.choices[0].message.content.strip()
  184. # 注意:问候语不需要情感标签,直接返回
  185. return greeting_text
  186. except Exception as e:
  187. logger.error(f"❌ 生成登录问候失败: {e}")
  188. # 降级方案:使用固定模板(不包含情感标签)
  189. return f"Welcome back, {user_name}. I'm {lumi_name}. How are you feeling today?"
  190. async def generate_response_stream(
  191. self,
  192. user_text: str,
  193. user_info: Optional[dict] = None,
  194. is_new_user: bool = False,
  195. chat_history: Optional[list] = None
  196. ) -> AsyncGenerator[tuple[str, EmotionType], None]:
  197. """
  198. 生成对话回复(流式)
  199. Args:
  200. user_text: 用户输入文本
  201. user_info: 用户信息字典(包含 username, bot_name)
  202. is_new_user: 是否为新用户(首次对话)
  203. chat_history: 对话历史(可选)
  204. Yields:
  205. (sentence, emotion): 句子片段和情感
  206. """
  207. try:
  208. # 构建System Prompt
  209. system_prompt = self._build_system_prompt(user_info, is_new_user)
  210. # 构建运行时上下文
  211. runtime_context = self._build_runtime_context(user_info)
  212. # 调用LLM流式API
  213. import time
  214. llm_api_start = time.time()
  215. # 🔥 连接健康检查(后台异步执行,不阻塞主流程)
  216. self._trigger_llm_healthcheck_background()
  217. # 构建消息列表
  218. messages = [
  219. {"role": "system", "content": system_prompt},
  220. {"role": "system", "content": runtime_context}, # 注入运行时上下文
  221. ]
  222. # 添加对话历史(如果有)
  223. if chat_history:
  224. messages.extend(chat_history)
  225. # 情感标签规则已写在 _get_system_prompt_base 中,不再追加重复 system 条以节省 token
  226. # 添加当前用户输入
  227. messages.append({"role": "user", "content": user_text})
  228. try:
  229. response = self.openai_client.chat.completions.create(
  230. model=settings.llm_model,
  231. messages=messages,
  232. temperature=0.7,
  233. max_tokens=500,
  234. stream=True, # 启用流式
  235. extra_body=settings.llm_openai_extra_body,
  236. )
  237. except Exception as api_error:
  238. # 🔥 检测连接错误,自动重建连接
  239. error_str = str(api_error).lower()
  240. is_connection_error = (
  241. 'connection' in error_str or
  242. 'timeout' in error_str or
  243. 'network' in error_str or
  244. 'reset' in error_str or
  245. 'broken pipe' in error_str or
  246. 'connection refused' in error_str or
  247. 'connection aborted' in error_str
  248. )
  249. if is_connection_error:
  250. logger.warning(f"⚠️ LLM连接错误,自动重建连接: {api_error}")
  251. self._recreate_http_client()
  252. # 重建后重试一次
  253. try:
  254. response = self.openai_client.chat.completions.create(
  255. model=settings.llm_model,
  256. messages=messages,
  257. temperature=0.7,
  258. max_tokens=500,
  259. stream=True,
  260. extra_body=settings.llm_openai_extra_body,
  261. )
  262. logger.info("✅ LLM连接重建后重试成功")
  263. except Exception as retry_error:
  264. logger.error(
  265. f"[LLM异常] 错误类型=LLM API调用失败(重建后仍失败), "
  266. f"异常类型={type(retry_error).__name__}, "
  267. f"错误消息={str(retry_error)}, "
  268. f"模型={settings.llm_model}, "
  269. f"用户输入长度={len(user_text)}, "
  270. f"System Prompt长度={len(system_prompt)}, "
  271. f"堆栈跟踪:\n{traceback.format_exc()}"
  272. )
  273. raise
  274. else:
  275. logger.error(
  276. f"[LLM异常] 错误类型=LLM API调用失败, "
  277. f"异常类型={type(api_error).__name__}, "
  278. f"错误消息={str(api_error)}, "
  279. f"模型={settings.llm_model}, "
  280. f"用户输入长度={len(user_text)}, "
  281. f"System Prompt长度={len(system_prompt)}, "
  282. f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
  283. f"使用国际版={settings.use_dashscope_intl}, "
  284. f"堆栈跟踪:\n{traceback.format_exc()}"
  285. )
  286. raise
  287. buffer = ""
  288. full_response = ""
  289. sentence_count = 0
  290. detected_emotion: EmotionType = "neutral" # 存储检测到的情感
  291. emotion_yielded = False # 标记是否已发送情感
  292. chunk_count = 0 # 计数chunk
  293. for chunk in response:
  294. chunk_count += 1
  295. if chunk.choices[0].delta.content:
  296. content = chunk.choices[0].delta.content
  297. buffer += content
  298. full_response += content
  299. # 🎯 优先检测情感标记(如果还没检测到)
  300. if detected_emotion == "neutral" and "[EMOTION:" in full_response:
  301. emotion_start = full_response.find("[EMOTION:")
  302. emotion_end = full_response.find("]", emotion_start)
  303. if emotion_end > emotion_start:
  304. emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
  305. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
  306. if emotion_str in valid_emotions:
  307. detected_emotion = emotion_str
  308. logger.info(f"[LLM情感检测] ✅ 检测到情感标签: {emotion_str}, 原始片段: {full_response[:200]}")
  309. else:
  310. # 🔥 未知情感词,默认映射为 neutral,但仍然移除标签
  311. logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral, 原始片段: {full_response[:200]}")
  312. detected_emotion = "neutral"
  313. # 🚀 立即 yield 空句子传递情感(减少延迟)
  314. if not emotion_yielded:
  315. yield ("", detected_emotion) # 空句子,仅传递情感
  316. emotion_yielded = True
  317. # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
  318. # 检测句子边界(完整的句子)
  319. # 注意:buffer 中可能包含情感标签,但我们保留它
  320. if self._is_complete_sentence(buffer):
  321. sentence = buffer.strip()
  322. sentence_count += 1
  323. # 使用已检测到的情感,但保留情感标签在文本中
  324. yield (sentence, detected_emotion)
  325. buffer = ""
  326. # ✅ v0.3: 保留情感标签在文本中(前端需要)
  327. # 处理buffer中的剩余内容(已输出句子之后的部分)
  328. remaining = buffer.strip()
  329. if remaining:
  330. sentence_count += 1
  331. yield (remaining, detected_emotion)
  332. elif not remaining and sentence_count == 0:
  333. # 如果一个句子都没输出,输出完整内容(保留情感标签)
  334. if full_response.strip():
  335. yield (full_response.strip(), detected_emotion)
  336. else:
  337. # 🔥 兜底逻辑:LLM没有返回任何文本(只返回了情感标记)
  338. default_response = "I understand. Could you tell me more?"
  339. yield (default_response, detected_emotion)
  340. # 🔍 调试日志:记录LLM完整响应和情感检测结果
  341. logger.debug(
  342. f"[LLM完整响应] 总chunk数={chunk_count}, "
  343. f"检测到的情感={detected_emotion}, "
  344. f"是否包含[EMOTION:={'[EMOTION:' in full_response}, "
  345. f"完整响应长度={len(full_response)}, "
  346. f"响应前500字符={full_response[:500]}"
  347. )
  348. # ⚠️ 警告:如果没有检测到情感标签
  349. if detected_emotion == "neutral" and "[EMOTION:" not in full_response:
  350. logger.warning(
  351. f"[LLM情感检测] ⚠️ 未检测到情感标签,LLM可能没有生成。"
  352. f"完整响应长度={len(full_response)}, "
  353. f"响应前500字符={full_response[:500]}"
  354. )
  355. except Exception as e:
  356. logger.error(
  357. f"[LLM异常] 错误类型=LLM流式生成失败, "
  358. f"异常类型={type(e).__name__}, "
  359. f"错误消息={str(e)}, "
  360. f"模型={settings.llm_model}, "
  361. f"用户输入={user_text[:100]}... (前100字符), "
  362. f"堆栈跟踪:\n{traceback.format_exc()}"
  363. )
  364. yield ("", "neutral")
  365. def _is_complete_sentence(self, text: str) -> bool:
  366. """检测是否完整句子"""
  367. text = text.strip()
  368. # 必须以句子结束符结尾
  369. if not text.endswith(('.', '!', '?', '。', '!', '?')):
  370. return False
  371. # 排除常见缩写(避免误判)
  372. words = text.split()
  373. if words:
  374. last_word = words[-1]
  375. # 常见英文缩写
  376. common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
  377. if last_word in common_abbrev:
  378. return False
  379. # 🔥 优化:减少最小字符限制从5到3,让短句更快发送
  380. # 例如"Hi!" "OK!" "Yes!" 这种短句也能快速返回
  381. if len(text) < 3:
  382. return False
  383. return True
  384. async def generate_response(
  385. self,
  386. user_text: str,
  387. user_info: Optional[dict] = None,
  388. is_new_user: bool = False,
  389. chat_history: Optional[list] = None
  390. ) -> tuple[str, EmotionType]:
  391. """
  392. 生成对话回复(非流式)
  393. Args:
  394. user_text: 用户输入文本
  395. user_info: 用户信息字典(包含 username, bot_name)
  396. is_new_user: 是否为新用户(首次对话)
  397. chat_history: 对话历史(可选)
  398. Returns:
  399. (response_text, emotion): 回复文本和情感
  400. """
  401. try:
  402. # 构建System Prompt
  403. system_prompt = self._build_system_prompt(user_info, is_new_user)
  404. # 构建运行时上下文
  405. runtime_context = self._build_runtime_context(user_info)
  406. # 🔥 连接健康检查(长时间未使用时)
  407. await self._ensure_llm_connection()
  408. # 构建消息列表
  409. messages = [
  410. {"role": "system", "content": system_prompt},
  411. {"role": "system", "content": runtime_context},
  412. ]
  413. if chat_history:
  414. messages.extend(chat_history)
  415. messages.append({"role": "user", "content": user_text})
  416. # 调用LLM
  417. response = self.openai_client.chat.completions.create(
  418. model=settings.llm_model,
  419. messages=messages,
  420. temperature=0.7,
  421. max_tokens=500,
  422. extra_body=settings.llm_openai_extra_body,
  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 ("", "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. extra_body=settings.llm_openai_extra_body,
  829. )
  830. test_response = await asyncio.to_thread(health_check)
  831. self._last_llm_healthcheck = time.time()
  832. logger.debug("✅ LLM连接健康检查成功(后台)")
  833. except Exception as e:
  834. # 健康检查失败,重建HTTP客户端
  835. logger.warning(f"⚠️ LLM连接健康检查失败,重建连接: {e}")
  836. self._recreate_http_client()
  837. self._last_llm_healthcheck = time.time()
  838. finally:
  839. self._healthcheck_tasks.discard("llm")
  840. # 在后台执行,不等待结果
  841. try:
  842. loop = asyncio.get_event_loop()
  843. loop.create_task(background_healthcheck())
  844. except RuntimeError:
  845. # 如果没有事件循环,使用线程执行
  846. import threading
  847. def run_in_thread():
  848. new_loop = asyncio.new_event_loop()
  849. asyncio.set_event_loop(new_loop)
  850. new_loop.run_until_complete(background_healthcheck())
  851. new_loop.close()
  852. threading.Thread(target=run_in_thread, daemon=True).start()
  853. async def _ensure_llm_connection(self):
  854. """
  855. 确保LLM连接可用(同步等待版本,用于需要确保连接可用的场景)
  856. 注意:此方法已废弃,改为使用 _trigger_llm_healthcheck_background()
  857. 保留此方法以保持向后兼容,但不再使用
  858. """
  859. # 直接返回,不执行阻塞的健康检查
  860. # 如果连接有问题,在实际API调用时会捕获异常并重建连接
  861. pass
  862. def _trigger_tts_healthcheck_background(self):
  863. """
  864. 触发TTS健康检查(后台异步执行,不阻塞主流程)
  865. 优化:健康检查在后台执行,不等待结果,避免阻塞用户请求
  866. 如果连接有问题,在实际API调用时会捕获异常并重建连接
  867. """
  868. import time
  869. now = time.time()
  870. # 如果距离上次检查超过间隔,触发后台健康检查
  871. if now - self._last_tts_healthcheck < self._tts_healthcheck_interval:
  872. return
  873. # 避免重复执行健康检查任务
  874. if "tts" in self._healthcheck_tasks:
  875. return
  876. # 创建后台任务(不等待完成)
  877. async def background_healthcheck():
  878. try:
  879. self._healthcheck_tasks.add("tts")
  880. # 执行简单的健康检查:发送一个极短的测试TTS请求
  881. def health_check():
  882. # 使用 SpeechSynthesizer 进行健康检查
  883. synthesizer = SpeechSynthesizer(
  884. model=settings.tts_model,
  885. voice=settings.tts_voice,
  886. speech_rate=settings.tts_speech_rate,
  887. pitch_rate=settings.tts_pitch_rate,
  888. language_hints=settings.tts_language_hints,
  889. )
  890. # 发送极短文本进行测试
  891. audio = synthesizer.call("Hi")
  892. return True
  893. await asyncio.to_thread(health_check)
  894. self._last_tts_healthcheck = time.time()
  895. logger.debug("✅ TTS连接健康检查成功(后台)")
  896. except Exception as e:
  897. # 健康检查失败,重建HTTP客户端
  898. logger.warning(f"⚠️ TTS连接健康检查失败,重建连接: {e}")
  899. self._recreate_http_client()
  900. self._last_tts_healthcheck = time.time()
  901. finally:
  902. self._healthcheck_tasks.discard("tts")
  903. # 在后台执行,不等待结果
  904. try:
  905. loop = asyncio.get_event_loop()
  906. loop.create_task(background_healthcheck())
  907. except RuntimeError:
  908. # 如果没有事件循环,使用线程执行
  909. import threading
  910. def run_in_thread():
  911. new_loop = asyncio.new_event_loop()
  912. asyncio.set_event_loop(new_loop)
  913. new_loop.run_until_complete(background_healthcheck())
  914. new_loop.close()
  915. threading.Thread(target=run_in_thread, daemon=True).start()
  916. async def _ensure_tts_connection(self):
  917. """
  918. 确保TTS连接可用(同步等待版本,用于需要确保连接可用的场景)
  919. 注意:此方法已废弃,改为使用 _trigger_tts_healthcheck_background()
  920. 保留此方法以保持向后兼容,但不再使用
  921. """
  922. # 直接返回,不执行阻塞的健康检查
  923. # 如果连接有问题,在实际API调用时会捕获异常并重建连接
  924. pass
  925. def _recreate_http_client(self):
  926. """重建HTTP客户端(连接失效时)"""
  927. # 🔥 使用连接池管理器重建连接(所有实例共享)
  928. http_pool.recreate_client()
  929. # 🔥 重新创建OpenAI客户端(使用新的HTTP连接池)
  930. http_client = http_pool.get_client()
  931. self.openai_client = OpenAI(
  932. api_key=settings.active_dashscope_api_key,
  933. base_url=get_dashscope_http_base_url(),
  934. http_client=http_client, # 使用新的连接池
  935. )
  936. logger.info("✅ HTTP客户端已重建(通过连接池管理器)")
  937. def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
  938. """根据情感选择音色"""
  939. # 所有情感统一使用配置文件中的音色
  940. return settings.tts_voice
  941. def _build_emotion_instruction(self, emotion: EmotionType) -> str:
  942. """根据情感构建 instruction 参数(用于 cosyvoice-v3)"""
  943. # 🔥 根据官方文档,instruction 格式应为英文,且情感名称需小写
  944. # 暂时返回空字符串,避免参数错误(428),后续可根据需要启用
  945. # emotion_map = {
  946. # "neutral": "You are having a casual conversation, your emotion is neutral.",
  947. # "happy": "You are having a casual conversation, your emotion is happy.",
  948. # "sad": "You are having a casual conversation, your emotion is sad.",
  949. # "excited": "You are having a casual conversation, your emotion is excited.",
  950. # "angry": "You are having a casual conversation, your emotion is angry.",
  951. # "surprised": "You are having a casual conversation, your emotion is surprised.",
  952. # "thoughtful": "You are having a casual conversation, your emotion is thoughtful.",
  953. # "encouraging": "You are having a casual conversation, your emotion is encouraging.",
  954. # "confused": "You are having a casual conversation, your emotion is confused.",
  955. # "worried": "You are having a casual conversation, your emotion is worried.",
  956. # "satisfied": "You are having a casual conversation, your emotion is satisfied.",
  957. # "friendly": "You are having a casual conversation, your emotion is friendly.",
  958. # "sorry": "You are having a casual conversation, your emotion is sorry.",
  959. # "fearful": "You are having a casual conversation, your emotion is fearful.",
  960. # }
  961. # return emotion_map.get(emotion, emotion_map["neutral"])
  962. return "" # 暂时禁用 instruction,避免 428 错误
  963. # ===================== System Prompts (参考 prompts-v0.2.1.py 架构) =====================
  964. def _get_system_prompt_base(self, user_info: Optional[dict] = None) -> str:
  965. """
  966. 基础提示词(Lumi 人设 + 口语陪练 + 纠错策略 + 情感标签)- 对齐 prompt-202604 / Backend-Prompt 20260404
  967. Args:
  968. user_info: 用户信息字典(包含 bot_name)
  969. """
  970. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
  971. return f"""You are {lumi_name} — a warm, gentle underground spirit.
  972. You help the user practice spoken English through relaxed, natural conversation.
  973. ────────────────────────
  974. PRIMARY GOAL
  975. ────────────────────────
  976. Support the user to speak more comfortably, confidently, and naturally in English.
  977. The conversation should feel easy, human, and engaging — like talking to a kind friend.
  978. ────────────────────────
  979. CONVERSATION STYLE
  980. ────────────────────────
  981. - Use natural, conversational, everyday English.
  982. - Keep replies simple and short (no more than 2 sentences).
  983. - Do NOT overload the user with questions.
  984. - Ask a question only when it helps continue the conversation.
  985. - Sometimes just respond, react, or share a short thought.
  986. - Prioritise:
  987. 1. Understanding the user
  988. 2. Emotional alignment
  989. 3. Smooth continuation
  990. - Do NOT suddenly change topic.
  991. - Do NOT introduce random daily details (e.g. food, weather) unless relevant.
  992. - Do NOT use emojis, emoticons, or decorative symbols.
  993. - Avoid repetitive patterns (e.g. always asking questions).
  994. If the user writes in Chinese:
  995. - Reply in simple English
  996. - You may gently confirm meaning, but do NOT translate long text
  997. ────────────────────────
  998. YOUR PERSONALITY
  999. ────────────────────────
  1000. - Warm, calm, slightly shy
  1001. - Supportive and attentive
  1002. - Curious about everyday human experiences
  1003. - Comfortable with simple, quiet conversation
  1004. ────────────────────────
  1005. WORLD BACKGROUND (PASSIVE ONLY)
  1006. ────────────────────────
  1007. These facts are true but this must remain invisible in normal conversation:
  1008. - You live in an underground world.
  1009. - At its centre is the Tree of Life.
  1010. - The Tree is ill; darkness has affected some areas.
  1011. - Glimmer Forest is the area where you live.
  1012. Important rules:
  1013. - Do NOT bring these up on your own.
  1014. - Do NOT add world or environment details as decoration.
  1015. - Only talk about them if the user asks directly.
  1016. ────────────────────────
  1017. LANGUAGE LEVEL (CEFR B1 TARGET)
  1018. ────────────────────────
  1019. - Use high-frequency, simple vocabulary.
  1020. - Keep sentences clear and not dense.
  1021. - Prefer shorter structures.
  1022. - Use natural tense as needed (not only present)
  1023. ────────────────────────
  1024. LANGUAGE SUPPORT (HIDDEN)
  1025. ────────────────────────
  1026. - Focus on meaning and confidence.
  1027. - If the user makes mistakes, gently recast in your reply.
  1028. - Never explain grammar or spelling.
  1029. - Never mention errors.
  1030. ━━━━━━━━━━━━━━━━
  1031. CORRECTION STRATEGY (FOCUS ON FORM)
  1032. ━━━━━━━━━━━━━━━━
  1033. Help improve accuracy WITHOUT breaking conversation flow.
  1034. Default principle:
  1035. → Communication first, correction second
  1036. Use this priority:
  1037. 1. Recast (most of the time)
  1038. 2. Clarification check (if meaning unclear)
  1039. 3. Explicit correction (only if repeated errors)
  1040. --- Recast (PRIMARY) ---
  1041. - Naturally reformulate the user's sentence with correct grammar
  1042. - Do NOT point out the mistake
  1043. - Blend correction into your response
  1044. - Keep conversation flowing
  1045. Example:
  1046. User: "He go to school yesterday"
  1047. You: "Oh, he went to school yesterday? How was it?"
  1048. --- Clarification Check ---
  1049. - Use ONLY when meaning is unclear
  1050. - Ask ONE simple question
  1051. - STOP and wait for user reply
  1052. - Do NOT continue talking in the same turn
  1053. Example:
  1054. "Do you mean he went yesterday?"
  1055. --- Explicit Correction (RARE) ---
  1056. - Use ONLY if the SAME grammar error repeats multiple times
  1057. - Explain briefly using very simple English
  1058. - Then STOP and wait for user to try again
  1059. - Focus ONLY on grammar (not vocabulary)
  1060. Example:
  1061. "We say went because it is past tense. Please try: I went to school."
  1062. --- Important Rules ---
  1063. - Do NOT correct every mistake
  1064. - Do NOT interrupt emotional expression for correction
  1065. - Keep corrections short and light
  1066. - Avoid sounding like a teacher
  1067. ━━━━━━━━━━━━━━━━
  1068. DIALOGUE QUALITY
  1069. ━━━━━━━━━━━━━━━━
  1070. - Show listening (e.g. "I see", "That sounds nice", "Oh really?")
  1071. - Build on what the user said
  1072. - Occasionally reflect or slightly extend their idea
  1073. - Keep the conversation coherent across turns
  1074. ━━━━━━━━━━━━━━━━
  1075. EMOTION TAG — REQUIRED
  1076. ━━━━━━━━━━━━━━━━
  1077. The FIRST line of every response MUST be:
  1078. [EMOTION:name]
  1079. Allowed emotions:
  1080. neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  1081. Choose based on:
  1082. - User's emotion
  1083. - Your tone
  1084. Examples:
  1085. [EMOTION:happy]
  1086. Hello! I'm so glad to see you. How are you feeling today?
  1087. Critical:
  1088. - NEVER return ONLY the emotion tag
  1089. - ALWAYS include actual spoken text after the tag
  1090. - The emotion tag MUST be the FIRST line, before any other content
  1091. - NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
  1092. - NO emojis or emoticons in your response"""
  1093. def _get_system_prompt_new_user(self) -> str:
  1094. """新用户模式(延迟入戏)- 对齐 Backend-Prompt 20260404 §1.2"""
  1095. return r"""
  1096. USER CONTEXT: NEW USER
  1097. This is your first time meeting the user.
  1098. Interaction style:
  1099. - Assume no shared history.
  1100. - Let the user decide how much to say.
  1101. Conversation approach:
  1102. - Show mild curiosity and warmth.
  1103. - Invite everyday, easy topics.
  1104. Avoid:
  1105. - introduce world background unless the user asks
  1106. - references to shared memories
  1107. - hints about future adventures or plans
  1108. The feeling should be:
  1109. "Two strangers sitting down quietly and starting to talk."
  1110. """
  1111. def _build_runtime_context(self, user_info: Optional[dict] = None) -> str:
  1112. """
  1113. 构建运行时上下文(每轮注入)- v0.3
  1114. 包含用户信息、Lumi名称和历史记忆摘要
  1115. """
  1116. if not user_info:
  1117. user_info = {}
  1118. user_name = (user_info.get("username") or "").strip() or "Neo"
  1119. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
  1120. user_id = user_info.get("user_id", "")
  1121. # 尝试加载记忆摘要(从 JSON 文件)
  1122. memory_summary = ""
  1123. if user_id:
  1124. try:
  1125. import sys
  1126. from pathlib import Path
  1127. # 添加项目根目录到路径
  1128. project_root = Path(__file__).parent.parent
  1129. sys.path.insert(0, str(project_root))
  1130. from memory_summaries.memory_service import load_memory
  1131. memory = load_memory(user_id)
  1132. memory_summary = memory.get("meta.memory_summary", "")
  1133. if isinstance(memory_summary, str):
  1134. memory_summary = memory_summary.strip()
  1135. else:
  1136. memory_summary = ""
  1137. except Exception as e:
  1138. logger.debug(f"加载记忆失败: {e}")
  1139. memory_summary = ""
  1140. memory_block = memory_summary if memory_summary else "(none)"
  1141. return f"""
  1142. [RUNTIME CONTEXT]
  1143. User name: {user_name}
  1144. Lumi name: {lumi_name}
  1145. User level: beginner
  1146. Current zone: Glimmer Forest
  1147. Last mission: Warm-up near the Tree
  1148. {memory_block}
  1149. """.strip()
  1150. def _build_system_prompt(
  1151. self,
  1152. user_info: Optional[dict] = None,
  1153. is_new_user: bool = False
  1154. ) -> str:
  1155. """
  1156. 构建 System Prompt:基础人设始终注入;仅新用户追加 USER CONTEXT: NEW USER。
  1157. 老用户不再使用「回归用户模式」文案(产品要求)。
  1158. """
  1159. base_prompt = self._get_system_prompt_base(user_info)
  1160. if is_new_user:
  1161. return base_prompt + "\n\n" + self._get_system_prompt_new_user()
  1162. return base_prompt
  1163. def _build_simplified_prompt(self, learner_proficiency: str, age_tag: str, topic_tag: str, experiment_tag: str) -> str:
  1164. """构建简化版System Prompt(优化延迟,减少token)"""
  1165. prompt = f"""You are KELLY, a friendly English tutor in London.
  1166. **Target**: {learner_proficiency} level, {age_tag}, {topic_tag}
  1167. **Guidelines**:
  1168. 1. Lead conversation actively, 2-3 short simple sentences
  1169. 2. Use A1-A2 level vocabulary only
  1170. 3. Ask easy, personal follow-up questions
  1171. 4. If unclear, ask for clarification then wait
  1172. 5. Transition smoothly between topics
  1173. 6. Be empathetic but natural
  1174. """
  1175. # 实验组:简化的纠错规则
  1176. if experiment_tag == "experiment":
  1177. prompt += """
  1178. **Feedback**:
  1179. - Recast grammar errors naturally (one at a time)
  1180. - Supportive tone, praise progress
  1181. - Never say "you made a mistake"
  1182. """
  1183. # 情感标签(必需)
  1184. prompt += """
  1185. **Emotion Tag - REQUIRED**:
  1186. Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 2-3 sentences of spoken response.
  1187. Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
  1188. **Choose based on**:
  1189. - Learner's emotion (sad→sorry/encouraging, happy→excited)
  1190. - Your response tone (empathetic, supportive, cheerful)
  1191. Example:
  1192. [EMOTION:sorry]
  1193. I'm sorry to hear that. What happened?
  1194. Critical:
  1195. - NEVER return ONLY the emotion tag
  1196. - ALWAYS include actual spoken text after the tag
  1197. - NO parentheses () or brackets [] in spoken text
  1198. """
  1199. return prompt
  1200. async def generate_summary(self, prompt: str) -> str:
  1201. """
  1202. 生成对话总结(非流式)
  1203. Args:
  1204. prompt: 总结提示词
  1205. Returns:
  1206. 总结文本
  1207. """
  1208. try:
  1209. # 🔥 连接健康检查(长时间未使用时)
  1210. await self._ensure_llm_connection()
  1211. response = self.openai_client.chat.completions.create(
  1212. model=settings.llm_model,
  1213. messages=[
  1214. {"role": "system", "content": "你是一位专业的英语学习助手,擅长总结学习对话的关键信息。"},
  1215. {"role": "user", "content": prompt},
  1216. ],
  1217. temperature=0.5,
  1218. max_tokens=300,
  1219. extra_body=settings.llm_openai_extra_body,
  1220. )
  1221. summary = response.choices[0].message.content or ""
  1222. return summary.strip()
  1223. except Exception as e:
  1224. logger.error(
  1225. f"[LLM异常] 错误类型=生成总结失败, "
  1226. f"异常类型={type(e).__name__}, "
  1227. f"错误消息={str(e)}, "
  1228. f"模型={settings.llm_model}, "
  1229. f"提示词长度={len(prompt)}, "
  1230. f"提示词内容={prompt[:200]}... (前200字符), "
  1231. f"堆栈跟踪:\n{traceback.format_exc()}"
  1232. )
  1233. return ""
  1234. # 全局AI服务实例
  1235. ai_service = AIService()