ai_service.py 64 KB

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