| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077 |
- """
- WebSocket处理器 - 新版本(STT+LLM+TTS流水线,支持流式LLM)
- """
- import json
- import base64
- import asyncio
- import time
- import re
- import logging
- import traceback
- from typing import Any, Dict, Literal, Optional
- from fastapi import WebSocket
- from starlette.websockets import WebSocketDisconnect, WebSocketState
- logger = logging.getLogger(__name__)
- from emotion_text_utils import (
- clean_text_for_display,
- strip_emotion_from_text,
- )
- # 表情符号正则表达式(用于TTS前清理)
- EMOJI_PATTERN = re.compile(
- "["
- "\U0001F600-\U0001F64F" # emoticons
- "\U0001F300-\U0001F5FF" # symbols & pictographs
- "\U0001F680-\U0001F6FF" # transport & map symbols
- "\U0001F1E0-\U0001F1FF" # flags (iOS)
- "\U00002702-\U000027B0"
- "\U000024C2-\U0001F251"
- "]+", flags=re.UNICODE
- )
- from models import (
- AudioChunkMessage,
- AudioEndMessage,
- CancelMessage,
- TextResultMessage,
- EmotionMessage,
- AudioResponseMessage,
- ErrorMessage,
- PingMessage,
- PongMessage,
- EnergyStoneEventMessage,
- EnergyStoneSpawnMessage,
- UserLevelMessage,
- )
- from ai_service import ai_service
- from request_logger import request_logger, PerformanceMetrics
- from config import settings
- from ab_experiment_media_config import resolve_llm_model, resolve_tts_params
- from stt_service import stt_service, STTSession
- from services.user_service import user_service
- from services.energy_stone_service import energy_stone_service
- from services.scenario_topic_service import scenario_topic_service
- from services.scenario_gift_service import grant_scene_gift_if_needed
- from services.llm_task_service import llm_task_service
- from scenario_config import (
- SCENE_DEFAULT,
- SCENE_WINDOW,
- SCENE_READING,
- SCENE_EATING,
- SCENE_WEIGHTS,
- SCENARIO_TOPICS,
- get_special_first_line,
- build_topics_payload,
- is_special_ab_experiment_user,
- )
- import random
- import uuid
- from sqlalchemy import text
- class WebSocketHandler:
- """WebSocket连接处理器"""
-
- def __init__(
- self,
- websocket: WebSocket,
- user_id: str = None,
- token: str = None,
- redis_cache = None,
- session_store = None,
- ):
- self.websocket = websocket
- self.user_id = user_id or "guest_user" # 用户ID(默认游客)
- self.token = token # 会话 token(单活校验)
- self.cancelled_requests: set = set() # 已取消的请求ID
- self.stt_sessions: Dict[str, STTSession] = {} # requestId -> STT会话对象
- self.event_loop: Optional[asyncio.AbstractEventLoop] = None # 保存事件循环引用
- self.request_start_times: Dict[str, float] = {} # 请求开始时间
- self.audio_end_times: Dict[str, float] = {} # requestId -> 录音结束时间
- self.ai_responses: Dict[str, list] = {} # requestId -> AI响应句子列表(用于日志记录)
- self.performance_metrics: Dict[str, PerformanceMetrics] = {} # requestId -> 性能指标
- self.energy_stone_service = None
-
- # 初始化服务(延迟加载,避免循环导入)
- self.message_service = None
- self.context_service = None
- self.redis_cache = redis_cache # 🔥 Redis缓存服务
- self.session_store = session_store # 会话存储(单活 token)
-
- # 🔥 预加载任务
- self.preload_tasks: Dict[str, asyncio.Task] = {}
-
- # 🔥 异步持久化队列
- self.pending_persistence = []
- self.persistence_task: Optional[asyncio.Task] = None
- self.persistence_interval = 5.0 # 每5秒批量持久化
-
- # 🔥 异步总结队列
- self.pending_summaries = []
- self.summary_task: Optional[asyncio.Task] = None
-
- # 情景对话:首条语音携带的 scene/topic,在进入 LLM 前落库
- self._pending_scenario_log: Dict[str, Dict[str, str]] = {}
-
- async def _init_services(self):
- """初始化服务(延迟加载,避免重复初始化)"""
- start_time = time.time()
-
- # 🔥 如果服务已初始化,直接返回(避免重复初始化导致的阻塞)
- if self.message_service and self.context_service:
- logger.debug(f"[{self.user_id}] 🔍 服务已初始化,跳过重复初始化")
- return
-
- try:
- logger.info(f"[{self.user_id}] 🔧 开始初始化服务...")
- from database import db_manager
- from services import MessageService, ContextService
-
- # 使用延迟初始化的全局实例
- from services.message_service import message_service
- from services.context_service import context_service
-
- # 🔥 只在服务未初始化时才设置(避免重复调用set_db_manager)
- if not message_service.db or not context_service.db:
- logger.debug(f"[{self.user_id}] 🔧 设置数据库管理器...")
- await message_service.set_db_manager(db_manager)
- await context_service.set_db_manager(db_manager)
- if not energy_stone_service.db:
- logger.debug(f"[{self.user_id}] 🔧 设置能量石服务数据库管理器...")
- await energy_stone_service.set_db_manager(db_manager)
- if not scenario_topic_service.db:
- await scenario_topic_service.set_db_manager(db_manager)
-
- self.message_service = message_service
- self.context_service = context_service
- self.energy_stone_service = energy_stone_service
-
- elapsed = (time.time() - start_time) * 1000
- logger.info(f"[{self.user_id}] ✅ 服务初始化完成,耗时: {elapsed:.1f}ms")
- except Exception as e:
- elapsed = (time.time() - start_time) * 1000
- logger.error(f"[{self.user_id}] ❌ 服务初始化失败,耗时: {elapsed:.1f}ms, 错误: {e}")
- logger.error(traceback.format_exc())
- async def _close_with_session_invalid(self, reason: str = "session_invalidated"):
- """发送会话失效错误并关闭连接"""
- try:
- await self.send_error(
- request_id="session",
- code="SESSION_INVALIDATED",
- error_message=reason,
- )
- except Exception:
- pass
- try:
- await self.websocket.close(code=4401, reason=reason)
- except Exception:
- pass
- async def _ensure_valid_session(self) -> bool:
- """校验会话 token,确保单用户单活"""
- if not self.session_store:
- return True
- if not self.user_id or not self.token:
- await self._close_with_session_invalid("missing_session_token")
- return False
- if not await self.session_store.validate(self.user_id, self.token, refresh=True):
- await self._close_with_session_invalid("session_invalidated")
- return False
- await self.session_store.bind_connection(self.user_id, self.token, self.websocket)
- return True
-
- async def handle_connection(self):
- """处理WebSocket连接"""
- await self.websocket.accept()
-
- # 初始化服务
- await self._init_services()
-
- # 校验会话 token
- if not await self._ensure_valid_session():
- return
-
- # 🔥 恢复未过期的能量石(页面切换后重新显示)
- await self._restore_active_stones()
-
- # 保存事件循环引用,供回调线程使用
- self.event_loop = asyncio.get_running_loop()
-
- try:
- while True:
- if not self._is_ws_connected():
- break
- try:
- data = await self.websocket.receive_text()
- except RuntimeError as exc:
- # 同用户重连时 bind_connection 会主动关闭旧连接,Starlette 抛 RuntimeError 而非 WebSocketDisconnect
- msg = str(exc).lower()
- if "websocket is not connected" in msg or "need to call \"accept\" first" in msg:
- logger.info(
- f"[{self.user_id}] ℹ️ WebSocket 已关闭(可能被新连接替换),停止接收消息"
- )
- break
- raise
- message_data = json.loads(data)
- await self.handle_message(message_data)
- except WebSocketDisconnect as e:
- close_code = getattr(e, "code", None)
- reason = getattr(e, "reason", None)
- logger.warning(f"[{self.user_id}] ⚠️ WebSocket断开: code={close_code}, reason={reason}")
- except Exception as e:
- logger.error(f"[{self.user_id}] ❌ WebSocket异常: {e.__class__.__name__}: {e}")
- logger.error(traceback.format_exc())
- finally:
- await self._cleanup_on_disconnect()
- close_code = getattr(self.websocket, "close_code", None)
- logger.info(f"[{self.user_id}] ℹ️ WebSocket连接结束,close_code={close_code}")
- def _is_ws_connected(self) -> bool:
- try:
- return self.websocket.client_state == WebSocketState.CONNECTED
- except Exception:
- return False
- async def _cleanup_on_disconnect(self) -> None:
- """连接关闭时释放 STT 会话等资源。"""
- if self.session_store:
- try:
- await self.session_store.unbind_connection(self.user_id, self.websocket)
- except Exception:
- pass
- for request_id in list(self.stt_sessions.keys()):
- try:
- await self.cleanup_request(request_id)
- except Exception as exc:
- logger.warning(
- f"[{self.user_id}] ⚠️ 断开连接时清理 request={request_id} 失败: {exc}"
- )
- for task in self.preload_tasks.values():
- if not task.done():
- task.cancel()
- self.preload_tasks.clear()
-
- async def handle_message(self, message_data: dict):
- """处理接收到的消息"""
- message_type = message_data.get("type")
- request_id = message_data.get("requestId")
- incoming_token = message_data.get("token")
- if incoming_token:
- self.token = incoming_token
- if not await self._ensure_valid_session():
- return
-
- # 检查是否为已取消的请求
- if request_id in self.cancelled_requests:
- return
-
- if message_type == "audio_chunk":
- await self.handle_audio_chunk(AudioChunkMessage(**message_data))
-
- elif message_type == "audio_end":
- await self.handle_audio_end(AudioEndMessage(**message_data))
-
- elif message_type == "cancel":
- await self.handle_cancel(CancelMessage(**message_data))
-
- elif message_type == "ping":
- await self.handle_ping(PingMessage(**message_data))
-
- elif message_type == "energy_stone_event":
- await self.handle_energy_stone_event(EnergyStoneEventMessage(**message_data))
-
- elif message_type == "start_greeting":
- # 由前端显式触发首句 greeting;支持 data.forceScene 测试用
- data = message_data.get("data") or {}
- force_scene = data.get("forceScene") if isinstance(data, dict) else None
- await self._send_greeting_message(force_scene=force_scene)
-
- elif message_type == "play_topic_first_line":
- # 情景对话:用户切换话题后请求播放该话题首句 TTS
- data = message_data.get("data") or {}
- if isinstance(data, dict) and data.get("topicKey"):
- await self._play_topic_first_line(
- request_id=message_data.get("requestId") or f"topic_{uuid.uuid4().hex[:8]}",
- topic_key=data.get("topicKey"),
- )
-
- async def handle_audio_chunk(self, message: AudioChunkMessage):
- """处理音频数据块 - 使用新的STT服务"""
- request_id = message.requestId
-
- # 检查是否取消
- if request_id in self.cancelled_requests:
- return
-
- # Base64解码音频数据
- audio_data = base64.b64decode(message.data)
- chunk_size = len(audio_data)
-
- # 第一个chunk:初始化请求并启动STT会话
- if request_id not in self.request_start_times:
- current_time = time.time()
- self.request_start_times[request_id] = current_time
-
- # 情景对话:首条语音携带的 scene/topic,进入 LLM 前落库
- if getattr(message, "scene", None) and getattr(message, "topic", None):
- self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
-
- # 初始化性能指标
- self.performance_metrics[request_id] = PerformanceMetrics()
- self.performance_metrics[request_id].user_speech_start = current_time
- self.performance_metrics[request_id].stt_first_audio_received = current_time
-
- # 创建STT会话
- session = await self._create_stt_session(request_id)
- if not session or not session.is_connected:
- await self.send_error(request_id, "STT_ERROR", "语音识别服务启动失败")
- return
-
- self.stt_sessions[request_id] = session
-
- # 🔥 启动Redis预加载任务(与录音并行)
- if self.redis_cache:
- self.preload_tasks[request_id] = asyncio.create_task(
- self._ensure_redis_context(request_id)
- )
-
- # 发送音频帧到STT服务
- session = self.stt_sessions.get(request_id)
- if session:
- success = await session.send_audio(audio_data)
- if not success:
- await self.send_error(request_id, "STT_ERROR", "发送音频数据失败")
-
- # 📝 更新STT音频统计(用于日志)
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].stt_audio_chunks += 1
- self.performance_metrics[request_id].stt_total_bytes += chunk_size
-
- async def handle_audio_end(self, message: AudioEndMessage):
- """处理音频结束 - 使用新的STT服务"""
- request_id = message.requestId
-
- # 情景对话:若首 chunk 未带 scene/topic,可在此补带(仅首条语音)
- if getattr(message, "scene", None) and getattr(message, "topic", None) and request_id not in self._pending_scenario_log:
- self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
-
- # 记录录音结束时间
- audio_end_time = time.time()
- self.audio_end_times[request_id] = audio_end_time
-
- # 更新性能指标
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].user_speech_end = audio_end_time
- self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
-
- speech_duration_ms = message.speechDuration or 0
- # 说话时长写库改异步,不阻塞 STT → LLM → TTS 主链路(全用户生效)
- if speech_duration_ms > 0:
- asyncio.create_task(
- self._update_speech_duration_async(request_id, speech_duration_ms)
- )
- # 初始化AI响应收集器
- self.ai_responses[request_id] = []
- # 检查是否取消
- if request_id in self.cancelled_requests:
- self.cancelled_requests.remove(request_id)
- await self.cleanup_request(request_id)
- return
-
- try:
- # 1. STT: 等待完整识别结果
- stt_start = time.time()
-
- session = self.stt_sessions.get(request_id)
- if not session:
- await self.send_error(request_id, "STT_ERROR", "语音识别会话不存在")
- request_logger.log_error(
- user_id=self.user_id,
- request_id=request_id,
- error_code="STT_ERROR",
- error_message="语音识别会话不存在",
- stage="stt"
- )
- return
-
- # 🔥 手动提交音频,触发识别(禁用VAD时必需)
- try:
- commit_success = await session.commit_audio()
- if not commit_success:
- logger.error(
- f"[STT异常] request_id={request_id}, "
- f"错误类型=提交识别请求失败, "
- f"用户ID={self.user_id}, "
- f"音频块数={message.totalChunks}, "
- f"会话状态=is_connected={session.is_connected}"
- )
- await self.send_error(request_id, "STT_ERROR", "提交识别请求失败")
- request_logger.log_error(
- user_id=self.user_id,
- request_id=request_id,
- error_code="STT_ERROR",
- error_message="提交识别请求失败",
- stage="stt"
- )
- await self.cleanup_request(request_id)
- return
- except Exception as commit_error:
- logger.error(
- f"[STT异常] request_id={request_id}, "
- f"错误类型=提交音频异常, "
- f"异常类型={type(commit_error).__name__}, "
- f"错误消息={str(commit_error)}, "
- f"用户ID={self.user_id}, "
- f"音频块数={message.totalChunks}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- await self.send_error(request_id, "STT_ERROR", f"提交音频异常: {str(commit_error)}")
- request_logger.log_error(
- user_id=self.user_id,
- request_id=request_id,
- error_code="STT_ERROR",
- error_message=f"提交音频异常: {str(commit_error)}",
- stage="stt"
- )
- await self.cleanup_request(request_id)
- return
-
- # 等待最终结果(根据音频长度动态调整超时)
- total_chunks = message.totalChunks
- if total_chunks > 80:
- timeout = 5.0
- elif total_chunks > 50:
- timeout = 4.0
- else:
- timeout = 3.0
-
- logger.info(f"[{request_id}] ⏳ 等待STT最终识别结果(超时: {timeout}s)...")
- text = await session.wait_for_final_result(timeout=timeout)
-
- # 获取识别结果
- if not text:
- # 检查语音检测状态,提供更详细的错误信息
- callback = session.callback
- speech_detected = callback.speech_detected if callback else False
- speech_start_count = callback.speech_start_count if callback else 0
-
- if not speech_detected and speech_start_count == 0:
- error_msg = "未检测到有效语音,请检查麦克风权限或重新录音"
- else:
- error_msg = f"语音识别超时,已检测到语音但未返回识别结果(speech_detected={speech_detected}, speech_start_count={speech_start_count})"
-
- logger.error(
- f"[{request_id}] ❌ STT识别失败: {error_msg}, "
- f"音频块数={message.totalChunks}, "
- f"录音时长={message.speechDuration}ms"
- )
-
- await self.send_error(request_id, "STT_ERROR", error_msg)
- request_logger.log_error(
- user_id=self.user_id,
- request_id=request_id,
- error_code="STT_ERROR",
- error_message=error_msg,
- stage="stt"
- )
- await self.cleanup_request(request_id)
- return
-
- stt_duration = time.time() - stt_start
- stt_complete_time = time.time()
-
- # 记录STT完成时间(在获取到text之后)
- stt_complete_elapsed = (stt_complete_time - self.request_start_times.get(request_id, stt_complete_time)) * 1000
- logger.info(f"[{request_id}] ✅ STT识别完成,识别文本: '{text[:50]}{'...' if len(text) > 50 else ''}',耗时: {stt_complete_elapsed:.1f}ms")
-
- # 更新性能指标
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].stt_completed = stt_complete_time
- self.performance_metrics[request_id].user_text = text
- # 获取STT首个文本生成时间
- first_text_time = session.get_first_text_time()
- if first_text_time:
- self.performance_metrics[request_id].stt_first_text_generated = first_text_time
-
- # 发送最终识别结果(source=user,前端不用于字幕)
- await self.send_text_result(request_id, text, True, source="user")
-
- if request_id in self.cancelled_requests:
- return
-
- # 🔥 LLM提示词准备开始
- llm_prompt_start = time.time()
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_prompt_prepare_start = llm_prompt_start
-
- # 🔥 从Redis获取上下文(零延迟)
- context = None
- current_round = 0
- next_round = 0
-
- if self.redis_cache:
- # 尝试从Redis获取上下文(使用超时,避免长时间阻塞)
- redis_start = time.time()
- try:
- logger.info(f"[{request_id}] 🔍 开始从Redis获取上下文(超时: 2.0s)...")
- context = await asyncio.wait_for(
- self._get_context_from_redis(request_id),
- timeout=2.0 # 2秒超时
- )
- redis_elapsed = (time.time() - redis_start) * 1000
- logger.info(f"[{request_id}] ✅ Redis上下文获取完成,耗时: {redis_elapsed:.1f}ms")
- except asyncio.TimeoutError:
- redis_elapsed = (time.time() - redis_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ 从Redis获取上下文超时(耗时: {redis_elapsed:.1f}ms),降级到MySQL")
- context = None
-
- if context:
- current_round = context.get('current_round', 0)
- next_round = current_round + 1
- logger.debug(f"[{request_id}] 📊 当前轮次: {current_round},下一轮次: {next_round}")
-
- # 🔥 立即写入Redis(用户消息,使用超时)
- write_start = time.time()
- try:
- logger.info(f"[{request_id}] 💾 开始写入Redis(用户消息,超时: 1.0s)...")
- await asyncio.wait_for(
- self.redis_cache.append_message(
- user_id=self.user_id,
- role='user',
- content=text,
- round_number=next_round
- ),
- timeout=1.0 # 1秒超时
- )
- append_elapsed = (time.time() - write_start) * 1000
- logger.debug(f"[{request_id}] ✅ Redis写入用户消息完成,耗时: {append_elapsed:.1f}ms")
-
- increment_start = time.time()
- await asyncio.wait_for(
- self.redis_cache.increment_round(self.user_id),
- timeout=1.0 # 1秒超时
- )
- increment_elapsed = (time.time() - increment_start) * 1000
- write_total = (time.time() - write_start) * 1000
- logger.info(f"[{request_id}] ✅ Redis写入完成(增量轮次耗时: {increment_elapsed:.1f}ms,总耗时: {write_total:.1f}ms)")
- except asyncio.TimeoutError:
- write_elapsed = (time.time() - write_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ Redis写入超时(耗时: {write_elapsed:.1f}ms),但继续处理")
- except Exception as e:
- write_elapsed = (time.time() - write_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ Redis写入失败(耗时: {write_elapsed:.1f}ms): {e}")
-
- # 🔥 异步保存user消息到MySQL(不阻塞主流程)
- await self._init_services()
- if self.message_service:
- asyncio.create_task(
- self.message_service.save_message(
- user_id=self.user_id,
- role='user',
- content=text
- )
- )
-
- else:
- # Redis未命中,降级到MySQL
- mysql_start = time.time()
- logger.info(f"[{request_id}] ⚠️ Redis未命中,降级到MySQL获取上下文...")
- await self._init_services()
-
- if self.message_service and self.context_service:
- try:
- # 保存用户消息
- save_start = time.time()
- logger.info(f"[{request_id}] 💾 开始保存用户消息到MySQL(超时: 5.0s)...")
- user_message = await asyncio.wait_for(
- self.message_service.save_message(
- user_id=self.user_id,
- role='user',
- content=text
- ),
- timeout=5.0
- )
- save_elapsed = (time.time() - save_start) * 1000
- next_round = user_message['round_number']
- current_round = next_round - 1
- logger.info(f"[{request_id}] ✅ 用户消息保存成功,轮次: {next_round},耗时: {save_elapsed:.1f}ms")
-
- # 📝 记录用户消息ID
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
-
- # 获取上下文
- context_start = time.time()
- logger.info(f"[{request_id}] 🔍 开始从MySQL获取上下文(超时: 3.0s)...")
- context = await asyncio.wait_for(
- self.context_service.get_context_for_round(
- user_id=self.user_id,
- current_round=current_round
- ),
- timeout=3.0
- )
- context_elapsed = (time.time() - context_start) * 1000
- mysql_total_elapsed = (time.time() - mysql_start) * 1000
- if context:
- messages_count = len(context.get('messages', []))
- logger.info(f"[{request_id}] ✅ 从MySQL获取上下文成功,消息数: {messages_count},获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
- else:
- logger.warning(f"[{request_id}] ⚠️ 从MySQL获取上下文为空,获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
-
- except asyncio.TimeoutError as e:
- elapsed = (time.time() - mysql_start) * 1000
- logger.error(f"[{request_id}] ❌ MySQL操作超时,耗时: {elapsed:.1f}ms, 错误: {e}")
- except Exception as e:
- elapsed = (time.time() - mysql_start) * 1000
- logger.error(f"[{request_id}] ❌ MySQL操作失败,耗时: {elapsed:.1f}ms, 错误: {e}")
- logger.error(traceback.format_exc())
- else:
- # 没有Redis,直接使用MySQL
- await self._init_services()
-
- if self.message_service and self.context_service:
- try:
- user_message = await asyncio.wait_for(
- self.message_service.save_message(
- user_id=self.user_id,
- role='user',
- content=text
- ),
- timeout=5.0
- )
- next_round = user_message['round_number']
- current_round = next_round - 1
-
- # 📝 记录用户消息ID
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
-
- context = await asyncio.wait_for(
- self.context_service.get_context_for_round(
- user_id=self.user_id,
- current_round=current_round
- ),
- timeout=3.0
- )
-
- except asyncio.TimeoutError:
- pass
- except Exception as e:
- pass
-
- # 🔥 LLM提示词准备结束
- llm_prompt_end = time.time()
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_prompt_prepare_end = llm_prompt_end
-
- llm_start = time.time()
- # 🔥 LLM调用开始
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_call_start = llm_start
-
- chunk_index = 0
- first_audio_time = None
- final_emotion = "neutral"
- sentence_count = 0
- llm_first_sentence_time = None
- llm_first_text_time = None # 🔥 LLM首个文本输出时间
- emotion_sent = False # 标记是否已发送情感
-
- # 情景对话:首条语音若携带 scene/topic,在此落库(仅记录一次)
- pending = self._pending_scenario_log.pop(request_id, None)
- if pending:
- await scenario_topic_service.record_conversation(
- self.user_id, pending["scene"], pending["topic"]
- )
-
- # 构建Prompt(如果有上下文,使用上下文;否则使用原始文本)
- prompt_build_start = time.time()
-
- # 获取用户信息(用于运行时上下文)
- user_info = await user_service.get_user(self.user_id)
- is_new_user = False
- if user_info:
- # 判断是否为新用户(total_rounds == 0)
- is_new_user = (user_info.get("total_rounds", 0) == 0)
- ab_task = None
- if is_special_ab_experiment_user(user_info):
- ab_task = await llm_task_service.get_latest_task_cached()
- if not ab_task:
- logger.warning(f"[{self.user_id}] ⚠️ A/B 用户未找到任务表 prompt,将回退默认 base prompt")
-
- ab_task_prompt = ab_task.get("task_prompt") if ab_task else None
- ab_task_name = ab_task.get("task_name") if ab_task else None
- # 获取system_prompt和runtime_context用于日志记录
- system_prompt = ai_service._build_system_prompt(
- user_info,
- is_new_user,
- ab_task_prompt=ab_task_prompt,
- )
- runtime_context = ai_service._build_runtime_context(user_info, ab_task_name=ab_task_name)
-
- # 构建对话历史(用于上下文)- v0.3: 近20轮对话
- chat_history = []
- if context and context.get("recent_messages"):
- for msg in context.get("recent_messages", []):
- role = "user" if msg.get("role") == "user" else "assistant"
- chat_history.append({"role": role, "content": msg.get("content", "")})
-
- # 📝 构建完整的 messages 数组(用于保存 LLM 提示词记录和日志)
- llm_messages = [
- {"role": "system", "content": system_prompt},
- {"role": "system", "content": runtime_context},
- ]
- if chat_history:
- llm_messages.extend(chat_history)
- llm_messages.append({"role": "user", "content": text}) # ✅ v0.3: 使用原始用户输入
-
- # 构建完整提示词用于日志(文本格式)
- full_prompt_parts = [
- f"System (Base): {system_prompt}",
- f"System (Runtime Context): {runtime_context}",
- ]
- if chat_history:
- full_prompt_parts.append(f"Chat History ({len(chat_history)} messages)")
- full_prompt_parts.append(f"User: {text}")
- full_prompt = "\n\n".join(full_prompt_parts)
-
- prompt_build_elapsed = (time.time() - prompt_build_start) * 1000
-
- # 📝 更新LLM输入统计(用于日志)
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_prompt_length = len(text)
- self.performance_metrics[request_id].llm_context_round = next_round
- self.performance_metrics[request_id].llm_full_prompt = full_prompt
-
- logger.info(f"[{request_id}] 🤖 开始调用LLM,用户输入长度: {len(text)},构建耗时: {prompt_build_elapsed:.1f}ms,轮次: {next_round}")
-
- # 🔥 流式处理:LLM生成一句 → 立即TTS
- # ✅ v0.3: 传入原始用户输入,generate_response_stream 内部会构建完整的 messages 数组
- try:
- llm_first_chunk_time = None
-
- async for sentence, emotion in ai_service.generate_response_stream(
- text, # ✅ v0.3: 原始用户输入,generate_response_stream 内部会构建完整的 messages 数组
- user_info=user_info,
- is_new_user=is_new_user,
- chat_history=chat_history if chat_history else None,
- ab_task=ab_task,
- ):
- if llm_first_chunk_time is None:
- llm_first_chunk_time = time.time()
- llm_first_chunk_elapsed = (llm_first_chunk_time - llm_start) * 1000
- logger.info(f"[{request_id}] ✅ LLM首个响应到达,耗时: {llm_first_chunk_elapsed:.1f}ms")
- if request_id in self.cancelled_requests:
- return
-
- # 🎯 优先处理情感:第一时间发送给前端
- if not emotion_sent and emotion:
- logger.info(f"[{request_id}] 🎭 发送情感标签给前端: {emotion}, 句子内容: {sentence[:100] if sentence else '(空句子)'}")
- await self.send_emotion(request_id, emotion)
- emotion_sent = True
- final_emotion = emotion
-
- # ⚡ 如果是空句子(仅传递情感),跳过 TTS 处理
- if not sentence:
- continue
-
- sentence_count += 1
-
- # 字幕展示文本:剥离 [EMOTION:xxx] 及 LLM 偶发的裸 emotion 词
- display_text = clean_text_for_display(sentence)
-
- # 🧹 TTS 文本清理(括号/emoji 仅影响语音,不影响字幕)
- clean_sentence = strip_emotion_from_text(sentence)
- clean_sentence = clean_sentence.translate(str.maketrans('', '', '()[]')).strip()
- clean_sentence = EMOJI_PATTERN.sub('', clean_sentence)
-
- # 如果清理后为空,跳过TTS(emotion 已通过 send_emotion 下发,无需空字幕)
- if not clean_sentence:
- continue
-
- # 发送文本给前端(source=assistant 用于字幕拼接)
- if display_text:
- await self.send_text_result(
- request_id, display_text, is_final=False, source="assistant"
- )
-
- # 收集AI响应(用于日志记录)- 使用清理后的文本
- self.ai_responses[request_id].append(clean_sentence)
-
- # 记录第一个句子生成时间
- if llm_first_sentence_time is None:
- llm_first_sentence_time = time.time()
-
- # 🔥 记录LLM首个文本输出时间
- if llm_first_text_time is None:
- llm_first_text_time = time.time()
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_first_text_output = llm_first_text_time
-
- tts_sentence_start = time.time()
- # 🔥 记录TTS调用开始时间(第一个句子)
- if request_id in self.performance_metrics and self.performance_metrics[request_id].tts_call_start is None:
- self.performance_metrics[request_id].tts_call_start = tts_sentence_start
-
- # 立即TTS合成这个句子(使用清理后的文本 + 情感参数)
- try:
- async for audio_chunk in ai_service.text_to_speech_stream(
- clean_sentence, emotion, user_info=user_info
- ):
- if request_id in self.cancelled_requests:
- return
-
- # Base64编码
- audio_base64 = base64.b64encode(audio_chunk).decode()
-
- # 发送音频数据
- await self.send_audio_response(
- request_id,
- audio_base64,
- chunk_index,
- False,
- )
-
- # 记录第一个音频块发送时间
- if chunk_index == 0:
- first_audio_time = time.time()
- time_to_first_audio = first_audio_time - audio_end_time
- # 🔥 记录TTS首个音频输出时间
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].tts_first_audio_output = first_audio_time
-
- # 📝 更新TTS音频统计(用于日志)
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].tts_audio_chunks += 1
- self.performance_metrics[request_id].tts_total_bytes += len(audio_chunk)
-
- chunk_index += 1
- except Exception as tts_error:
- # TTS异常已在ai_service中详细记录,这里只记录上下文信息
- logger.error(
- f"[WebSocket异常] request_id={request_id}, "
- f"错误类型=TTS合成异常, "
- f"异常类型={type(tts_error).__name__}, "
- f"错误消息={str(tts_error)}, "
- f"句子内容={clean_sentence[:100]}... (前100字符), "
- f"情感={emotion}, "
- f"句子序号={sentence_count}, "
- f"已发送chunk数={chunk_index}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- # 重新抛出异常,让外层处理
- raise
-
- # 更新当前情感(但不再发送,因为已在开头发送)
- if emotion:
- final_emotion = emotion
- except Exception as llm_error:
- # LLM异常已在ai_service中详细记录,这里只记录上下文信息
- logger.error(
- f"[WebSocket异常] request_id={request_id}, "
- f"错误类型=LLM生成异常, "
- f"异常类型={type(llm_error).__name__}, "
- f"错误消息={str(llm_error)}, "
- f"用户输入={text[:100]}... (前100字符), "
- f"LLM输入={llm_input[:100]}... (前100字符), "
- f"上下文轮次={next_round}, "
- f"已生成句子数={sentence_count}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- # 重新抛出异常,让外层处理
- raise
-
- # 发送 TTS 结束标记:整轮 AI 回复(可能多句话/多段 TTS)全部发完后只发一次 isLast=True,前端据此判定「对话结束」并在全部播完后才切 idle
- await self.send_audio_response(request_id, "", chunk_index, True)
-
- # 记录最后一个音频块发送时间
- last_audio_time = time.time()
- time_to_last_audio = last_audio_time - audio_end_time
-
- # 🔥 更新性能指标:LLM和TTS完成时间
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].llm_all_text_completed = last_audio_time
- self.performance_metrics[request_id].llm_call_end = last_audio_time
- self.performance_metrics[request_id].tts_all_audio_completed = last_audio_time
- self.performance_metrics[request_id].tts_call_end = last_audio_time
- # 📝 更新LLM输出统计
- self.performance_metrics[request_id].llm_emotion = final_emotion
- self.performance_metrics[request_id].llm_sentences = sentence_count
- # 📝 更新TTS输入统计
- self.performance_metrics[request_id].tts_voice = resolve_tts_params(user_info)["voice"]
-
- # 🔥 保存AI响应
- ai_response_text = ' '.join(self.ai_responses.get(request_id, []))
- llm_total_elapsed = (time.time() - llm_start) * 1000
- logger.info(f"[{request_id}] ✅ LLM调用完成,总耗时: {llm_total_elapsed:.1f}ms,句子数: {sentence_count},情感: {final_emotion}")
-
- if ai_response_text:
- # 🔥 立即写入Redis(不阻塞)
- if self.redis_cache:
- redis_save_start = time.time()
- try:
- logger.debug(f"[{request_id}] 💾 开始保存AI响应到Redis...")
- await self.redis_cache.append_message(
- user_id=self.user_id,
- role='assistant',
- content=ai_response_text,
- round_number=next_round
- )
- redis_save_elapsed = (time.time() - redis_save_start) * 1000
- logger.debug(f"[{request_id}] ✅ AI响应保存到Redis完成,耗时: {redis_save_elapsed:.1f}ms")
- except Exception as e:
- redis_save_elapsed = (time.time() - redis_save_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ AI响应保存到Redis失败,耗时: {redis_save_elapsed:.1f}ms, 错误: {e}")
-
- # 🔥 立即保存AI消息到MySQL(获取message_id)
- if self.message_service:
- mysql_save_start = time.time()
- try:
- logger.info(f"[{request_id}] 💾 开始保存AI消息到MySQL(超时: 5.0s)...")
- assistant_message = await asyncio.wait_for(
- self.message_service.save_message(
- user_id=self.user_id,
- role='assistant',
- content=ai_response_text,
- emotion=final_emotion
- ),
- timeout=5.0
- )
- mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
- logger.info(f"[{request_id}] ✅ AI消息保存到MySQL成功,耗时: {mysql_save_elapsed:.1f}ms")
- # 📝 记录AI消息ID
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].assistant_message_id = assistant_message.get('message_id')
-
- # 🔥 保存LLM提示词记录(异步,不阻塞主流程)
- db_message_id = assistant_message.get('id')
- if db_message_id and self.message_service:
- try:
- # 获取LLM调用参数
- llm_call_metadata = {}
- if request_id in self.performance_metrics:
- metrics = self.performance_metrics[request_id]
- llm_call_metadata = {
- "llm_call_start": getattr(metrics, 'llm_call_start', None),
- "llm_first_chunk_time": getattr(metrics, 'llm_first_chunk_time', None),
- "llm_first_text_output": getattr(metrics, 'llm_first_text_output', None),
- "llm_prompt_length": getattr(metrics, 'llm_prompt_length', None),
- }
-
- # 异步保存LLM提示词记录(不阻塞主流程)
- asyncio.create_task(
- self.message_service.save_llm_prompt(
- message_id=db_message_id,
- user_id=self.user_id,
- round_number=next_round,
- system_prompt=system_prompt,
- runtime_context=runtime_context,
- messages=llm_messages,
- model_name=resolve_llm_model(user_info),
- temperature="0.7",
- max_tokens=500,
- stream=True,
- metadata=llm_call_metadata
- )
- )
- logger.debug(f"[{request_id}] 📝 LLM提示词记录保存任务已创建: message_id={db_message_id}")
- except Exception as e:
- logger.warning(f"[{request_id}] ⚠️ 创建LLM提示词记录保存任务失败: {e}")
- except asyncio.TimeoutError:
- mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL超时,耗时: {mysql_save_elapsed:.1f}ms")
- except Exception as e:
- mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
- logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL失败,耗时: {mysql_save_elapsed:.1f}ms, 错误: {e}")
- # 保存失败不影响主流程,继续异步持久化
-
- # 🔥 加入异步持久化队列(批量写入MySQL,用于重试或降级场景)
- asyncio.create_task(
- self._persist_conversation_async(
- request_id=request_id,
- user_text=text,
- ai_text=ai_response_text,
- round_number=next_round,
- emotion=final_emotion
- )
- )
-
- # 首次对话“水晶石”赠送已关闭:不再生成首句奖励(MissionEnergyStone)
- # 也不再更新 `users.crystal_stone_total`。
- # 注意:总结触发逻辑已移至 message_service.py
- # 在保存 assistant 消息时,如果 current_round % 15 == 0,会自动触发总结
-
- # 计算总耗时
- total_end = time.time()
- total_duration = total_end - self.request_start_times.get(request_id, stt_start)
- llm_tts_total = total_end - llm_start
-
- # 📝 记录详细性能日志到文件
- try:
- ai_response_log = ' '.join(self.ai_responses.get(request_id, []))
-
- # 更新最终的AI响应文本
- if request_id in self.performance_metrics:
- self.performance_metrics[request_id].ai_response_text = ai_response_log
-
- # 记录详细性能数据
- request_logger.log_request(
- user_id=self.user_id,
- request_id=request_id,
- metrics=self.performance_metrics[request_id]
- )
- except Exception as log_error:
- pass
-
- except Exception as e:
- # 详细记录异常信息
- session = self.stt_sessions.get(request_id)
- user_text_log = session.get_current_text() if session else ""
-
- # 判断异常来源(STT/LLM/TTS)
- error_type = "UNKNOWN_ERROR"
- error_stage = "unknown"
- error_str = str(e).lower()
-
- if "stt" in error_str or "语音识别" in error_str or "transcription" in error_str:
- error_type = "STT_ERROR"
- error_stage = "stt"
- elif "llm" in error_str or "chat" in error_str or "completion" in error_str or "generate" in error_str:
- error_type = "LLM_ERROR"
- error_stage = "llm"
- elif "tts" in error_str or "speech" in error_str or "audio" in error_str:
- error_type = "TTS_ERROR"
- error_stage = "tts"
- else:
- error_type = "NETWORK_ERROR"
- error_stage = "unknown"
-
- logger.error(
- f"[WebSocket异常] request_id={request_id}, "
- f"user_id={self.user_id}, "
- f"错误类型={error_type}, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"阶段={error_stage}, "
- f"用户输入={user_text_log[:100]}... (前100字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
-
- # 记录错误日志
- try:
- request_logger.log_error(
- user_id=self.user_id,
- request_id=request_id,
- error_code=error_type,
- error_message=str(e),
- stage=error_stage,
- user_text=user_text_log
- )
- except Exception as log_error:
- logger.error(
- f"[日志记录异常] request_id={request_id}, "
- f"错误类型=记录错误日志失败, "
- f"异常类型={type(log_error).__name__}, "
- f"错误消息={str(log_error)}"
- )
-
- await self.send_error(request_id, error_type, str(e))
- finally:
- # 清理资源
- await self.cleanup_request(request_id)
-
- async def _create_stt_session(self, request_id: str) -> Optional[STTSession]:
- """创建STT会话并设置回调"""
- async def on_text_update(text: str, is_final: bool):
- """STT文本更新回调(source=user,前端不用于字幕)"""
- await self.send_text_result(request_id, text, is_final, source="user")
-
- async def on_error(error_msg: str):
- """STT错误回调"""
- await self.send_error(request_id, "STT_ERROR", error_msg)
-
- # 创建会话
- session = await stt_service.create_session(
- request_id=request_id,
- on_text_update=on_text_update,
- on_error=on_error,
- event_loop=self.event_loop
- )
-
- return session if session.is_connected else None
-
- async def cleanup_request(self, request_id: str):
- """清理请求相关资源"""
- # 清理STT会话
- if request_id in self.stt_sessions:
- session = self.stt_sessions[request_id]
- await session.close()
- del self.stt_sessions[request_id]
-
- # 清理取消标记
- if request_id in self.cancelled_requests:
- self.cancelled_requests.remove(request_id)
-
- # 清理时间记录
- if request_id in self.request_start_times:
- del self.request_start_times[request_id]
-
- if request_id in self.audio_end_times:
- del self.audio_end_times[request_id]
-
- # 清理AI响应收集器
- if request_id in self.ai_responses:
- del self.ai_responses[request_id]
-
- # 清理性能指标
- if request_id in self.performance_metrics:
- del self.performance_metrics[request_id]
-
- async def handle_cancel(self, message: CancelMessage):
- """处理取消请求"""
- request_id = message.requestId
-
- self.cancelled_requests.add(request_id)
-
- # 清理资源(包括关闭STT会话)
- await self.cleanup_request(request_id)
-
- async def handle_ping(self, message: PingMessage):
- """处理Ping"""
- await self.send_pong(message.requestId)
- async def handle_energy_stone_event(self, message: EnergyStoneEventMessage):
- """处理前端能量石事件"""
- if not self.energy_stone_service:
- return
- metadata = {
- "client_ts": message.clientTs,
- "position": message.position,
- "request_id": message.requestId,
- }
- try:
- await self.energy_stone_service.mark_event(
- stone_id=message.stoneId,
- event=message.event,
- metadata={k: v for k, v in metadata.items() if v is not None},
- )
- except Exception as exc:
- logger.warning(
- "[EnergyStone] 记录事件失败: stone_id=%s event=%s error=%s",
- message.stoneId,
- message.event,
- exc,
- )
-
- async def _maybe_spawn_mission_stone(self, request_id: str):
- """首次对话奖励"""
- if not self.energy_stone_service or not self.user_id:
- return
- try:
- payload = await self.energy_stone_service.create_mission_stone_if_needed(
- user_id=self.user_id,
- request_id=request_id,
- )
- if payload:
- await self.send_energy_stone_spawn(payload)
- except Exception as exc:
- logger.warning(
- "[EnergyStone] 任务能量石生成失败: user_id=%s error=%s",
- self.user_id,
- exc,
- )
- async def _update_speech_duration_async(
- self, request_id: str, speech_duration_ms: int
- ) -> None:
- """后台更新累积说话时长,并同步等级/能量石(不阻塞对话主链路)。"""
- duration_start = time.time()
- try:
- logger.info(
- f"[{request_id}] ⏱️ 异步更新用户说话时长: {speech_duration_ms}ms..."
- )
- level_update = await asyncio.wait_for(
- user_service.update_speech_duration(self.user_id, speech_duration_ms),
- timeout=5.0,
- )
- duration_elapsed = (time.time() - duration_start) * 1000
- logger.info(
- f"[{request_id}] ✅ 异步更新用户说话时长成功,耗时: {duration_elapsed:.1f}ms"
- )
- except asyncio.TimeoutError:
- duration_elapsed = (time.time() - duration_start) * 1000
- logger.warning(
- f"[{request_id}] ⚠️ 异步更新用户说话时长超时(耗时: {duration_elapsed:.1f}ms): "
- f"user_id={self.user_id}, duration_ms={speech_duration_ms}"
- )
- return
- except Exception as e:
- duration_elapsed = (time.time() - duration_start) * 1000
- logger.warning(
- f"[{request_id}] ⚠️ 异步更新用户说话时长失败(耗时: {duration_elapsed:.1f}ms): "
- f"user_id={self.user_id}, duration_ms={speech_duration_ms}, error={e}"
- )
- return
- if not level_update or not isinstance(level_update, dict):
- return
- total_duration_ms = level_update.get("total_duration_ms")
- user_level = level_update.get("user_level")
- if total_duration_ms is not None:
- await self._maybe_spawn_conversation_stones(
- total_duration_ms, speech_duration_ms, request_id
- )
- if user_level is not None:
- await self.send_user_level_update(
- request_id=request_id,
- level=user_level,
- total_duration_ms=total_duration_ms or 0,
- )
- elif total_duration_ms is not None:
- try:
- user_info = await user_service.get_user(self.user_id)
- if user_info and user_info.get("user_level") is not None:
- await self.send_user_level_update(
- request_id=request_id,
- level=user_info.get("user_level"),
- total_duration_ms=total_duration_ms,
- )
- except Exception as e:
- logger.warning(
- f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}"
- )
-
- async def _maybe_spawn_conversation_stones(
- self,
- total_duration_ms: int,
- increment_ms: int,
- request_id: str,
- ):
- """按累计说话时长发放"""
- if (
- not self.energy_stone_service
- or not self.user_id
- or total_duration_ms <= 0
- or increment_ms <= 0
- ):
- return
- try:
- stones = await self.energy_stone_service.create_conversation_stones_if_needed(
- user_id=self.user_id,
- request_id=request_id,
- total_duration_ms=total_duration_ms,
- duration_increment_ms=increment_ms,
- )
- for stone in stones:
- await self.send_energy_stone_spawn(stone)
- except Exception as exc:
- logger.warning(
- "[EnergyStone] 对话能量石生成失败: user_id=%s error=%s",
- self.user_id,
- exc,
- )
- async def _send_greeting_message(self, force_scene: Optional[str] = None):
- """
- 发送首句(AI先说话)
- - 若 force_scene 有效则强制该场景;否则按权重随机场景(特殊场景需排除已聊完的)。
- - default:新用户固定首句 / 回归用户动态登录问候。
- - 特殊场景:未完成话题等概率选一,发 greeting_context 后发固定首句+TTS。
- """
- request_id = f"greeting_{uuid.uuid4().hex[:8]}"
- try:
- user_info = await user_service.get_user(self.user_id)
- if not user_info:
- logger.warning(f"[{self.user_id}] ⚠️ 无法获取用户信息,跳过首句发送")
- return
-
- user_name = (user_info.get("username") or "").strip() or "Neo"
- is_new_user = (user_info.get("total_rounds", 0) == 0)
- ab_experiment_user = is_special_ab_experiment_user(user_info)
-
- # 1) 决定本次场景
- chosen_scene = SCENE_DEFAULT
- scene_anchor: Optional[Dict[str, Any]] = None
- valid_force = force_scene in (SCENE_DEFAULT, SCENE_WINDOW, SCENE_READING, SCENE_EATING)
- if ab_experiment_user:
- chosen_scene = SCENE_DEFAULT
- if valid_force and force_scene != SCENE_DEFAULT:
- logger.info(
- f"[{self.user_id}] A/B 实验用户忽略强制特殊场景 force_scene={force_scene},使用 default"
- )
- else:
- logger.info(f"[{self.user_id}] A/B 实验用户,跳过特殊情景场景随机")
- elif valid_force:
- if force_scene == SCENE_DEFAULT:
- chosen_scene = force_scene
- logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}")
- else:
- forced_anchor = await self._pick_scene_anchor_for_user(force_scene)
- if forced_anchor:
- chosen_scene = force_scene
- scene_anchor = forced_anchor
- logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}, anchor_layout_item_id={scene_anchor.get('anchorLayoutItemId')}")
- else:
- chosen_scene = SCENE_DEFAULT
- logger.info(f"[{self.user_id}] ℹ️ 强制场景 {force_scene} 无可用锚点,回退 default")
- else:
- await self._init_services()
- completed = await scenario_topic_service.get_completed_count_per_scenario(self.user_id)
- candidates = []
- for scene_key, weight in SCENE_WEIGHTS:
- if scene_key == SCENE_DEFAULT:
- candidates.append((scene_key, weight))
- else:
- if completed.get(scene_key, 0) < 3 and await self._scene_has_available_anchor(scene_key):
- candidates.append((scene_key, weight))
- if not candidates:
- candidates = [(SCENE_DEFAULT, 70)]
- total = sum(w for _, w in candidates)
- r = random.uniform(0, total)
- acc = 0
- for sk, w in candidates:
- acc += w
- if r < acc:
- chosen_scene = sk
- break
-
- # 2) 默认场景:沿用原逻辑
- if chosen_scene == SCENE_DEFAULT:
- lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
- if is_special_ab_experiment_user(user_info):
- ab_task = await llm_task_service.get_latest_task_cached()
- lumi_first_line = ab_task.get("lumi_first_line") if ab_task else None
- greeting_text = ai_service.ab_group_greeting_line(
- user_name,
- lumi_name,
- is_new_user,
- lumi_first_line=lumi_first_line,
- )
- logger.info(f"[{self.user_id}] 🎯 A/B 实验用户首句: {greeting_text}")
- elif is_new_user:
- greeting_text = ai_service.first_meeting_line(user_name, lumi_name)
- logger.info(f"[{self.user_id}] 🎯 新用户首句: {greeting_text}")
- else:
- await self._init_services()
- chat_history = []
- if self.context_service:
- try:
- current_round = await self.redis_cache.get_current_round(self.user_id) if self.redis_cache else 1
- context = await self.context_service.get_context_for_round(
- user_id=self.user_id, current_round=current_round
- )
- if context and context.get("recent_messages"):
- for msg in context.get("recent_messages", []):
- role = "user" if msg.get("role") == "user" else "assistant"
- chat_history.append({"role": role, "content": msg.get("content", "")})
- except Exception as e:
- logger.warning(f"[{self.user_id}] ⚠️ 获取对话历史失败: {e}")
- greeting_text = await ai_service.generate_login_greeting(
- user_info=user_info, chat_history=chat_history if chat_history else None
- )
- logger.info(f"[{self.user_id}] 🎯 回归用户登录问候: {greeting_text}")
-
- emotion = "friendly" if is_new_user else "neutral"
- clean_text = greeting_text
- if (
- not is_special_ab_experiment_user(user_info)
- and not is_new_user
- and "[EMOTION:" in greeting_text
- ):
- emotion_start = greeting_text.find("[EMOTION:")
- emotion_end = greeting_text.find("]", emotion_start)
- if emotion_end > emotion_start:
- emotion_str = greeting_text[emotion_start + 9:emotion_end].strip().lower()
- valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
- if emotion_str in valid_emotions:
- emotion = emotion_str
- clean_text = greeting_text[emotion_end + 1:].strip()
- clean_text = clean_text.translate(str.maketrans('', '', '()[]')).strip()
- clean_text = EMOJI_PATTERN.sub('', clean_text)
- if not clean_text or not clean_text.strip():
- clean_text = "Welcome back! How are you today?"
-
- await self.send_emotion(request_id, emotion)
- await self.send_text_result(request_id, clean_text, is_final=True, source="assistant")
- chunk_index = 0
- async for audio_chunk in ai_service.text_to_speech_stream(
- clean_text, emotion, user_info=user_info
- ):
- audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
- await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
- chunk_index += 1
- await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
- logger.info(f"[{self.user_id}] ✅ 首句发送完成: request_id={request_id}")
- return
-
- # 3) 特殊场景:选话题、发 greeting_context、再发首句+TTS
- if not scene_anchor:
- scene_anchor = await self._pick_scene_anchor_for_user(chosen_scene)
- if not scene_anchor:
- logger.warning(f"[{self.user_id}] ⚠️ 特殊场景 {chosen_scene} 无可用锚点,回退默认首句")
- await self._send_greeting_message(force_scene=SCENE_DEFAULT)
- return
- done_topics = await scenario_topic_service.get_done_topic_keys(self.user_id, chosen_scene)
- all_topics = SCENARIO_TOPICS.get(chosen_scene, [])
- available = [t for t in all_topics if t not in done_topics]
- if not available:
- logger.warning(f"[{self.user_id}] ⚠️ 特殊场景 {chosen_scene} 无可用话题,回退默认首句")
- await self._send_greeting_message(force_scene=SCENE_DEFAULT)
- return
-
- chosen_topic = random.choice(available)
- first_line = get_special_first_line(chosen_topic, user_name)
- topics_payload = build_topics_payload(available)
-
- await self._send_greeting_context(
- request_id=request_id,
- scene=chosen_scene,
- topic=chosen_topic,
- topics=topics_payload,
- first_line=first_line,
- scene_anchor=scene_anchor,
- )
- emotion = "friendly"
- await self.send_emotion(request_id, emotion)
- await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
- chunk_index = 0
- async for audio_chunk in ai_service.text_to_speech_stream(
- first_line, emotion, user_info=user_info
- ):
- audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
- await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
- chunk_index += 1
- await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
- await grant_scene_gift_if_needed(self.user_id, chosen_scene)
- logger.info(f"[{self.user_id}] ✅ 特殊场景首句发送完成: scene={chosen_scene}, topic={chosen_topic}, request_id={request_id}")
- except Exception as e:
- logger.error(f"[{self.user_id}] ❌ 发送首句失败: {e}")
- logger.error(traceback.format_exc())
-
- async def _send_greeting_context(
- self,
- request_id: str,
- scene: str,
- topic: str,
- topics: list,
- first_line: str,
- scene_anchor: Optional[Dict[str, Any]] = None,
- ):
- """发送 greeting_context 消息(仅特殊场景)"""
- payload = {
- "type": "greeting_context",
- "requestId": request_id,
- "timestamp": self._get_timestamp(),
- "scene": scene,
- "topic": topic,
- "topics": topics,
- "firstLine": first_line,
- }
- if scene_anchor:
- payload.update(scene_anchor)
- await self.websocket.send_text(json.dumps(payload))
- async def _scene_has_available_anchor(self, scene: str) -> bool:
- if scene == SCENE_DEFAULT:
- return True
- anchor = await self._pick_scene_anchor_for_user(scene)
- return anchor is not None
- async def _pick_scene_anchor_for_user(self, scene: str) -> Optional[Dict[str, Any]]:
- """为特殊场景在当前布局中随机选取一个锚点(优先新槽位模型)。"""
- anchor_type: Optional[str] = None
- if scene in (SCENE_READING, SCENE_EATING):
- anchor_type = "seat"
- elif scene == SCENE_WINDOW:
- anchor_type = "window"
- if not anchor_type:
- return None
- try:
- from database import db_manager
- async with db_manager.get_session() as session:
- layout_user_type_res = await session.execute(
- text(
- """
- SELECT DATA_TYPE
- FROM information_schema.columns
- WHERE table_schema = :db
- AND table_name = 'chatbot_layouts'
- AND column_name = 'user_id'
- """
- ),
- {"db": settings.MYSQL_DATABASE},
- )
- layout_user_type = (layout_user_type_res.mappings().first() or {}).get("DATA_TYPE") or ""
- layout_user_id_where_expr = (
- "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
- if "bigint" in str(layout_user_type).lower()
- else ":user_id"
- )
- # 1) 新模型:基于 user_chatbot_slot_placements + chatbot_backdrop_slots 选锚点
- backdrop_user_type_res = await session.execute(
- text(
- """
- SELECT DATA_TYPE
- FROM information_schema.columns
- WHERE table_schema = :db
- AND table_name = 'user_chatbot_backdrops'
- AND column_name = 'user_id'
- """
- ),
- {"db": settings.MYSQL_DATABASE},
- )
- backdrop_user_type = (backdrop_user_type_res.mappings().first() or {}).get("DATA_TYPE") or ""
- backdrop_user_id_where_expr = (
- "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
- if "bigint" in str(backdrop_user_type).lower()
- else ":user_id"
- )
- backdrop_row_res = await session.execute(
- text(
- f"""
- SELECT backdrop_item_id
- FROM user_chatbot_backdrops
- WHERE user_id = {backdrop_user_id_where_expr}
- ORDER BY updated_at DESC, id DESC
- LIMIT 1
- """
- ),
- {"user_id": self.user_id},
- )
- backdrop_row = backdrop_row_res.mappings().first()
- backdrop_item_id = (
- int(backdrop_row["backdrop_item_id"])
- if backdrop_row and backdrop_row.get("backdrop_item_id") is not None
- else None
- )
- candidates: list[dict[str, Any]] = []
- if backdrop_item_id is not None:
- slot_candidates_res = await session.execute(
- text(
- f"""
- SELECT
- usp.id,
- s.start_row,
- s.start_col,
- s.size_rows,
- s.size_cols,
- COALESCE(usp.z_index, s.z_index, si.default_z_index, 0) AS z_index
- FROM user_chatbot_slot_placements usp
- JOIN chatbot_backdrop_slots s
- ON s.backdrop_item_id = usp.backdrop_item_id
- AND s.slot_code = usp.slot_code
- AND s.is_active = 1
- LEFT JOIN shop_items si ON si.id = usp.item_id
- WHERE usp.user_id = {layout_user_id_where_expr}
- AND usp.backdrop_item_id = :backdrop_item_id
- AND s.anchor_type = :anchor_type
- ORDER BY usp.id ASC
- """
- ),
- {
- "user_id": self.user_id,
- "backdrop_item_id": backdrop_item_id,
- "anchor_type": anchor_type,
- },
- )
- candidates = [dict(r) for r in slot_candidates_res.mappings().all()]
- # 2) 兼容旧模型:新模型未命中时回退 layout_items + scene_anchor_type
- if not candidates:
- layout_row_res = await session.execute(
- text(
- f"""
- SELECT id
- FROM chatbot_layouts l
- WHERE l.user_id = {layout_user_id_where_expr}
- ORDER BY l.updated_at DESC, l.id DESC
- LIMIT 1
- """
- ),
- {"user_id": self.user_id},
- )
- layout_row = layout_row_res.mappings().first()
- if not layout_row or layout_row.get("id") is None:
- return None
- items_res = await session.execute(
- text(
- """
- SELECT
- li.id,
- li.start_row,
- li.start_col,
- li.size_rows,
- li.size_cols,
- li.z_index
- FROM chatbot_layout_items li
- JOIN shop_items si ON si.id = li.item_id
- WHERE li.layout_id = :layout_id
- AND si.scene_anchor_type = :anchor_type
- ORDER BY li.id ASC
- """
- ),
- {"layout_id": layout_row["id"], "anchor_type": anchor_type},
- )
- candidates = [dict(r) for r in items_res.mappings().all()]
- if not candidates:
- return None
- picked = random.choice(candidates)
- anchor_z = int(picked.get("z_index") or 0)
- return {
- "anchorLayoutItemId": int(picked["id"]),
- "anchorPlacement": "window_left_bottom" if scene == SCENE_WINDOW else "seat_center_bottom",
- "anchorZIndex": anchor_z,
- "specialAvatarZIndex": max(anchor_z + 1, 3),
- }
- except Exception as e:
- logger.warning(f"[{self.user_id}] 获取场景锚点失败: scene={scene}, error={e}")
- return None
-
- async def _play_topic_first_line(self, request_id: str, topic_key: str):
- """情景对话:播放某话题的规范首句 TTS(用户切换话题后调用)"""
- try:
- user_info = await user_service.get_user(self.user_id)
- user_name = (user_info.get("username") or "").strip() or "Neo" if user_info else "Neo"
- first_line = get_special_first_line(topic_key, user_name)
- emotion = "friendly"
- await self.send_emotion(request_id, emotion)
- await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
- chunk_index = 0
- async for audio_chunk in ai_service.text_to_speech_stream(
- first_line, emotion, user_info=user_info
- ):
- audio_base64 = base64.b64encode(audio_chunk).decode("utf-8")
- await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
- chunk_index += 1
- await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
- except Exception as e:
- logger.warning(f"[{self.user_id}] play_topic_first_line failed: topic_key={topic_key}, e={e}")
-
- async def send_user_level_update(self, request_id: str, level: int, total_duration_ms: int):
- """发送用户等级更新消息"""
- message = UserLevelMessage(
- type="user_level",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- level=level,
- totalDurationMs=total_duration_ms,
- )
- await self.websocket.send_text(message.model_dump_json())
-
- async def send_text_result(
- self, request_id: str, text: str, is_final: bool, source: Optional[Literal["user", "assistant"]] = None
- ):
- """发送文本结果。source=user 表示用户STT(不参与字幕),source=assistant 表示AI回复(参与字幕拼接)。"""
- message = TextResultMessage(
- type="text_result",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- text=text,
- isFinal=is_final,
- source=source,
- )
- await self.websocket.send_text(message.model_dump_json())
- async def send_energy_stone_spawn(self, payload: Dict[str, Any]):
- """发送能量石生成消息"""
- message = EnergyStoneSpawnMessage(
- type="energy_stone_spawn",
- requestId=payload.get("stone_id"),
- timestamp=self._get_timestamp(),
- stoneId=payload.get("stone_id"),
- stoneType=payload.get("stone_type"),
- ttlSeconds=payload.get("ttl_seconds", settings.ENERGY_STONE_TTL_SECONDS),
- appearAt=payload.get("appear_at", self._get_timestamp()),
- metadata=payload.get("metadata"),
- )
- await self.websocket.send_text(message.model_dump_json())
-
- async def _restore_active_stones(self):
- """恢复未过期的能量石(页面切换后重新显示)"""
- if not self.energy_stone_service or not self.user_id:
- return
-
- try:
- active_stones = await self.energy_stone_service.get_active_stones(self.user_id)
- if active_stones:
- logger.info(
- f"[{self.user_id}] 🔄 恢复 {len(active_stones)} 个未过期的能量石"
- )
- for stone_payload in active_stones:
- await self.send_energy_stone_spawn(stone_payload)
- except Exception as exc:
- logger.warning(
- f"[{self.user_id}] ⚠️ 恢复能量石失败: {exc}"
- )
-
- async def send_emotion(self, request_id: str, emotion: str):
- """发送情感标签"""
- message = EmotionMessage(
- type="emotion",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- emotion=emotion,
- )
- await self.websocket.send_text(message.model_dump_json())
-
- async def send_audio_response(
- self, request_id: str, data: str, chunk_index: int, is_last: bool,
- format: str = "pcm", sample_rate: int = 24000
- ):
- """发送音频响应"""
- message = AudioResponseMessage(
- type="audio_response",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- data=data,
- sampleRate=sample_rate,
- format=format,
- chunkIndex=chunk_index,
- isLast=is_last,
- )
- await self.websocket.send_text(message.model_dump_json())
-
- async def send_error(self, request_id: str, code: str, error_message: str):
- """发送错误消息"""
- message = ErrorMessage(
- type="error",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- code=code,
- message=error_message,
- )
- await self.websocket.send_text(message.model_dump_json())
-
- async def send_pong(self, request_id: str):
- """发送Pong"""
- message = PongMessage(
- type="pong",
- requestId=request_id,
- timestamp=self._get_timestamp(),
- )
- await self.websocket.send_text(message.model_dump_json())
-
- def _get_timestamp(self) -> int:
- """获取当前时间戳(毫秒)"""
- return int(time.time() * 1000)
-
- def _build_llm_input(self, user_input: str, context: dict) -> str:
- """
- 构建包含上下文的LLM输入
-
- Args:
- user_input: 用户当前输入
- context: 上下文信息(包含summary和recent_messages)
-
- Returns:
- 完整的LLM输入文本
- """
-
- parts = []
-
- # 1. 最新总结(如果有,已包含所有历史信息)
- summary = context.get('summary')
- if summary:
- parts.append("【历史对话总结】")
- parts.append(summary)
- parts.append("")
-
- # 2. 最近对话(如果有)
- recent_messages = context.get('recent_messages', [])
- if recent_messages:
- parts.append("【最近对话】")
- for msg in recent_messages:
- role_label = "User" if msg['role'] == 'user' else "Assistant"
- parts.append(f"{role_label}: {msg['content']}")
- parts.append("")
-
- # 3. 当前用户输入
- parts.append(f"User: {user_input}")
-
- return "\n".join(parts)
-
- async def _ensure_redis_context(self, request_id: str):
- """
- 确保Redis中有最新上下文(预加载)
-
- 策略:
- 1. 检查Redis是否有缓存
- 2. 如果没有,从MySQL加载并更新Redis
- 3. 如果有,直接使用(零等待)
- """
- try:
- start = time.time()
-
- # 检查Redis缓存
- current_round = await self.redis_cache.get_current_round(self.user_id)
- cached_context = await self.redis_cache.get_context(self.user_id)
-
- if cached_context:
- # ✅ Redis已有缓存,无需操作
- return
-
- # ❌ Redis缓存未命中,从MySQL加载
- # 确保服务已初始化
- await self._init_services()
-
- if not self.context_service:
- return
-
- # 从MySQL获取上下文
- context = await self.context_service.get_context_for_round(
- user_id=self.user_id,
- current_round=current_round
- )
-
- # 更新Redis缓存
- await self.redis_cache.set_context(self.user_id, context)
-
- # 同时缓存消息列表
- for msg in context.get('recent_messages', []):
- await self.redis_cache.append_message(
- user_id=self.user_id,
- role=msg['role'],
- content=msg['content'],
- round_number=msg.get('round_number', 0)
- )
-
- # 同步轮次
- if current_round > 0:
- await self.redis_cache.set_current_round(self.user_id, current_round)
-
- except asyncio.CancelledError:
- # 🔥 任务被取消时,确保清理资源
- logger.debug(f"Redis上下文预加载任务被取消: request_id={request_id}")
- raise # 重新抛出 CancelledError
- except Exception as e:
- import traceback
- traceback.print_exc()
-
- async def _get_context_from_redis(self, request_id: str) -> Optional[dict]:
- """
- 从Redis获取上下文(零延迟)
-
- Returns:
- 上下文字典 或 None(需降级到MySQL)
- """
- try:
- if not self.redis_cache:
- return None
-
- start = time.time()
-
- # 获取当前轮次
- current_round = await self.redis_cache.get_current_round(self.user_id)
-
- # 从Redis构建上下文(需要传入db_manager以获取所有总结)
- db_manager = None
- if self.context_service:
- db_manager = self.context_service.db
-
- context = await self.redis_cache.build_context_from_cache(
- user_id=self.user_id,
- current_round=current_round,
- db_manager=db_manager
- )
-
- return context
-
- except Exception as e:
- return None
-
- async def _persist_conversation_async(
- self,
- request_id: str,
- user_text: str,
- ai_text: str,
- round_number: int,
- emotion: str
- ):
- """
- 异步持久化对话到MySQL(不阻塞主流程)
-
- 策略:加入队列,批量写入
- """
- self.pending_persistence.append({
- 'request_id': request_id,
- 'user_id': self.user_id,
- 'user_text': user_text,
- 'ai_text': ai_text,
- 'round_number': round_number,
- 'emotion': emotion,
- 'timestamp': time.time()
- })
-
- # 启动持久化任务
- if not self.persistence_task or self.persistence_task.done():
- self.persistence_task = asyncio.create_task(
- self._batch_persistence_loop()
- )
-
- async def _batch_persistence_loop(self):
- """批量持久化循环(后台任务)"""
- while True:
- try:
- await asyncio.sleep(self.persistence_interval)
-
- if not self.pending_persistence:
- continue
-
- # 批量提取
- batch = self.pending_persistence.copy()
- self.pending_persistence.clear()
-
- start = time.time()
-
- # 确保服务已初始化
- await self._init_services()
-
- if not self.message_service:
- # 重新加入队列
- self.pending_persistence.extend(batch)
- continue
-
- # 批量写入MySQL
- # ⚠️ 注意:用户消息和AI消息都已在主流程中立即保存
- # 这里不再重复保存,避免数据库中出现重复记录
- # 如果立即保存失败,应该通过错误日志和监控告警处理,而不是在这里重试
- for item in batch:
- # 记录日志,但不保存(避免重复)
- logger.debug(
- f"跳过批量持久化(已立即保存): "
- f"user={item['user_id']}, round={item['round_number']}"
- )
-
- except asyncio.CancelledError:
- # 🔥 任务被取消时,确保清理资源
- logger.info("批量持久化任务被取消,正在清理...")
- raise # 重新抛出 CancelledError
- except Exception as e:
- import traceback
- traceback.print_exc()
-
- async def _generate_summary_async(
- self,
- request_id: str,
- user_id: str,
- start_round: int,
- end_round: int
- ):
- """
- 异步生成对话总结(完全不阻塞)
-
- 触发时机:
- - 每15轮触发一次(15, 30, 45...)
- """
- self.pending_summaries.append({
- 'request_id': request_id,
- 'user_id': user_id,
- 'start_round': start_round,
- 'end_round': end_round,
- 'timestamp': time.time()
- })
-
- # 启动总结生成任务
- if not self.summary_task or self.summary_task.done():
- self.summary_task = asyncio.create_task(
- self._summary_generation_loop()
- )
-
- async def _summary_generation_loop(self):
- """总结生成循环(后台任务)"""
- while True:
- try:
- # 等待任务或间隔
- await asyncio.sleep(1.0)
-
- if not self.pending_summaries:
- continue
-
- # 逐个处理总结任务
- task = self.pending_summaries.pop(0)
-
- start = time.time()
-
- try:
- # 确保服务已初始化
- await self._init_services()
-
- if not self.context_service or not self.redis_cache:
- continue
-
- # 调用总结服务(耗时2-3秒)
- # 注意:这里假设有summary_service,如果没有需要创建
- from services.summary_service import SummaryService
- summary_service = SummaryService(self.context_service.db)
-
- start_round = task['start_round']
- end_round = task['end_round']
-
- # generate_summary 会自动从数据库获取消息并保存总结
- # 该方法会自动获取之前的总结并进行累积总结
- await summary_service.generate_summary(
- user_id=task['user_id'],
- start_round=start_round,
- end_round=end_round
- )
-
- # 从数据库获取生成的总结文本(使用 ORM)
- from database import db_manager
- from models_db import ConversationSummary
- from sqlalchemy import select
-
- # 🔥 async with 会自动管理 session 生命周期,包括 CancelledError 情况
- async with db_manager.get_session() as session:
- result = await session.execute(
- select(ConversationSummary)
- .where(
- ConversationSummary.user_id == task['user_id'],
- ConversationSummary.start_round == start_round,
- ConversationSummary.end_round == end_round
- )
- .order_by(ConversationSummary.created_at.desc())
- .limit(1)
- )
- summary_obj = result.scalar_one_or_none()
- summary = summary_obj.summary_text if summary_obj else ""
-
- # 🔥 保存到Redis(立即生效)
- await self.redis_cache.set_summary(
- user_id=task['user_id'],
- end_round=end_round,
- summary=summary
- )
-
- # 🔥 更新Redis上下文缓存(包含新总结)
- await self.redis_cache.invalidate_context(task['user_id'])
-
- except Exception as e:
- import traceback
- traceback.print_exc()
-
- except asyncio.CancelledError:
- # 🔥 任务被取消时,确保清理资源
- logger.info("总结生成任务被取消,正在清理...")
- raise # 重新抛出 CancelledError
- except Exception as e:
- import traceback
- traceback.print_exc()
|