websocket_handler.py 96 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077
  1. """
  2. WebSocket处理器 - 新版本(STT+LLM+TTS流水线,支持流式LLM)
  3. """
  4. import json
  5. import base64
  6. import asyncio
  7. import time
  8. import re
  9. import logging
  10. import traceback
  11. from typing import Any, Dict, Literal, Optional
  12. from fastapi import WebSocket
  13. from starlette.websockets import WebSocketDisconnect, WebSocketState
  14. logger = logging.getLogger(__name__)
  15. from emotion_text_utils import (
  16. clean_text_for_display,
  17. strip_emotion_from_text,
  18. )
  19. # 表情符号正则表达式(用于TTS前清理)
  20. EMOJI_PATTERN = re.compile(
  21. "["
  22. "\U0001F600-\U0001F64F" # emoticons
  23. "\U0001F300-\U0001F5FF" # symbols & pictographs
  24. "\U0001F680-\U0001F6FF" # transport & map symbols
  25. "\U0001F1E0-\U0001F1FF" # flags (iOS)
  26. "\U00002702-\U000027B0"
  27. "\U000024C2-\U0001F251"
  28. "]+", flags=re.UNICODE
  29. )
  30. from models import (
  31. AudioChunkMessage,
  32. AudioEndMessage,
  33. CancelMessage,
  34. TextResultMessage,
  35. EmotionMessage,
  36. AudioResponseMessage,
  37. ErrorMessage,
  38. PingMessage,
  39. PongMessage,
  40. EnergyStoneEventMessage,
  41. EnergyStoneSpawnMessage,
  42. UserLevelMessage,
  43. )
  44. from ai_service import ai_service
  45. from request_logger import request_logger, PerformanceMetrics
  46. from config import settings
  47. from ab_experiment_media_config import resolve_llm_model, resolve_tts_params
  48. from stt_service import stt_service, STTSession
  49. from services.user_service import user_service
  50. from services.energy_stone_service import energy_stone_service
  51. from services.scenario_topic_service import scenario_topic_service
  52. from services.scenario_gift_service import grant_scene_gift_if_needed
  53. from services.llm_task_service import llm_task_service
  54. from scenario_config import (
  55. SCENE_DEFAULT,
  56. SCENE_WINDOW,
  57. SCENE_READING,
  58. SCENE_EATING,
  59. SCENE_WEIGHTS,
  60. SCENARIO_TOPICS,
  61. get_special_first_line,
  62. build_topics_payload,
  63. is_special_ab_experiment_user,
  64. )
  65. import random
  66. import uuid
  67. from sqlalchemy import text
  68. class WebSocketHandler:
  69. """WebSocket连接处理器"""
  70. def __init__(
  71. self,
  72. websocket: WebSocket,
  73. user_id: str = None,
  74. token: str = None,
  75. redis_cache = None,
  76. session_store = None,
  77. ):
  78. self.websocket = websocket
  79. self.user_id = user_id or "guest_user" # 用户ID(默认游客)
  80. self.token = token # 会话 token(单活校验)
  81. self.cancelled_requests: set = set() # 已取消的请求ID
  82. self.stt_sessions: Dict[str, STTSession] = {} # requestId -> STT会话对象
  83. self.event_loop: Optional[asyncio.AbstractEventLoop] = None # 保存事件循环引用
  84. self.request_start_times: Dict[str, float] = {} # 请求开始时间
  85. self.audio_end_times: Dict[str, float] = {} # requestId -> 录音结束时间
  86. self.ai_responses: Dict[str, list] = {} # requestId -> AI响应句子列表(用于日志记录)
  87. self.performance_metrics: Dict[str, PerformanceMetrics] = {} # requestId -> 性能指标
  88. self.energy_stone_service = None
  89. # 初始化服务(延迟加载,避免循环导入)
  90. self.message_service = None
  91. self.context_service = None
  92. self.redis_cache = redis_cache # 🔥 Redis缓存服务
  93. self.session_store = session_store # 会话存储(单活 token)
  94. # 🔥 预加载任务
  95. self.preload_tasks: Dict[str, asyncio.Task] = {}
  96. # 🔥 异步持久化队列
  97. self.pending_persistence = []
  98. self.persistence_task: Optional[asyncio.Task] = None
  99. self.persistence_interval = 5.0 # 每5秒批量持久化
  100. # 🔥 异步总结队列
  101. self.pending_summaries = []
  102. self.summary_task: Optional[asyncio.Task] = None
  103. # 情景对话:首条语音携带的 scene/topic,在进入 LLM 前落库
  104. self._pending_scenario_log: Dict[str, Dict[str, str]] = {}
  105. async def _init_services(self):
  106. """初始化服务(延迟加载,避免重复初始化)"""
  107. start_time = time.time()
  108. # 🔥 如果服务已初始化,直接返回(避免重复初始化导致的阻塞)
  109. if self.message_service and self.context_service:
  110. logger.debug(f"[{self.user_id}] 🔍 服务已初始化,跳过重复初始化")
  111. return
  112. try:
  113. logger.info(f"[{self.user_id}] 🔧 开始初始化服务...")
  114. from database import db_manager
  115. from services import MessageService, ContextService
  116. # 使用延迟初始化的全局实例
  117. from services.message_service import message_service
  118. from services.context_service import context_service
  119. # 🔥 只在服务未初始化时才设置(避免重复调用set_db_manager)
  120. if not message_service.db or not context_service.db:
  121. logger.debug(f"[{self.user_id}] 🔧 设置数据库管理器...")
  122. await message_service.set_db_manager(db_manager)
  123. await context_service.set_db_manager(db_manager)
  124. if not energy_stone_service.db:
  125. logger.debug(f"[{self.user_id}] 🔧 设置能量石服务数据库管理器...")
  126. await energy_stone_service.set_db_manager(db_manager)
  127. if not scenario_topic_service.db:
  128. await scenario_topic_service.set_db_manager(db_manager)
  129. self.message_service = message_service
  130. self.context_service = context_service
  131. self.energy_stone_service = energy_stone_service
  132. elapsed = (time.time() - start_time) * 1000
  133. logger.info(f"[{self.user_id}] ✅ 服务初始化完成,耗时: {elapsed:.1f}ms")
  134. except Exception as e:
  135. elapsed = (time.time() - start_time) * 1000
  136. logger.error(f"[{self.user_id}] ❌ 服务初始化失败,耗时: {elapsed:.1f}ms, 错误: {e}")
  137. logger.error(traceback.format_exc())
  138. async def _close_with_session_invalid(self, reason: str = "session_invalidated"):
  139. """发送会话失效错误并关闭连接"""
  140. try:
  141. await self.send_error(
  142. request_id="session",
  143. code="SESSION_INVALIDATED",
  144. error_message=reason,
  145. )
  146. except Exception:
  147. pass
  148. try:
  149. await self.websocket.close(code=4401, reason=reason)
  150. except Exception:
  151. pass
  152. async def _ensure_valid_session(self) -> bool:
  153. """校验会话 token,确保单用户单活"""
  154. if not self.session_store:
  155. return True
  156. if not self.user_id or not self.token:
  157. await self._close_with_session_invalid("missing_session_token")
  158. return False
  159. if not await self.session_store.validate(self.user_id, self.token, refresh=True):
  160. await self._close_with_session_invalid("session_invalidated")
  161. return False
  162. await self.session_store.bind_connection(self.user_id, self.token, self.websocket)
  163. return True
  164. async def handle_connection(self):
  165. """处理WebSocket连接"""
  166. await self.websocket.accept()
  167. # 初始化服务
  168. await self._init_services()
  169. # 校验会话 token
  170. if not await self._ensure_valid_session():
  171. return
  172. # 🔥 恢复未过期的能量石(页面切换后重新显示)
  173. await self._restore_active_stones()
  174. # 保存事件循环引用,供回调线程使用
  175. self.event_loop = asyncio.get_running_loop()
  176. try:
  177. while True:
  178. if not self._is_ws_connected():
  179. break
  180. try:
  181. data = await self.websocket.receive_text()
  182. except RuntimeError as exc:
  183. # 同用户重连时 bind_connection 会主动关闭旧连接,Starlette 抛 RuntimeError 而非 WebSocketDisconnect
  184. msg = str(exc).lower()
  185. if "websocket is not connected" in msg or "need to call \"accept\" first" in msg:
  186. logger.info(
  187. f"[{self.user_id}] ℹ️ WebSocket 已关闭(可能被新连接替换),停止接收消息"
  188. )
  189. break
  190. raise
  191. message_data = json.loads(data)
  192. await self.handle_message(message_data)
  193. except WebSocketDisconnect as e:
  194. close_code = getattr(e, "code", None)
  195. reason = getattr(e, "reason", None)
  196. logger.warning(f"[{self.user_id}] ⚠️ WebSocket断开: code={close_code}, reason={reason}")
  197. except Exception as e:
  198. logger.error(f"[{self.user_id}] ❌ WebSocket异常: {e.__class__.__name__}: {e}")
  199. logger.error(traceback.format_exc())
  200. finally:
  201. await self._cleanup_on_disconnect()
  202. close_code = getattr(self.websocket, "close_code", None)
  203. logger.info(f"[{self.user_id}] ℹ️ WebSocket连接结束,close_code={close_code}")
  204. def _is_ws_connected(self) -> bool:
  205. try:
  206. return self.websocket.client_state == WebSocketState.CONNECTED
  207. except Exception:
  208. return False
  209. async def _cleanup_on_disconnect(self) -> None:
  210. """连接关闭时释放 STT 会话等资源。"""
  211. if self.session_store:
  212. try:
  213. await self.session_store.unbind_connection(self.user_id, self.websocket)
  214. except Exception:
  215. pass
  216. for request_id in list(self.stt_sessions.keys()):
  217. try:
  218. await self.cleanup_request(request_id)
  219. except Exception as exc:
  220. logger.warning(
  221. f"[{self.user_id}] ⚠️ 断开连接时清理 request={request_id} 失败: {exc}"
  222. )
  223. for task in self.preload_tasks.values():
  224. if not task.done():
  225. task.cancel()
  226. self.preload_tasks.clear()
  227. async def handle_message(self, message_data: dict):
  228. """处理接收到的消息"""
  229. message_type = message_data.get("type")
  230. request_id = message_data.get("requestId")
  231. incoming_token = message_data.get("token")
  232. if incoming_token:
  233. self.token = incoming_token
  234. if not await self._ensure_valid_session():
  235. return
  236. # 检查是否为已取消的请求
  237. if request_id in self.cancelled_requests:
  238. return
  239. if message_type == "audio_chunk":
  240. await self.handle_audio_chunk(AudioChunkMessage(**message_data))
  241. elif message_type == "audio_end":
  242. await self.handle_audio_end(AudioEndMessage(**message_data))
  243. elif message_type == "cancel":
  244. await self.handle_cancel(CancelMessage(**message_data))
  245. elif message_type == "ping":
  246. await self.handle_ping(PingMessage(**message_data))
  247. elif message_type == "energy_stone_event":
  248. await self.handle_energy_stone_event(EnergyStoneEventMessage(**message_data))
  249. elif message_type == "start_greeting":
  250. # 由前端显式触发首句 greeting;支持 data.forceScene 测试用
  251. data = message_data.get("data") or {}
  252. force_scene = data.get("forceScene") if isinstance(data, dict) else None
  253. await self._send_greeting_message(force_scene=force_scene)
  254. elif message_type == "play_topic_first_line":
  255. # 情景对话:用户切换话题后请求播放该话题首句 TTS
  256. data = message_data.get("data") or {}
  257. if isinstance(data, dict) and data.get("topicKey"):
  258. await self._play_topic_first_line(
  259. request_id=message_data.get("requestId") or f"topic_{uuid.uuid4().hex[:8]}",
  260. topic_key=data.get("topicKey"),
  261. )
  262. async def handle_audio_chunk(self, message: AudioChunkMessage):
  263. """处理音频数据块 - 使用新的STT服务"""
  264. request_id = message.requestId
  265. # 检查是否取消
  266. if request_id in self.cancelled_requests:
  267. return
  268. # Base64解码音频数据
  269. audio_data = base64.b64decode(message.data)
  270. chunk_size = len(audio_data)
  271. # 第一个chunk:初始化请求并启动STT会话
  272. if request_id not in self.request_start_times:
  273. current_time = time.time()
  274. self.request_start_times[request_id] = current_time
  275. # 情景对话:首条语音携带的 scene/topic,进入 LLM 前落库
  276. if getattr(message, "scene", None) and getattr(message, "topic", None):
  277. self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
  278. # 初始化性能指标
  279. self.performance_metrics[request_id] = PerformanceMetrics()
  280. self.performance_metrics[request_id].user_speech_start = current_time
  281. self.performance_metrics[request_id].stt_first_audio_received = current_time
  282. # 创建STT会话
  283. session = await self._create_stt_session(request_id)
  284. if not session or not session.is_connected:
  285. await self.send_error(request_id, "STT_ERROR", "语音识别服务启动失败")
  286. return
  287. self.stt_sessions[request_id] = session
  288. # 🔥 启动Redis预加载任务(与录音并行)
  289. if self.redis_cache:
  290. self.preload_tasks[request_id] = asyncio.create_task(
  291. self._ensure_redis_context(request_id)
  292. )
  293. # 发送音频帧到STT服务
  294. session = self.stt_sessions.get(request_id)
  295. if session:
  296. success = await session.send_audio(audio_data)
  297. if not success:
  298. await self.send_error(request_id, "STT_ERROR", "发送音频数据失败")
  299. # 📝 更新STT音频统计(用于日志)
  300. if request_id in self.performance_metrics:
  301. self.performance_metrics[request_id].stt_audio_chunks += 1
  302. self.performance_metrics[request_id].stt_total_bytes += chunk_size
  303. async def handle_audio_end(self, message: AudioEndMessage):
  304. """处理音频结束 - 使用新的STT服务"""
  305. request_id = message.requestId
  306. # 情景对话:若首 chunk 未带 scene/topic,可在此补带(仅首条语音)
  307. if getattr(message, "scene", None) and getattr(message, "topic", None) and request_id not in self._pending_scenario_log:
  308. self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
  309. # 记录录音结束时间
  310. audio_end_time = time.time()
  311. self.audio_end_times[request_id] = audio_end_time
  312. # 更新性能指标
  313. if request_id in self.performance_metrics:
  314. self.performance_metrics[request_id].user_speech_end = audio_end_time
  315. self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
  316. speech_duration_ms = message.speechDuration or 0
  317. # 说话时长写库改异步,不阻塞 STT → LLM → TTS 主链路(全用户生效)
  318. if speech_duration_ms > 0:
  319. asyncio.create_task(
  320. self._update_speech_duration_async(request_id, speech_duration_ms)
  321. )
  322. # 初始化AI响应收集器
  323. self.ai_responses[request_id] = []
  324. # 检查是否取消
  325. if request_id in self.cancelled_requests:
  326. self.cancelled_requests.remove(request_id)
  327. await self.cleanup_request(request_id)
  328. return
  329. try:
  330. # 1. STT: 等待完整识别结果
  331. stt_start = time.time()
  332. session = self.stt_sessions.get(request_id)
  333. if not session:
  334. await self.send_error(request_id, "STT_ERROR", "语音识别会话不存在")
  335. request_logger.log_error(
  336. user_id=self.user_id,
  337. request_id=request_id,
  338. error_code="STT_ERROR",
  339. error_message="语音识别会话不存在",
  340. stage="stt"
  341. )
  342. return
  343. # 🔥 手动提交音频,触发识别(禁用VAD时必需)
  344. try:
  345. commit_success = await session.commit_audio()
  346. if not commit_success:
  347. logger.error(
  348. f"[STT异常] request_id={request_id}, "
  349. f"错误类型=提交识别请求失败, "
  350. f"用户ID={self.user_id}, "
  351. f"音频块数={message.totalChunks}, "
  352. f"会话状态=is_connected={session.is_connected}"
  353. )
  354. await self.send_error(request_id, "STT_ERROR", "提交识别请求失败")
  355. request_logger.log_error(
  356. user_id=self.user_id,
  357. request_id=request_id,
  358. error_code="STT_ERROR",
  359. error_message="提交识别请求失败",
  360. stage="stt"
  361. )
  362. await self.cleanup_request(request_id)
  363. return
  364. except Exception as commit_error:
  365. logger.error(
  366. f"[STT异常] request_id={request_id}, "
  367. f"错误类型=提交音频异常, "
  368. f"异常类型={type(commit_error).__name__}, "
  369. f"错误消息={str(commit_error)}, "
  370. f"用户ID={self.user_id}, "
  371. f"音频块数={message.totalChunks}, "
  372. f"堆栈跟踪:\n{traceback.format_exc()}"
  373. )
  374. await self.send_error(request_id, "STT_ERROR", f"提交音频异常: {str(commit_error)}")
  375. request_logger.log_error(
  376. user_id=self.user_id,
  377. request_id=request_id,
  378. error_code="STT_ERROR",
  379. error_message=f"提交音频异常: {str(commit_error)}",
  380. stage="stt"
  381. )
  382. await self.cleanup_request(request_id)
  383. return
  384. # 等待最终结果(根据音频长度动态调整超时)
  385. total_chunks = message.totalChunks
  386. if total_chunks > 80:
  387. timeout = 5.0
  388. elif total_chunks > 50:
  389. timeout = 4.0
  390. else:
  391. timeout = 3.0
  392. logger.info(f"[{request_id}] ⏳ 等待STT最终识别结果(超时: {timeout}s)...")
  393. text = await session.wait_for_final_result(timeout=timeout)
  394. # 获取识别结果
  395. if not text:
  396. # 检查语音检测状态,提供更详细的错误信息
  397. callback = session.callback
  398. speech_detected = callback.speech_detected if callback else False
  399. speech_start_count = callback.speech_start_count if callback else 0
  400. if not speech_detected and speech_start_count == 0:
  401. error_msg = "未检测到有效语音,请检查麦克风权限或重新录音"
  402. else:
  403. error_msg = f"语音识别超时,已检测到语音但未返回识别结果(speech_detected={speech_detected}, speech_start_count={speech_start_count})"
  404. logger.error(
  405. f"[{request_id}] ❌ STT识别失败: {error_msg}, "
  406. f"音频块数={message.totalChunks}, "
  407. f"录音时长={message.speechDuration}ms"
  408. )
  409. await self.send_error(request_id, "STT_ERROR", error_msg)
  410. request_logger.log_error(
  411. user_id=self.user_id,
  412. request_id=request_id,
  413. error_code="STT_ERROR",
  414. error_message=error_msg,
  415. stage="stt"
  416. )
  417. await self.cleanup_request(request_id)
  418. return
  419. stt_duration = time.time() - stt_start
  420. stt_complete_time = time.time()
  421. # 记录STT完成时间(在获取到text之后)
  422. stt_complete_elapsed = (stt_complete_time - self.request_start_times.get(request_id, stt_complete_time)) * 1000
  423. logger.info(f"[{request_id}] ✅ STT识别完成,识别文本: '{text[:50]}{'...' if len(text) > 50 else ''}',耗时: {stt_complete_elapsed:.1f}ms")
  424. # 更新性能指标
  425. if request_id in self.performance_metrics:
  426. self.performance_metrics[request_id].stt_completed = stt_complete_time
  427. self.performance_metrics[request_id].user_text = text
  428. # 获取STT首个文本生成时间
  429. first_text_time = session.get_first_text_time()
  430. if first_text_time:
  431. self.performance_metrics[request_id].stt_first_text_generated = first_text_time
  432. # 发送最终识别结果(source=user,前端不用于字幕)
  433. await self.send_text_result(request_id, text, True, source="user")
  434. if request_id in self.cancelled_requests:
  435. return
  436. # 🔥 LLM提示词准备开始
  437. llm_prompt_start = time.time()
  438. if request_id in self.performance_metrics:
  439. self.performance_metrics[request_id].llm_prompt_prepare_start = llm_prompt_start
  440. # 🔥 从Redis获取上下文(零延迟)
  441. context = None
  442. current_round = 0
  443. next_round = 0
  444. if self.redis_cache:
  445. # 尝试从Redis获取上下文(使用超时,避免长时间阻塞)
  446. redis_start = time.time()
  447. try:
  448. logger.info(f"[{request_id}] 🔍 开始从Redis获取上下文(超时: 2.0s)...")
  449. context = await asyncio.wait_for(
  450. self._get_context_from_redis(request_id),
  451. timeout=2.0 # 2秒超时
  452. )
  453. redis_elapsed = (time.time() - redis_start) * 1000
  454. logger.info(f"[{request_id}] ✅ Redis上下文获取完成,耗时: {redis_elapsed:.1f}ms")
  455. except asyncio.TimeoutError:
  456. redis_elapsed = (time.time() - redis_start) * 1000
  457. logger.warning(f"[{request_id}] ⚠️ 从Redis获取上下文超时(耗时: {redis_elapsed:.1f}ms),降级到MySQL")
  458. context = None
  459. if context:
  460. current_round = context.get('current_round', 0)
  461. next_round = current_round + 1
  462. logger.debug(f"[{request_id}] 📊 当前轮次: {current_round},下一轮次: {next_round}")
  463. # 🔥 立即写入Redis(用户消息,使用超时)
  464. write_start = time.time()
  465. try:
  466. logger.info(f"[{request_id}] 💾 开始写入Redis(用户消息,超时: 1.0s)...")
  467. await asyncio.wait_for(
  468. self.redis_cache.append_message(
  469. user_id=self.user_id,
  470. role='user',
  471. content=text,
  472. round_number=next_round
  473. ),
  474. timeout=1.0 # 1秒超时
  475. )
  476. append_elapsed = (time.time() - write_start) * 1000
  477. logger.debug(f"[{request_id}] ✅ Redis写入用户消息完成,耗时: {append_elapsed:.1f}ms")
  478. increment_start = time.time()
  479. await asyncio.wait_for(
  480. self.redis_cache.increment_round(self.user_id),
  481. timeout=1.0 # 1秒超时
  482. )
  483. increment_elapsed = (time.time() - increment_start) * 1000
  484. write_total = (time.time() - write_start) * 1000
  485. logger.info(f"[{request_id}] ✅ Redis写入完成(增量轮次耗时: {increment_elapsed:.1f}ms,总耗时: {write_total:.1f}ms)")
  486. except asyncio.TimeoutError:
  487. write_elapsed = (time.time() - write_start) * 1000
  488. logger.warning(f"[{request_id}] ⚠️ Redis写入超时(耗时: {write_elapsed:.1f}ms),但继续处理")
  489. except Exception as e:
  490. write_elapsed = (time.time() - write_start) * 1000
  491. logger.warning(f"[{request_id}] ⚠️ Redis写入失败(耗时: {write_elapsed:.1f}ms): {e}")
  492. # 🔥 异步保存user消息到MySQL(不阻塞主流程)
  493. await self._init_services()
  494. if self.message_service:
  495. asyncio.create_task(
  496. self.message_service.save_message(
  497. user_id=self.user_id,
  498. role='user',
  499. content=text
  500. )
  501. )
  502. else:
  503. # Redis未命中,降级到MySQL
  504. mysql_start = time.time()
  505. logger.info(f"[{request_id}] ⚠️ Redis未命中,降级到MySQL获取上下文...")
  506. await self._init_services()
  507. if self.message_service and self.context_service:
  508. try:
  509. # 保存用户消息
  510. save_start = time.time()
  511. logger.info(f"[{request_id}] 💾 开始保存用户消息到MySQL(超时: 5.0s)...")
  512. user_message = await asyncio.wait_for(
  513. self.message_service.save_message(
  514. user_id=self.user_id,
  515. role='user',
  516. content=text
  517. ),
  518. timeout=5.0
  519. )
  520. save_elapsed = (time.time() - save_start) * 1000
  521. next_round = user_message['round_number']
  522. current_round = next_round - 1
  523. logger.info(f"[{request_id}] ✅ 用户消息保存成功,轮次: {next_round},耗时: {save_elapsed:.1f}ms")
  524. # 📝 记录用户消息ID
  525. if request_id in self.performance_metrics:
  526. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  527. # 获取上下文
  528. context_start = time.time()
  529. logger.info(f"[{request_id}] 🔍 开始从MySQL获取上下文(超时: 3.0s)...")
  530. context = await asyncio.wait_for(
  531. self.context_service.get_context_for_round(
  532. user_id=self.user_id,
  533. current_round=current_round
  534. ),
  535. timeout=3.0
  536. )
  537. context_elapsed = (time.time() - context_start) * 1000
  538. mysql_total_elapsed = (time.time() - mysql_start) * 1000
  539. if context:
  540. messages_count = len(context.get('messages', []))
  541. logger.info(f"[{request_id}] ✅ 从MySQL获取上下文成功,消息数: {messages_count},获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
  542. else:
  543. logger.warning(f"[{request_id}] ⚠️ 从MySQL获取上下文为空,获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
  544. except asyncio.TimeoutError as e:
  545. elapsed = (time.time() - mysql_start) * 1000
  546. logger.error(f"[{request_id}] ❌ MySQL操作超时,耗时: {elapsed:.1f}ms, 错误: {e}")
  547. except Exception as e:
  548. elapsed = (time.time() - mysql_start) * 1000
  549. logger.error(f"[{request_id}] ❌ MySQL操作失败,耗时: {elapsed:.1f}ms, 错误: {e}")
  550. logger.error(traceback.format_exc())
  551. else:
  552. # 没有Redis,直接使用MySQL
  553. await self._init_services()
  554. if self.message_service and self.context_service:
  555. try:
  556. user_message = await asyncio.wait_for(
  557. self.message_service.save_message(
  558. user_id=self.user_id,
  559. role='user',
  560. content=text
  561. ),
  562. timeout=5.0
  563. )
  564. next_round = user_message['round_number']
  565. current_round = next_round - 1
  566. # 📝 记录用户消息ID
  567. if request_id in self.performance_metrics:
  568. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  569. context = await asyncio.wait_for(
  570. self.context_service.get_context_for_round(
  571. user_id=self.user_id,
  572. current_round=current_round
  573. ),
  574. timeout=3.0
  575. )
  576. except asyncio.TimeoutError:
  577. pass
  578. except Exception as e:
  579. pass
  580. # 🔥 LLM提示词准备结束
  581. llm_prompt_end = time.time()
  582. if request_id in self.performance_metrics:
  583. self.performance_metrics[request_id].llm_prompt_prepare_end = llm_prompt_end
  584. llm_start = time.time()
  585. # 🔥 LLM调用开始
  586. if request_id in self.performance_metrics:
  587. self.performance_metrics[request_id].llm_call_start = llm_start
  588. chunk_index = 0
  589. first_audio_time = None
  590. final_emotion = "neutral"
  591. sentence_count = 0
  592. llm_first_sentence_time = None
  593. llm_first_text_time = None # 🔥 LLM首个文本输出时间
  594. emotion_sent = False # 标记是否已发送情感
  595. # 情景对话:首条语音若携带 scene/topic,在此落库(仅记录一次)
  596. pending = self._pending_scenario_log.pop(request_id, None)
  597. if pending:
  598. await scenario_topic_service.record_conversation(
  599. self.user_id, pending["scene"], pending["topic"]
  600. )
  601. # 构建Prompt(如果有上下文,使用上下文;否则使用原始文本)
  602. prompt_build_start = time.time()
  603. # 获取用户信息(用于运行时上下文)
  604. user_info = await user_service.get_user(self.user_id)
  605. is_new_user = False
  606. if user_info:
  607. # 判断是否为新用户(total_rounds == 0)
  608. is_new_user = (user_info.get("total_rounds", 0) == 0)
  609. ab_task = None
  610. if is_special_ab_experiment_user(user_info):
  611. ab_task = await llm_task_service.get_latest_task_cached()
  612. if not ab_task:
  613. logger.warning(f"[{self.user_id}] ⚠️ A/B 用户未找到任务表 prompt,将回退默认 base prompt")
  614. ab_task_prompt = ab_task.get("task_prompt") if ab_task else None
  615. ab_task_name = ab_task.get("task_name") if ab_task else None
  616. # 获取system_prompt和runtime_context用于日志记录
  617. system_prompt = ai_service._build_system_prompt(
  618. user_info,
  619. is_new_user,
  620. ab_task_prompt=ab_task_prompt,
  621. )
  622. runtime_context = ai_service._build_runtime_context(user_info, ab_task_name=ab_task_name)
  623. # 构建对话历史(用于上下文)- v0.3: 近20轮对话
  624. chat_history = []
  625. if context and context.get("recent_messages"):
  626. for msg in context.get("recent_messages", []):
  627. role = "user" if msg.get("role") == "user" else "assistant"
  628. chat_history.append({"role": role, "content": msg.get("content", "")})
  629. # 📝 构建完整的 messages 数组(用于保存 LLM 提示词记录和日志)
  630. llm_messages = [
  631. {"role": "system", "content": system_prompt},
  632. {"role": "system", "content": runtime_context},
  633. ]
  634. if chat_history:
  635. llm_messages.extend(chat_history)
  636. llm_messages.append({"role": "user", "content": text}) # ✅ v0.3: 使用原始用户输入
  637. # 构建完整提示词用于日志(文本格式)
  638. full_prompt_parts = [
  639. f"System (Base): {system_prompt}",
  640. f"System (Runtime Context): {runtime_context}",
  641. ]
  642. if chat_history:
  643. full_prompt_parts.append(f"Chat History ({len(chat_history)} messages)")
  644. full_prompt_parts.append(f"User: {text}")
  645. full_prompt = "\n\n".join(full_prompt_parts)
  646. prompt_build_elapsed = (time.time() - prompt_build_start) * 1000
  647. # 📝 更新LLM输入统计(用于日志)
  648. if request_id in self.performance_metrics:
  649. self.performance_metrics[request_id].llm_prompt_length = len(text)
  650. self.performance_metrics[request_id].llm_context_round = next_round
  651. self.performance_metrics[request_id].llm_full_prompt = full_prompt
  652. logger.info(f"[{request_id}] 🤖 开始调用LLM,用户输入长度: {len(text)},构建耗时: {prompt_build_elapsed:.1f}ms,轮次: {next_round}")
  653. # 🔥 流式处理:LLM生成一句 → 立即TTS
  654. # ✅ v0.3: 传入原始用户输入,generate_response_stream 内部会构建完整的 messages 数组
  655. try:
  656. llm_first_chunk_time = None
  657. async for sentence, emotion in ai_service.generate_response_stream(
  658. text, # ✅ v0.3: 原始用户输入,generate_response_stream 内部会构建完整的 messages 数组
  659. user_info=user_info,
  660. is_new_user=is_new_user,
  661. chat_history=chat_history if chat_history else None,
  662. ab_task=ab_task,
  663. ):
  664. if llm_first_chunk_time is None:
  665. llm_first_chunk_time = time.time()
  666. llm_first_chunk_elapsed = (llm_first_chunk_time - llm_start) * 1000
  667. logger.info(f"[{request_id}] ✅ LLM首个响应到达,耗时: {llm_first_chunk_elapsed:.1f}ms")
  668. if request_id in self.cancelled_requests:
  669. return
  670. # 🎯 优先处理情感:第一时间发送给前端
  671. if not emotion_sent and emotion:
  672. logger.info(f"[{request_id}] 🎭 发送情感标签给前端: {emotion}, 句子内容: {sentence[:100] if sentence else '(空句子)'}")
  673. await self.send_emotion(request_id, emotion)
  674. emotion_sent = True
  675. final_emotion = emotion
  676. # ⚡ 如果是空句子(仅传递情感),跳过 TTS 处理
  677. if not sentence:
  678. continue
  679. sentence_count += 1
  680. # 字幕展示文本:剥离 [EMOTION:xxx] 及 LLM 偶发的裸 emotion 词
  681. display_text = clean_text_for_display(sentence)
  682. # 🧹 TTS 文本清理(括号/emoji 仅影响语音,不影响字幕)
  683. clean_sentence = strip_emotion_from_text(sentence)
  684. clean_sentence = clean_sentence.translate(str.maketrans('', '', '()[]')).strip()
  685. clean_sentence = EMOJI_PATTERN.sub('', clean_sentence)
  686. # 如果清理后为空,跳过TTS(emotion 已通过 send_emotion 下发,无需空字幕)
  687. if not clean_sentence:
  688. continue
  689. # 发送文本给前端(source=assistant 用于字幕拼接)
  690. if display_text:
  691. await self.send_text_result(
  692. request_id, display_text, is_final=False, source="assistant"
  693. )
  694. # 收集AI响应(用于日志记录)- 使用清理后的文本
  695. self.ai_responses[request_id].append(clean_sentence)
  696. # 记录第一个句子生成时间
  697. if llm_first_sentence_time is None:
  698. llm_first_sentence_time = time.time()
  699. # 🔥 记录LLM首个文本输出时间
  700. if llm_first_text_time is None:
  701. llm_first_text_time = time.time()
  702. if request_id in self.performance_metrics:
  703. self.performance_metrics[request_id].llm_first_text_output = llm_first_text_time
  704. tts_sentence_start = time.time()
  705. # 🔥 记录TTS调用开始时间(第一个句子)
  706. if request_id in self.performance_metrics and self.performance_metrics[request_id].tts_call_start is None:
  707. self.performance_metrics[request_id].tts_call_start = tts_sentence_start
  708. # 立即TTS合成这个句子(使用清理后的文本 + 情感参数)
  709. try:
  710. async for audio_chunk in ai_service.text_to_speech_stream(
  711. clean_sentence, emotion, user_info=user_info
  712. ):
  713. if request_id in self.cancelled_requests:
  714. return
  715. # Base64编码
  716. audio_base64 = base64.b64encode(audio_chunk).decode()
  717. # 发送音频数据
  718. await self.send_audio_response(
  719. request_id,
  720. audio_base64,
  721. chunk_index,
  722. False,
  723. )
  724. # 记录第一个音频块发送时间
  725. if chunk_index == 0:
  726. first_audio_time = time.time()
  727. time_to_first_audio = first_audio_time - audio_end_time
  728. # 🔥 记录TTS首个音频输出时间
  729. if request_id in self.performance_metrics:
  730. self.performance_metrics[request_id].tts_first_audio_output = first_audio_time
  731. # 📝 更新TTS音频统计(用于日志)
  732. if request_id in self.performance_metrics:
  733. self.performance_metrics[request_id].tts_audio_chunks += 1
  734. self.performance_metrics[request_id].tts_total_bytes += len(audio_chunk)
  735. chunk_index += 1
  736. except Exception as tts_error:
  737. # TTS异常已在ai_service中详细记录,这里只记录上下文信息
  738. logger.error(
  739. f"[WebSocket异常] request_id={request_id}, "
  740. f"错误类型=TTS合成异常, "
  741. f"异常类型={type(tts_error).__name__}, "
  742. f"错误消息={str(tts_error)}, "
  743. f"句子内容={clean_sentence[:100]}... (前100字符), "
  744. f"情感={emotion}, "
  745. f"句子序号={sentence_count}, "
  746. f"已发送chunk数={chunk_index}, "
  747. f"堆栈跟踪:\n{traceback.format_exc()}"
  748. )
  749. # 重新抛出异常,让外层处理
  750. raise
  751. # 更新当前情感(但不再发送,因为已在开头发送)
  752. if emotion:
  753. final_emotion = emotion
  754. except Exception as llm_error:
  755. # LLM异常已在ai_service中详细记录,这里只记录上下文信息
  756. logger.error(
  757. f"[WebSocket异常] request_id={request_id}, "
  758. f"错误类型=LLM生成异常, "
  759. f"异常类型={type(llm_error).__name__}, "
  760. f"错误消息={str(llm_error)}, "
  761. f"用户输入={text[:100]}... (前100字符), "
  762. f"LLM输入={llm_input[:100]}... (前100字符), "
  763. f"上下文轮次={next_round}, "
  764. f"已生成句子数={sentence_count}, "
  765. f"堆栈跟踪:\n{traceback.format_exc()}"
  766. )
  767. # 重新抛出异常,让外层处理
  768. raise
  769. # 发送 TTS 结束标记:整轮 AI 回复(可能多句话/多段 TTS)全部发完后只发一次 isLast=True,前端据此判定「对话结束」并在全部播完后才切 idle
  770. await self.send_audio_response(request_id, "", chunk_index, True)
  771. # 记录最后一个音频块发送时间
  772. last_audio_time = time.time()
  773. time_to_last_audio = last_audio_time - audio_end_time
  774. # 🔥 更新性能指标:LLM和TTS完成时间
  775. if request_id in self.performance_metrics:
  776. self.performance_metrics[request_id].llm_all_text_completed = last_audio_time
  777. self.performance_metrics[request_id].llm_call_end = last_audio_time
  778. self.performance_metrics[request_id].tts_all_audio_completed = last_audio_time
  779. self.performance_metrics[request_id].tts_call_end = last_audio_time
  780. # 📝 更新LLM输出统计
  781. self.performance_metrics[request_id].llm_emotion = final_emotion
  782. self.performance_metrics[request_id].llm_sentences = sentence_count
  783. # 📝 更新TTS输入统计
  784. self.performance_metrics[request_id].tts_voice = resolve_tts_params(user_info)["voice"]
  785. # 🔥 保存AI响应
  786. ai_response_text = ' '.join(self.ai_responses.get(request_id, []))
  787. llm_total_elapsed = (time.time() - llm_start) * 1000
  788. logger.info(f"[{request_id}] ✅ LLM调用完成,总耗时: {llm_total_elapsed:.1f}ms,句子数: {sentence_count},情感: {final_emotion}")
  789. if ai_response_text:
  790. # 🔥 立即写入Redis(不阻塞)
  791. if self.redis_cache:
  792. redis_save_start = time.time()
  793. try:
  794. logger.debug(f"[{request_id}] 💾 开始保存AI响应到Redis...")
  795. await self.redis_cache.append_message(
  796. user_id=self.user_id,
  797. role='assistant',
  798. content=ai_response_text,
  799. round_number=next_round
  800. )
  801. redis_save_elapsed = (time.time() - redis_save_start) * 1000
  802. logger.debug(f"[{request_id}] ✅ AI响应保存到Redis完成,耗时: {redis_save_elapsed:.1f}ms")
  803. except Exception as e:
  804. redis_save_elapsed = (time.time() - redis_save_start) * 1000
  805. logger.warning(f"[{request_id}] ⚠️ AI响应保存到Redis失败,耗时: {redis_save_elapsed:.1f}ms, 错误: {e}")
  806. # 🔥 立即保存AI消息到MySQL(获取message_id)
  807. if self.message_service:
  808. mysql_save_start = time.time()
  809. try:
  810. logger.info(f"[{request_id}] 💾 开始保存AI消息到MySQL(超时: 5.0s)...")
  811. assistant_message = await asyncio.wait_for(
  812. self.message_service.save_message(
  813. user_id=self.user_id,
  814. role='assistant',
  815. content=ai_response_text,
  816. emotion=final_emotion
  817. ),
  818. timeout=5.0
  819. )
  820. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  821. logger.info(f"[{request_id}] ✅ AI消息保存到MySQL成功,耗时: {mysql_save_elapsed:.1f}ms")
  822. # 📝 记录AI消息ID
  823. if request_id in self.performance_metrics:
  824. self.performance_metrics[request_id].assistant_message_id = assistant_message.get('message_id')
  825. # 🔥 保存LLM提示词记录(异步,不阻塞主流程)
  826. db_message_id = assistant_message.get('id')
  827. if db_message_id and self.message_service:
  828. try:
  829. # 获取LLM调用参数
  830. llm_call_metadata = {}
  831. if request_id in self.performance_metrics:
  832. metrics = self.performance_metrics[request_id]
  833. llm_call_metadata = {
  834. "llm_call_start": getattr(metrics, 'llm_call_start', None),
  835. "llm_first_chunk_time": getattr(metrics, 'llm_first_chunk_time', None),
  836. "llm_first_text_output": getattr(metrics, 'llm_first_text_output', None),
  837. "llm_prompt_length": getattr(metrics, 'llm_prompt_length', None),
  838. }
  839. # 异步保存LLM提示词记录(不阻塞主流程)
  840. asyncio.create_task(
  841. self.message_service.save_llm_prompt(
  842. message_id=db_message_id,
  843. user_id=self.user_id,
  844. round_number=next_round,
  845. system_prompt=system_prompt,
  846. runtime_context=runtime_context,
  847. messages=llm_messages,
  848. model_name=resolve_llm_model(user_info),
  849. temperature="0.7",
  850. max_tokens=500,
  851. stream=True,
  852. metadata=llm_call_metadata
  853. )
  854. )
  855. logger.debug(f"[{request_id}] 📝 LLM提示词记录保存任务已创建: message_id={db_message_id}")
  856. except Exception as e:
  857. logger.warning(f"[{request_id}] ⚠️ 创建LLM提示词记录保存任务失败: {e}")
  858. except asyncio.TimeoutError:
  859. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  860. logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL超时,耗时: {mysql_save_elapsed:.1f}ms")
  861. except Exception as e:
  862. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  863. logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL失败,耗时: {mysql_save_elapsed:.1f}ms, 错误: {e}")
  864. # 保存失败不影响主流程,继续异步持久化
  865. # 🔥 加入异步持久化队列(批量写入MySQL,用于重试或降级场景)
  866. asyncio.create_task(
  867. self._persist_conversation_async(
  868. request_id=request_id,
  869. user_text=text,
  870. ai_text=ai_response_text,
  871. round_number=next_round,
  872. emotion=final_emotion
  873. )
  874. )
  875. # 首次对话“水晶石”赠送已关闭:不再生成首句奖励(MissionEnergyStone)
  876. # 也不再更新 `users.crystal_stone_total`。
  877. # 注意:总结触发逻辑已移至 message_service.py
  878. # 在保存 assistant 消息时,如果 current_round % 15 == 0,会自动触发总结
  879. # 计算总耗时
  880. total_end = time.time()
  881. total_duration = total_end - self.request_start_times.get(request_id, stt_start)
  882. llm_tts_total = total_end - llm_start
  883. # 📝 记录详细性能日志到文件
  884. try:
  885. ai_response_log = ' '.join(self.ai_responses.get(request_id, []))
  886. # 更新最终的AI响应文本
  887. if request_id in self.performance_metrics:
  888. self.performance_metrics[request_id].ai_response_text = ai_response_log
  889. # 记录详细性能数据
  890. request_logger.log_request(
  891. user_id=self.user_id,
  892. request_id=request_id,
  893. metrics=self.performance_metrics[request_id]
  894. )
  895. except Exception as log_error:
  896. pass
  897. except Exception as e:
  898. # 详细记录异常信息
  899. session = self.stt_sessions.get(request_id)
  900. user_text_log = session.get_current_text() if session else ""
  901. # 判断异常来源(STT/LLM/TTS)
  902. error_type = "UNKNOWN_ERROR"
  903. error_stage = "unknown"
  904. error_str = str(e).lower()
  905. if "stt" in error_str or "语音识别" in error_str or "transcription" in error_str:
  906. error_type = "STT_ERROR"
  907. error_stage = "stt"
  908. elif "llm" in error_str or "chat" in error_str or "completion" in error_str or "generate" in error_str:
  909. error_type = "LLM_ERROR"
  910. error_stage = "llm"
  911. elif "tts" in error_str or "speech" in error_str or "audio" in error_str:
  912. error_type = "TTS_ERROR"
  913. error_stage = "tts"
  914. else:
  915. error_type = "NETWORK_ERROR"
  916. error_stage = "unknown"
  917. logger.error(
  918. f"[WebSocket异常] request_id={request_id}, "
  919. f"user_id={self.user_id}, "
  920. f"错误类型={error_type}, "
  921. f"异常类型={type(e).__name__}, "
  922. f"错误消息={str(e)}, "
  923. f"阶段={error_stage}, "
  924. f"用户输入={user_text_log[:100]}... (前100字符), "
  925. f"堆栈跟踪:\n{traceback.format_exc()}"
  926. )
  927. # 记录错误日志
  928. try:
  929. request_logger.log_error(
  930. user_id=self.user_id,
  931. request_id=request_id,
  932. error_code=error_type,
  933. error_message=str(e),
  934. stage=error_stage,
  935. user_text=user_text_log
  936. )
  937. except Exception as log_error:
  938. logger.error(
  939. f"[日志记录异常] request_id={request_id}, "
  940. f"错误类型=记录错误日志失败, "
  941. f"异常类型={type(log_error).__name__}, "
  942. f"错误消息={str(log_error)}"
  943. )
  944. await self.send_error(request_id, error_type, str(e))
  945. finally:
  946. # 清理资源
  947. await self.cleanup_request(request_id)
  948. async def _create_stt_session(self, request_id: str) -> Optional[STTSession]:
  949. """创建STT会话并设置回调"""
  950. async def on_text_update(text: str, is_final: bool):
  951. """STT文本更新回调(source=user,前端不用于字幕)"""
  952. await self.send_text_result(request_id, text, is_final, source="user")
  953. async def on_error(error_msg: str):
  954. """STT错误回调"""
  955. await self.send_error(request_id, "STT_ERROR", error_msg)
  956. # 创建会话
  957. session = await stt_service.create_session(
  958. request_id=request_id,
  959. on_text_update=on_text_update,
  960. on_error=on_error,
  961. event_loop=self.event_loop
  962. )
  963. return session if session.is_connected else None
  964. async def cleanup_request(self, request_id: str):
  965. """清理请求相关资源"""
  966. # 清理STT会话
  967. if request_id in self.stt_sessions:
  968. session = self.stt_sessions[request_id]
  969. await session.close()
  970. del self.stt_sessions[request_id]
  971. # 清理取消标记
  972. if request_id in self.cancelled_requests:
  973. self.cancelled_requests.remove(request_id)
  974. # 清理时间记录
  975. if request_id in self.request_start_times:
  976. del self.request_start_times[request_id]
  977. if request_id in self.audio_end_times:
  978. del self.audio_end_times[request_id]
  979. # 清理AI响应收集器
  980. if request_id in self.ai_responses:
  981. del self.ai_responses[request_id]
  982. # 清理性能指标
  983. if request_id in self.performance_metrics:
  984. del self.performance_metrics[request_id]
  985. async def handle_cancel(self, message: CancelMessage):
  986. """处理取消请求"""
  987. request_id = message.requestId
  988. self.cancelled_requests.add(request_id)
  989. # 清理资源(包括关闭STT会话)
  990. await self.cleanup_request(request_id)
  991. async def handle_ping(self, message: PingMessage):
  992. """处理Ping"""
  993. await self.send_pong(message.requestId)
  994. async def handle_energy_stone_event(self, message: EnergyStoneEventMessage):
  995. """处理前端能量石事件"""
  996. if not self.energy_stone_service:
  997. return
  998. metadata = {
  999. "client_ts": message.clientTs,
  1000. "position": message.position,
  1001. "request_id": message.requestId,
  1002. }
  1003. try:
  1004. await self.energy_stone_service.mark_event(
  1005. stone_id=message.stoneId,
  1006. event=message.event,
  1007. metadata={k: v for k, v in metadata.items() if v is not None},
  1008. )
  1009. except Exception as exc:
  1010. logger.warning(
  1011. "[EnergyStone] 记录事件失败: stone_id=%s event=%s error=%s",
  1012. message.stoneId,
  1013. message.event,
  1014. exc,
  1015. )
  1016. async def _maybe_spawn_mission_stone(self, request_id: str):
  1017. """首次对话奖励"""
  1018. if not self.energy_stone_service or not self.user_id:
  1019. return
  1020. try:
  1021. payload = await self.energy_stone_service.create_mission_stone_if_needed(
  1022. user_id=self.user_id,
  1023. request_id=request_id,
  1024. )
  1025. if payload:
  1026. await self.send_energy_stone_spawn(payload)
  1027. except Exception as exc:
  1028. logger.warning(
  1029. "[EnergyStone] 任务能量石生成失败: user_id=%s error=%s",
  1030. self.user_id,
  1031. exc,
  1032. )
  1033. async def _update_speech_duration_async(
  1034. self, request_id: str, speech_duration_ms: int
  1035. ) -> None:
  1036. """后台更新累积说话时长,并同步等级/能量石(不阻塞对话主链路)。"""
  1037. duration_start = time.time()
  1038. try:
  1039. logger.info(
  1040. f"[{request_id}] ⏱️ 异步更新用户说话时长: {speech_duration_ms}ms..."
  1041. )
  1042. level_update = await asyncio.wait_for(
  1043. user_service.update_speech_duration(self.user_id, speech_duration_ms),
  1044. timeout=5.0,
  1045. )
  1046. duration_elapsed = (time.time() - duration_start) * 1000
  1047. logger.info(
  1048. f"[{request_id}] ✅ 异步更新用户说话时长成功,耗时: {duration_elapsed:.1f}ms"
  1049. )
  1050. except asyncio.TimeoutError:
  1051. duration_elapsed = (time.time() - duration_start) * 1000
  1052. logger.warning(
  1053. f"[{request_id}] ⚠️ 异步更新用户说话时长超时(耗时: {duration_elapsed:.1f}ms): "
  1054. f"user_id={self.user_id}, duration_ms={speech_duration_ms}"
  1055. )
  1056. return
  1057. except Exception as e:
  1058. duration_elapsed = (time.time() - duration_start) * 1000
  1059. logger.warning(
  1060. f"[{request_id}] ⚠️ 异步更新用户说话时长失败(耗时: {duration_elapsed:.1f}ms): "
  1061. f"user_id={self.user_id}, duration_ms={speech_duration_ms}, error={e}"
  1062. )
  1063. return
  1064. if not level_update or not isinstance(level_update, dict):
  1065. return
  1066. total_duration_ms = level_update.get("total_duration_ms")
  1067. user_level = level_update.get("user_level")
  1068. if total_duration_ms is not None:
  1069. await self._maybe_spawn_conversation_stones(
  1070. total_duration_ms, speech_duration_ms, request_id
  1071. )
  1072. if user_level is not None:
  1073. await self.send_user_level_update(
  1074. request_id=request_id,
  1075. level=user_level,
  1076. total_duration_ms=total_duration_ms or 0,
  1077. )
  1078. elif total_duration_ms is not None:
  1079. try:
  1080. user_info = await user_service.get_user(self.user_id)
  1081. if user_info and user_info.get("user_level") is not None:
  1082. await self.send_user_level_update(
  1083. request_id=request_id,
  1084. level=user_info.get("user_level"),
  1085. total_duration_ms=total_duration_ms,
  1086. )
  1087. except Exception as e:
  1088. logger.warning(
  1089. f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}"
  1090. )
  1091. async def _maybe_spawn_conversation_stones(
  1092. self,
  1093. total_duration_ms: int,
  1094. increment_ms: int,
  1095. request_id: str,
  1096. ):
  1097. """按累计说话时长发放"""
  1098. if (
  1099. not self.energy_stone_service
  1100. or not self.user_id
  1101. or total_duration_ms <= 0
  1102. or increment_ms <= 0
  1103. ):
  1104. return
  1105. try:
  1106. stones = await self.energy_stone_service.create_conversation_stones_if_needed(
  1107. user_id=self.user_id,
  1108. request_id=request_id,
  1109. total_duration_ms=total_duration_ms,
  1110. duration_increment_ms=increment_ms,
  1111. )
  1112. for stone in stones:
  1113. await self.send_energy_stone_spawn(stone)
  1114. except Exception as exc:
  1115. logger.warning(
  1116. "[EnergyStone] 对话能量石生成失败: user_id=%s error=%s",
  1117. self.user_id,
  1118. exc,
  1119. )
  1120. async def _send_greeting_message(self, force_scene: Optional[str] = None):
  1121. """
  1122. 发送首句(AI先说话)
  1123. - 若 force_scene 有效则强制该场景;否则按权重随机场景(特殊场景需排除已聊完的)。
  1124. - default:新用户固定首句 / 回归用户动态登录问候。
  1125. - 特殊场景:未完成话题等概率选一,发 greeting_context 后发固定首句+TTS。
  1126. """
  1127. request_id = f"greeting_{uuid.uuid4().hex[:8]}"
  1128. try:
  1129. user_info = await user_service.get_user(self.user_id)
  1130. if not user_info:
  1131. logger.warning(f"[{self.user_id}] ⚠️ 无法获取用户信息,跳过首句发送")
  1132. return
  1133. user_name = (user_info.get("username") or "").strip() or "Neo"
  1134. is_new_user = (user_info.get("total_rounds", 0) == 0)
  1135. ab_experiment_user = is_special_ab_experiment_user(user_info)
  1136. # 1) 决定本次场景
  1137. chosen_scene = SCENE_DEFAULT
  1138. scene_anchor: Optional[Dict[str, Any]] = None
  1139. valid_force = force_scene in (SCENE_DEFAULT, SCENE_WINDOW, SCENE_READING, SCENE_EATING)
  1140. if ab_experiment_user:
  1141. chosen_scene = SCENE_DEFAULT
  1142. if valid_force and force_scene != SCENE_DEFAULT:
  1143. logger.info(
  1144. f"[{self.user_id}] A/B 实验用户忽略强制特殊场景 force_scene={force_scene},使用 default"
  1145. )
  1146. else:
  1147. logger.info(f"[{self.user_id}] A/B 实验用户,跳过特殊情景场景随机")
  1148. elif valid_force:
  1149. if force_scene == SCENE_DEFAULT:
  1150. chosen_scene = force_scene
  1151. logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}")
  1152. else:
  1153. forced_anchor = await self._pick_scene_anchor_for_user(force_scene)
  1154. if forced_anchor:
  1155. chosen_scene = force_scene
  1156. scene_anchor = forced_anchor
  1157. logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}, anchor_layout_item_id={scene_anchor.get('anchorLayoutItemId')}")
  1158. else:
  1159. chosen_scene = SCENE_DEFAULT
  1160. logger.info(f"[{self.user_id}] ℹ️ 强制场景 {force_scene} 无可用锚点,回退 default")
  1161. else:
  1162. await self._init_services()
  1163. completed = await scenario_topic_service.get_completed_count_per_scenario(self.user_id)
  1164. candidates = []
  1165. for scene_key, weight in SCENE_WEIGHTS:
  1166. if scene_key == SCENE_DEFAULT:
  1167. candidates.append((scene_key, weight))
  1168. else:
  1169. if completed.get(scene_key, 0) < 3 and await self._scene_has_available_anchor(scene_key):
  1170. candidates.append((scene_key, weight))
  1171. if not candidates:
  1172. candidates = [(SCENE_DEFAULT, 70)]
  1173. total = sum(w for _, w in candidates)
  1174. r = random.uniform(0, total)
  1175. acc = 0
  1176. for sk, w in candidates:
  1177. acc += w
  1178. if r < acc:
  1179. chosen_scene = sk
  1180. break
  1181. # 2) 默认场景:沿用原逻辑
  1182. if chosen_scene == SCENE_DEFAULT:
  1183. lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
  1184. if is_special_ab_experiment_user(user_info):
  1185. ab_task = await llm_task_service.get_latest_task_cached()
  1186. lumi_first_line = ab_task.get("lumi_first_line") if ab_task else None
  1187. greeting_text = ai_service.ab_group_greeting_line(
  1188. user_name,
  1189. lumi_name,
  1190. is_new_user,
  1191. lumi_first_line=lumi_first_line,
  1192. )
  1193. logger.info(f"[{self.user_id}] 🎯 A/B 实验用户首句: {greeting_text}")
  1194. elif is_new_user:
  1195. greeting_text = ai_service.first_meeting_line(user_name, lumi_name)
  1196. logger.info(f"[{self.user_id}] 🎯 新用户首句: {greeting_text}")
  1197. else:
  1198. await self._init_services()
  1199. chat_history = []
  1200. if self.context_service:
  1201. try:
  1202. current_round = await self.redis_cache.get_current_round(self.user_id) if self.redis_cache else 1
  1203. context = await self.context_service.get_context_for_round(
  1204. user_id=self.user_id, current_round=current_round
  1205. )
  1206. if context and context.get("recent_messages"):
  1207. for msg in context.get("recent_messages", []):
  1208. role = "user" if msg.get("role") == "user" else "assistant"
  1209. chat_history.append({"role": role, "content": msg.get("content", "")})
  1210. except Exception as e:
  1211. logger.warning(f"[{self.user_id}] ⚠️ 获取对话历史失败: {e}")
  1212. greeting_text = await ai_service.generate_login_greeting(
  1213. user_info=user_info, chat_history=chat_history if chat_history else None
  1214. )
  1215. logger.info(f"[{self.user_id}] 🎯 回归用户登录问候: {greeting_text}")
  1216. emotion = "friendly" if is_new_user else "neutral"
  1217. clean_text = greeting_text
  1218. if (
  1219. not is_special_ab_experiment_user(user_info)
  1220. and not is_new_user
  1221. and "[EMOTION:" in greeting_text
  1222. ):
  1223. emotion_start = greeting_text.find("[EMOTION:")
  1224. emotion_end = greeting_text.find("]", emotion_start)
  1225. if emotion_end > emotion_start:
  1226. emotion_str = greeting_text[emotion_start + 9:emotion_end].strip().lower()
  1227. valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
  1228. if emotion_str in valid_emotions:
  1229. emotion = emotion_str
  1230. clean_text = greeting_text[emotion_end + 1:].strip()
  1231. clean_text = clean_text.translate(str.maketrans('', '', '()[]')).strip()
  1232. clean_text = EMOJI_PATTERN.sub('', clean_text)
  1233. if not clean_text or not clean_text.strip():
  1234. clean_text = "Welcome back! How are you today?"
  1235. await self.send_emotion(request_id, emotion)
  1236. await self.send_text_result(request_id, clean_text, is_final=True, source="assistant")
  1237. chunk_index = 0
  1238. async for audio_chunk in ai_service.text_to_speech_stream(
  1239. clean_text, emotion, user_info=user_info
  1240. ):
  1241. audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
  1242. await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
  1243. chunk_index += 1
  1244. await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
  1245. logger.info(f"[{self.user_id}] ✅ 首句发送完成: request_id={request_id}")
  1246. return
  1247. # 3) 特殊场景:选话题、发 greeting_context、再发首句+TTS
  1248. if not scene_anchor:
  1249. scene_anchor = await self._pick_scene_anchor_for_user(chosen_scene)
  1250. if not scene_anchor:
  1251. logger.warning(f"[{self.user_id}] ⚠️ 特殊场景 {chosen_scene} 无可用锚点,回退默认首句")
  1252. await self._send_greeting_message(force_scene=SCENE_DEFAULT)
  1253. return
  1254. done_topics = await scenario_topic_service.get_done_topic_keys(self.user_id, chosen_scene)
  1255. all_topics = SCENARIO_TOPICS.get(chosen_scene, [])
  1256. available = [t for t in all_topics if t not in done_topics]
  1257. if not available:
  1258. logger.warning(f"[{self.user_id}] ⚠️ 特殊场景 {chosen_scene} 无可用话题,回退默认首句")
  1259. await self._send_greeting_message(force_scene=SCENE_DEFAULT)
  1260. return
  1261. chosen_topic = random.choice(available)
  1262. first_line = get_special_first_line(chosen_topic, user_name)
  1263. topics_payload = build_topics_payload(available)
  1264. await self._send_greeting_context(
  1265. request_id=request_id,
  1266. scene=chosen_scene,
  1267. topic=chosen_topic,
  1268. topics=topics_payload,
  1269. first_line=first_line,
  1270. scene_anchor=scene_anchor,
  1271. )
  1272. emotion = "friendly"
  1273. await self.send_emotion(request_id, emotion)
  1274. await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
  1275. chunk_index = 0
  1276. async for audio_chunk in ai_service.text_to_speech_stream(
  1277. first_line, emotion, user_info=user_info
  1278. ):
  1279. audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
  1280. await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
  1281. chunk_index += 1
  1282. await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
  1283. await grant_scene_gift_if_needed(self.user_id, chosen_scene)
  1284. logger.info(f"[{self.user_id}] ✅ 特殊场景首句发送完成: scene={chosen_scene}, topic={chosen_topic}, request_id={request_id}")
  1285. except Exception as e:
  1286. logger.error(f"[{self.user_id}] ❌ 发送首句失败: {e}")
  1287. logger.error(traceback.format_exc())
  1288. async def _send_greeting_context(
  1289. self,
  1290. request_id: str,
  1291. scene: str,
  1292. topic: str,
  1293. topics: list,
  1294. first_line: str,
  1295. scene_anchor: Optional[Dict[str, Any]] = None,
  1296. ):
  1297. """发送 greeting_context 消息(仅特殊场景)"""
  1298. payload = {
  1299. "type": "greeting_context",
  1300. "requestId": request_id,
  1301. "timestamp": self._get_timestamp(),
  1302. "scene": scene,
  1303. "topic": topic,
  1304. "topics": topics,
  1305. "firstLine": first_line,
  1306. }
  1307. if scene_anchor:
  1308. payload.update(scene_anchor)
  1309. await self.websocket.send_text(json.dumps(payload))
  1310. async def _scene_has_available_anchor(self, scene: str) -> bool:
  1311. if scene == SCENE_DEFAULT:
  1312. return True
  1313. anchor = await self._pick_scene_anchor_for_user(scene)
  1314. return anchor is not None
  1315. async def _pick_scene_anchor_for_user(self, scene: str) -> Optional[Dict[str, Any]]:
  1316. """为特殊场景在当前布局中随机选取一个锚点(优先新槽位模型)。"""
  1317. anchor_type: Optional[str] = None
  1318. if scene in (SCENE_READING, SCENE_EATING):
  1319. anchor_type = "seat"
  1320. elif scene == SCENE_WINDOW:
  1321. anchor_type = "window"
  1322. if not anchor_type:
  1323. return None
  1324. try:
  1325. from database import db_manager
  1326. async with db_manager.get_session() as session:
  1327. layout_user_type_res = await session.execute(
  1328. text(
  1329. """
  1330. SELECT DATA_TYPE
  1331. FROM information_schema.columns
  1332. WHERE table_schema = :db
  1333. AND table_name = 'chatbot_layouts'
  1334. AND column_name = 'user_id'
  1335. """
  1336. ),
  1337. {"db": settings.MYSQL_DATABASE},
  1338. )
  1339. layout_user_type = (layout_user_type_res.mappings().first() or {}).get("DATA_TYPE") or ""
  1340. layout_user_id_where_expr = (
  1341. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1342. if "bigint" in str(layout_user_type).lower()
  1343. else ":user_id"
  1344. )
  1345. # 1) 新模型:基于 user_chatbot_slot_placements + chatbot_backdrop_slots 选锚点
  1346. backdrop_user_type_res = await session.execute(
  1347. text(
  1348. """
  1349. SELECT DATA_TYPE
  1350. FROM information_schema.columns
  1351. WHERE table_schema = :db
  1352. AND table_name = 'user_chatbot_backdrops'
  1353. AND column_name = 'user_id'
  1354. """
  1355. ),
  1356. {"db": settings.MYSQL_DATABASE},
  1357. )
  1358. backdrop_user_type = (backdrop_user_type_res.mappings().first() or {}).get("DATA_TYPE") or ""
  1359. backdrop_user_id_where_expr = (
  1360. "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
  1361. if "bigint" in str(backdrop_user_type).lower()
  1362. else ":user_id"
  1363. )
  1364. backdrop_row_res = await session.execute(
  1365. text(
  1366. f"""
  1367. SELECT backdrop_item_id
  1368. FROM user_chatbot_backdrops
  1369. WHERE user_id = {backdrop_user_id_where_expr}
  1370. ORDER BY updated_at DESC, id DESC
  1371. LIMIT 1
  1372. """
  1373. ),
  1374. {"user_id": self.user_id},
  1375. )
  1376. backdrop_row = backdrop_row_res.mappings().first()
  1377. backdrop_item_id = (
  1378. int(backdrop_row["backdrop_item_id"])
  1379. if backdrop_row and backdrop_row.get("backdrop_item_id") is not None
  1380. else None
  1381. )
  1382. candidates: list[dict[str, Any]] = []
  1383. if backdrop_item_id is not None:
  1384. slot_candidates_res = await session.execute(
  1385. text(
  1386. f"""
  1387. SELECT
  1388. usp.id,
  1389. s.start_row,
  1390. s.start_col,
  1391. s.size_rows,
  1392. s.size_cols,
  1393. COALESCE(usp.z_index, s.z_index, si.default_z_index, 0) AS z_index
  1394. FROM user_chatbot_slot_placements usp
  1395. JOIN chatbot_backdrop_slots s
  1396. ON s.backdrop_item_id = usp.backdrop_item_id
  1397. AND s.slot_code = usp.slot_code
  1398. AND s.is_active = 1
  1399. LEFT JOIN shop_items si ON si.id = usp.item_id
  1400. WHERE usp.user_id = {layout_user_id_where_expr}
  1401. AND usp.backdrop_item_id = :backdrop_item_id
  1402. AND s.anchor_type = :anchor_type
  1403. ORDER BY usp.id ASC
  1404. """
  1405. ),
  1406. {
  1407. "user_id": self.user_id,
  1408. "backdrop_item_id": backdrop_item_id,
  1409. "anchor_type": anchor_type,
  1410. },
  1411. )
  1412. candidates = [dict(r) for r in slot_candidates_res.mappings().all()]
  1413. # 2) 兼容旧模型:新模型未命中时回退 layout_items + scene_anchor_type
  1414. if not candidates:
  1415. layout_row_res = await session.execute(
  1416. text(
  1417. f"""
  1418. SELECT id
  1419. FROM chatbot_layouts l
  1420. WHERE l.user_id = {layout_user_id_where_expr}
  1421. ORDER BY l.updated_at DESC, l.id DESC
  1422. LIMIT 1
  1423. """
  1424. ),
  1425. {"user_id": self.user_id},
  1426. )
  1427. layout_row = layout_row_res.mappings().first()
  1428. if not layout_row or layout_row.get("id") is None:
  1429. return None
  1430. items_res = await session.execute(
  1431. text(
  1432. """
  1433. SELECT
  1434. li.id,
  1435. li.start_row,
  1436. li.start_col,
  1437. li.size_rows,
  1438. li.size_cols,
  1439. li.z_index
  1440. FROM chatbot_layout_items li
  1441. JOIN shop_items si ON si.id = li.item_id
  1442. WHERE li.layout_id = :layout_id
  1443. AND si.scene_anchor_type = :anchor_type
  1444. ORDER BY li.id ASC
  1445. """
  1446. ),
  1447. {"layout_id": layout_row["id"], "anchor_type": anchor_type},
  1448. )
  1449. candidates = [dict(r) for r in items_res.mappings().all()]
  1450. if not candidates:
  1451. return None
  1452. picked = random.choice(candidates)
  1453. anchor_z = int(picked.get("z_index") or 0)
  1454. return {
  1455. "anchorLayoutItemId": int(picked["id"]),
  1456. "anchorPlacement": "window_left_bottom" if scene == SCENE_WINDOW else "seat_center_bottom",
  1457. "anchorZIndex": anchor_z,
  1458. "specialAvatarZIndex": max(anchor_z + 1, 3),
  1459. }
  1460. except Exception as e:
  1461. logger.warning(f"[{self.user_id}] 获取场景锚点失败: scene={scene}, error={e}")
  1462. return None
  1463. async def _play_topic_first_line(self, request_id: str, topic_key: str):
  1464. """情景对话:播放某话题的规范首句 TTS(用户切换话题后调用)"""
  1465. try:
  1466. user_info = await user_service.get_user(self.user_id)
  1467. user_name = (user_info.get("username") or "").strip() or "Neo" if user_info else "Neo"
  1468. first_line = get_special_first_line(topic_key, user_name)
  1469. emotion = "friendly"
  1470. await self.send_emotion(request_id, emotion)
  1471. await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
  1472. chunk_index = 0
  1473. async for audio_chunk in ai_service.text_to_speech_stream(
  1474. first_line, emotion, user_info=user_info
  1475. ):
  1476. audio_base64 = base64.b64encode(audio_chunk).decode("utf-8")
  1477. await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
  1478. chunk_index += 1
  1479. await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
  1480. except Exception as e:
  1481. logger.warning(f"[{self.user_id}] play_topic_first_line failed: topic_key={topic_key}, e={e}")
  1482. async def send_user_level_update(self, request_id: str, level: int, total_duration_ms: int):
  1483. """发送用户等级更新消息"""
  1484. message = UserLevelMessage(
  1485. type="user_level",
  1486. requestId=request_id,
  1487. timestamp=self._get_timestamp(),
  1488. level=level,
  1489. totalDurationMs=total_duration_ms,
  1490. )
  1491. await self.websocket.send_text(message.model_dump_json())
  1492. async def send_text_result(
  1493. self, request_id: str, text: str, is_final: bool, source: Optional[Literal["user", "assistant"]] = None
  1494. ):
  1495. """发送文本结果。source=user 表示用户STT(不参与字幕),source=assistant 表示AI回复(参与字幕拼接)。"""
  1496. message = TextResultMessage(
  1497. type="text_result",
  1498. requestId=request_id,
  1499. timestamp=self._get_timestamp(),
  1500. text=text,
  1501. isFinal=is_final,
  1502. source=source,
  1503. )
  1504. await self.websocket.send_text(message.model_dump_json())
  1505. async def send_energy_stone_spawn(self, payload: Dict[str, Any]):
  1506. """发送能量石生成消息"""
  1507. message = EnergyStoneSpawnMessage(
  1508. type="energy_stone_spawn",
  1509. requestId=payload.get("stone_id"),
  1510. timestamp=self._get_timestamp(),
  1511. stoneId=payload.get("stone_id"),
  1512. stoneType=payload.get("stone_type"),
  1513. ttlSeconds=payload.get("ttl_seconds", settings.ENERGY_STONE_TTL_SECONDS),
  1514. appearAt=payload.get("appear_at", self._get_timestamp()),
  1515. metadata=payload.get("metadata"),
  1516. )
  1517. await self.websocket.send_text(message.model_dump_json())
  1518. async def _restore_active_stones(self):
  1519. """恢复未过期的能量石(页面切换后重新显示)"""
  1520. if not self.energy_stone_service or not self.user_id:
  1521. return
  1522. try:
  1523. active_stones = await self.energy_stone_service.get_active_stones(self.user_id)
  1524. if active_stones:
  1525. logger.info(
  1526. f"[{self.user_id}] 🔄 恢复 {len(active_stones)} 个未过期的能量石"
  1527. )
  1528. for stone_payload in active_stones:
  1529. await self.send_energy_stone_spawn(stone_payload)
  1530. except Exception as exc:
  1531. logger.warning(
  1532. f"[{self.user_id}] ⚠️ 恢复能量石失败: {exc}"
  1533. )
  1534. async def send_emotion(self, request_id: str, emotion: str):
  1535. """发送情感标签"""
  1536. message = EmotionMessage(
  1537. type="emotion",
  1538. requestId=request_id,
  1539. timestamp=self._get_timestamp(),
  1540. emotion=emotion,
  1541. )
  1542. await self.websocket.send_text(message.model_dump_json())
  1543. async def send_audio_response(
  1544. self, request_id: str, data: str, chunk_index: int, is_last: bool,
  1545. format: str = "pcm", sample_rate: int = 24000
  1546. ):
  1547. """发送音频响应"""
  1548. message = AudioResponseMessage(
  1549. type="audio_response",
  1550. requestId=request_id,
  1551. timestamp=self._get_timestamp(),
  1552. data=data,
  1553. sampleRate=sample_rate,
  1554. format=format,
  1555. chunkIndex=chunk_index,
  1556. isLast=is_last,
  1557. )
  1558. await self.websocket.send_text(message.model_dump_json())
  1559. async def send_error(self, request_id: str, code: str, error_message: str):
  1560. """发送错误消息"""
  1561. message = ErrorMessage(
  1562. type="error",
  1563. requestId=request_id,
  1564. timestamp=self._get_timestamp(),
  1565. code=code,
  1566. message=error_message,
  1567. )
  1568. await self.websocket.send_text(message.model_dump_json())
  1569. async def send_pong(self, request_id: str):
  1570. """发送Pong"""
  1571. message = PongMessage(
  1572. type="pong",
  1573. requestId=request_id,
  1574. timestamp=self._get_timestamp(),
  1575. )
  1576. await self.websocket.send_text(message.model_dump_json())
  1577. def _get_timestamp(self) -> int:
  1578. """获取当前时间戳(毫秒)"""
  1579. return int(time.time() * 1000)
  1580. def _build_llm_input(self, user_input: str, context: dict) -> str:
  1581. """
  1582. 构建包含上下文的LLM输入
  1583. Args:
  1584. user_input: 用户当前输入
  1585. context: 上下文信息(包含summary和recent_messages)
  1586. Returns:
  1587. 完整的LLM输入文本
  1588. """
  1589. parts = []
  1590. # 1. 最新总结(如果有,已包含所有历史信息)
  1591. summary = context.get('summary')
  1592. if summary:
  1593. parts.append("【历史对话总结】")
  1594. parts.append(summary)
  1595. parts.append("")
  1596. # 2. 最近对话(如果有)
  1597. recent_messages = context.get('recent_messages', [])
  1598. if recent_messages:
  1599. parts.append("【最近对话】")
  1600. for msg in recent_messages:
  1601. role_label = "User" if msg['role'] == 'user' else "Assistant"
  1602. parts.append(f"{role_label}: {msg['content']}")
  1603. parts.append("")
  1604. # 3. 当前用户输入
  1605. parts.append(f"User: {user_input}")
  1606. return "\n".join(parts)
  1607. async def _ensure_redis_context(self, request_id: str):
  1608. """
  1609. 确保Redis中有最新上下文(预加载)
  1610. 策略:
  1611. 1. 检查Redis是否有缓存
  1612. 2. 如果没有,从MySQL加载并更新Redis
  1613. 3. 如果有,直接使用(零等待)
  1614. """
  1615. try:
  1616. start = time.time()
  1617. # 检查Redis缓存
  1618. current_round = await self.redis_cache.get_current_round(self.user_id)
  1619. cached_context = await self.redis_cache.get_context(self.user_id)
  1620. if cached_context:
  1621. # ✅ Redis已有缓存,无需操作
  1622. return
  1623. # ❌ Redis缓存未命中,从MySQL加载
  1624. # 确保服务已初始化
  1625. await self._init_services()
  1626. if not self.context_service:
  1627. return
  1628. # 从MySQL获取上下文
  1629. context = await self.context_service.get_context_for_round(
  1630. user_id=self.user_id,
  1631. current_round=current_round
  1632. )
  1633. # 更新Redis缓存
  1634. await self.redis_cache.set_context(self.user_id, context)
  1635. # 同时缓存消息列表
  1636. for msg in context.get('recent_messages', []):
  1637. await self.redis_cache.append_message(
  1638. user_id=self.user_id,
  1639. role=msg['role'],
  1640. content=msg['content'],
  1641. round_number=msg.get('round_number', 0)
  1642. )
  1643. # 同步轮次
  1644. if current_round > 0:
  1645. await self.redis_cache.set_current_round(self.user_id, current_round)
  1646. except asyncio.CancelledError:
  1647. # 🔥 任务被取消时,确保清理资源
  1648. logger.debug(f"Redis上下文预加载任务被取消: request_id={request_id}")
  1649. raise # 重新抛出 CancelledError
  1650. except Exception as e:
  1651. import traceback
  1652. traceback.print_exc()
  1653. async def _get_context_from_redis(self, request_id: str) -> Optional[dict]:
  1654. """
  1655. 从Redis获取上下文(零延迟)
  1656. Returns:
  1657. 上下文字典 或 None(需降级到MySQL)
  1658. """
  1659. try:
  1660. if not self.redis_cache:
  1661. return None
  1662. start = time.time()
  1663. # 获取当前轮次
  1664. current_round = await self.redis_cache.get_current_round(self.user_id)
  1665. # 从Redis构建上下文(需要传入db_manager以获取所有总结)
  1666. db_manager = None
  1667. if self.context_service:
  1668. db_manager = self.context_service.db
  1669. context = await self.redis_cache.build_context_from_cache(
  1670. user_id=self.user_id,
  1671. current_round=current_round,
  1672. db_manager=db_manager
  1673. )
  1674. return context
  1675. except Exception as e:
  1676. return None
  1677. async def _persist_conversation_async(
  1678. self,
  1679. request_id: str,
  1680. user_text: str,
  1681. ai_text: str,
  1682. round_number: int,
  1683. emotion: str
  1684. ):
  1685. """
  1686. 异步持久化对话到MySQL(不阻塞主流程)
  1687. 策略:加入队列,批量写入
  1688. """
  1689. self.pending_persistence.append({
  1690. 'request_id': request_id,
  1691. 'user_id': self.user_id,
  1692. 'user_text': user_text,
  1693. 'ai_text': ai_text,
  1694. 'round_number': round_number,
  1695. 'emotion': emotion,
  1696. 'timestamp': time.time()
  1697. })
  1698. # 启动持久化任务
  1699. if not self.persistence_task or self.persistence_task.done():
  1700. self.persistence_task = asyncio.create_task(
  1701. self._batch_persistence_loop()
  1702. )
  1703. async def _batch_persistence_loop(self):
  1704. """批量持久化循环(后台任务)"""
  1705. while True:
  1706. try:
  1707. await asyncio.sleep(self.persistence_interval)
  1708. if not self.pending_persistence:
  1709. continue
  1710. # 批量提取
  1711. batch = self.pending_persistence.copy()
  1712. self.pending_persistence.clear()
  1713. start = time.time()
  1714. # 确保服务已初始化
  1715. await self._init_services()
  1716. if not self.message_service:
  1717. # 重新加入队列
  1718. self.pending_persistence.extend(batch)
  1719. continue
  1720. # 批量写入MySQL
  1721. # ⚠️ 注意:用户消息和AI消息都已在主流程中立即保存
  1722. # 这里不再重复保存,避免数据库中出现重复记录
  1723. # 如果立即保存失败,应该通过错误日志和监控告警处理,而不是在这里重试
  1724. for item in batch:
  1725. # 记录日志,但不保存(避免重复)
  1726. logger.debug(
  1727. f"跳过批量持久化(已立即保存): "
  1728. f"user={item['user_id']}, round={item['round_number']}"
  1729. )
  1730. except asyncio.CancelledError:
  1731. # 🔥 任务被取消时,确保清理资源
  1732. logger.info("批量持久化任务被取消,正在清理...")
  1733. raise # 重新抛出 CancelledError
  1734. except Exception as e:
  1735. import traceback
  1736. traceback.print_exc()
  1737. async def _generate_summary_async(
  1738. self,
  1739. request_id: str,
  1740. user_id: str,
  1741. start_round: int,
  1742. end_round: int
  1743. ):
  1744. """
  1745. 异步生成对话总结(完全不阻塞)
  1746. 触发时机:
  1747. - 每15轮触发一次(15, 30, 45...)
  1748. """
  1749. self.pending_summaries.append({
  1750. 'request_id': request_id,
  1751. 'user_id': user_id,
  1752. 'start_round': start_round,
  1753. 'end_round': end_round,
  1754. 'timestamp': time.time()
  1755. })
  1756. # 启动总结生成任务
  1757. if not self.summary_task or self.summary_task.done():
  1758. self.summary_task = asyncio.create_task(
  1759. self._summary_generation_loop()
  1760. )
  1761. async def _summary_generation_loop(self):
  1762. """总结生成循环(后台任务)"""
  1763. while True:
  1764. try:
  1765. # 等待任务或间隔
  1766. await asyncio.sleep(1.0)
  1767. if not self.pending_summaries:
  1768. continue
  1769. # 逐个处理总结任务
  1770. task = self.pending_summaries.pop(0)
  1771. start = time.time()
  1772. try:
  1773. # 确保服务已初始化
  1774. await self._init_services()
  1775. if not self.context_service or not self.redis_cache:
  1776. continue
  1777. # 调用总结服务(耗时2-3秒)
  1778. # 注意:这里假设有summary_service,如果没有需要创建
  1779. from services.summary_service import SummaryService
  1780. summary_service = SummaryService(self.context_service.db)
  1781. start_round = task['start_round']
  1782. end_round = task['end_round']
  1783. # generate_summary 会自动从数据库获取消息并保存总结
  1784. # 该方法会自动获取之前的总结并进行累积总结
  1785. await summary_service.generate_summary(
  1786. user_id=task['user_id'],
  1787. start_round=start_round,
  1788. end_round=end_round
  1789. )
  1790. # 从数据库获取生成的总结文本(使用 ORM)
  1791. from database import db_manager
  1792. from models_db import ConversationSummary
  1793. from sqlalchemy import select
  1794. # 🔥 async with 会自动管理 session 生命周期,包括 CancelledError 情况
  1795. async with db_manager.get_session() as session:
  1796. result = await session.execute(
  1797. select(ConversationSummary)
  1798. .where(
  1799. ConversationSummary.user_id == task['user_id'],
  1800. ConversationSummary.start_round == start_round,
  1801. ConversationSummary.end_round == end_round
  1802. )
  1803. .order_by(ConversationSummary.created_at.desc())
  1804. .limit(1)
  1805. )
  1806. summary_obj = result.scalar_one_or_none()
  1807. summary = summary_obj.summary_text if summary_obj else ""
  1808. # 🔥 保存到Redis(立即生效)
  1809. await self.redis_cache.set_summary(
  1810. user_id=task['user_id'],
  1811. end_round=end_round,
  1812. summary=summary
  1813. )
  1814. # 🔥 更新Redis上下文缓存(包含新总结)
  1815. await self.redis_cache.invalidate_context(task['user_id'])
  1816. except Exception as e:
  1817. import traceback
  1818. traceback.print_exc()
  1819. except asyncio.CancelledError:
  1820. # 🔥 任务被取消时,确保清理资源
  1821. logger.info("总结生成任务被取消,正在清理...")
  1822. raise # 重新抛出 CancelledError
  1823. except Exception as e:
  1824. import traceback
  1825. traceback.print_exc()