| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429 |
- """
- AI服务:集成STT、LLM、TTS模型
- """
- import base64
- import json
- import asyncio
- import tempfile
- import os
- import logging
- import traceback
- from typing import AsyncGenerator, Optional
- import dashscope
- from openai import OpenAI
- from config import (
- settings,
- get_dashscope_http_base_url,
- get_dashscope_domain,
- get_dashscope_tts_api_url,
- )
- from models import EmotionType
- from http_pool import http_pool # 🔥 使用连接池管理器
- logger = logging.getLogger(__name__)
- # 🔥 TTS API选择
- # 使用 SpeechSynthesizer (tts_v2) 支持 cosyvoice-v3-flash 模型
- # 使用 streaming_call 实现真正的流式传输
- from dashscope.audio.tts_v2 import SpeechSynthesizer, ResultCallback
- # 设置API Key和endpoint(根据国际版/国内版自动选择)
- dashscope.api_key = settings.active_dashscope_api_key
- dashscope.base_http_api_url = get_dashscope_tts_api_url()
- # 配置国际版endpoint(如果需要)
- if settings.use_dashscope_intl:
- dashscope_domain = get_dashscope_domain()
- dashscope.base_websocket_api_url = f"wss://{dashscope_domain}/api-ws/v1"
- class AIService:
- """AI服务管理器"""
-
- # 类级别的预热标记(避免多次实例化时重复预热)
- _tts_warmed_up = False
- _warmup_lock = None
- _tts_call_lock = None # TTS调用锁(防止SDK内部冲突)
- _last_tts_call_time = 0.0 # 上次TTS调用时间(用于速率控制)
-
- def __init__(self):
- # 🔥 使用连接池管理器(类似SQLAlchemy的连接池模式)
- # 所有AIService实例共享同一个HTTP连接池,避免重复创建连接
- http_client = http_pool.get_client()
-
- self.openai_client = OpenAI(
- api_key=settings.active_dashscope_api_key,
- base_url=get_dashscope_http_base_url(),
- http_client=http_client, # 使用连接池管理的 HTTP 客户端
- )
-
- # 🔥 连接健康检查标记
- self._last_llm_healthcheck: float = 0.0
- self._llm_healthcheck_interval = settings.LLM_HEALTHCHECK_INTERVAL # 从配置读取
- self._last_tts_healthcheck: float = 0.0
- self._tts_healthcheck_interval = settings.TTS_HEALTHCHECK_INTERVAL # 从配置读取
- self._healthcheck_tasks: set = set() # 后台健康检查任务集合(避免重复执行)
-
- # 🔥 TTS优化:预热TTS连接(后台执行,不阻塞启动)
- import threading
-
- # 初始化锁
- if AIService._warmup_lock is None:
- AIService._warmup_lock = threading.Lock()
- AIService._tts_call_lock = threading.Lock() # 初始化TTS调用锁
-
- # 只预热一次(避免多次实例化时重复)
- if not AIService._tts_warmed_up:
- threading.Thread(target=self._warmup_tts_sync, daemon=True).start()
-
- def _warmup_tts_sync(self):
- """同步方式预热TTS连接(在后台线程执行)"""
- import time
-
- # 使用锁确保只预热一次
- with AIService._warmup_lock:
- if AIService._tts_warmed_up:
- return # 已经预热过了
-
- time.sleep(2) # 等待2秒确保服务启动完成
-
- try:
- start_time = time.time()
-
- # 使用 SpeechSynthesizer 预热(同步调用即可)
- synthesizer = SpeechSynthesizer(
- model=settings.tts_model,
- voice=settings.tts_voice,
- speech_rate=settings.tts_speech_rate,
- pitch_rate=settings.tts_pitch_rate,
- language_hints=settings.tts_language_hints,
- )
-
- # 发送极短文本进行预热(使用同步调用)
- audio = synthesizer.call("Hi")
-
- AIService._tts_warmed_up = True # 标记为已预热
-
- except Exception as e:
- logger.error(
- f"[TTS异常] 错误类型=TTS预热失败, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.tts_model}, "
- f"音色={settings.tts_voice}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
-
- # STT现在使用流式识别,在websocket_handler中直接处理
-
- def first_meeting_line(self, user_name: str, lumi_name: str) -> str:
- """
- 新用户的首句(固定模板)- v0.3
- 参考 prompts-v0.3.py 的 first_meeting_line 函数
-
- Args:
- user_name: 用户名称
- lumi_name: Lumi名称
-
- Returns:
- 固定首句(纯文本,不含情感标签)
- """
- user_name = (user_name or "").strip() or "Neo"
- lumi_name = (lumi_name or "").strip() or "Lumi"
- # ✅ v0.3: 与 prompts-v0.3.py 保持一致,返回固定模板(不含情感标签)
- return (
- f"{user_name}… we made it. I'm {lumi_name}. I'm really glad you're here. How are you feeling right now?"
- )
- def _login_greeting_prompt(self, user_name: str, lumi_name: str) -> str:
- """
- 构建登录问候提示词 - v0.3
-
- Args:
- user_name: 用户名称
- lumi_name: Lumi名称
-
- Returns:
- 登录问候提示词
- """
- return f"""
- [LOGIN GREETING REQUEST]
- Generate ONLY the first greeting line for a returning user.
- Runtime names:
- - User: {user_name}
- - Lumi: {lumi_name}
- World cue (must include at least ONE of these exact phrases):
- - "Glimmer Forest"
- - "the Tree"
- - "the glow"
- Tone:
- - Warm, familiar, like seeing a friend again
- - Calm and grounded, not excited or dramatic
- Constraints:
- - 1–2 short sentences, voice-friendly.
- - Must include BOTH names exactly as given above.
- - Must include at least ONE world cue phrase above.
- - End with ONE simple question.
- - No emojis, emoticons, or decorative symbols.
- Output ONLY the greeting line.
- """.strip()
- async def generate_login_greeting(
- self,
- user_info: Optional[dict] = None,
- chat_history: Optional[list] = None
- ) -> str:
- """
- 生成回归用户的登录问候(动态生成)- v0.2.1
-
- Args:
- user_info: 用户信息字典
- chat_history: 对话历史(可选)
-
- Returns:
- 登录问候语(包含情感标签)
- """
- if not user_info:
- user_info = {}
-
- user_name = (user_info.get("username") or "").strip() or "Neo"
- lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
-
- # 构建 System Prompt
- system_prompt = self._build_system_prompt(user_info, is_new_user=False)
-
- # ✅ v0.3: 添加 Runtime Context(包含 memory_summary)
- runtime_context = self._build_runtime_context(user_info)
-
- # 构建消息列表
- messages = [
- {"role": "system", "content": system_prompt},
- {"role": "system", "content": runtime_context}, # ✅ 新增:包含历史记忆
- ]
-
- # 添加对话历史(如果有)
- if chat_history:
- messages.extend(chat_history)
-
- # 添加登录问候请求(v0.3 版本)
- # 注意:问候语不需要情感标签
- login_greeting_prompt = self._login_greeting_prompt(user_name, lumi_name)
- messages.append({"role": "user", "content": login_greeting_prompt})
-
- try:
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=messages,
- temperature=0.7,
- max_tokens=200,
- )
- greeting_text = response.choices[0].message.content.strip()
- # 注意:问候语不需要情感标签,直接返回
- return greeting_text
- except Exception as e:
- logger.error(f"❌ 生成登录问候失败: {e}")
- # 降级方案:使用固定模板(不包含情感标签)
- return f"Welcome back, {user_name}. I'm {lumi_name}. How are you feeling today?"
- async def generate_response_stream(
- self,
- user_text: str,
- user_info: Optional[dict] = None,
- is_new_user: bool = False,
- chat_history: Optional[list] = None
- ) -> AsyncGenerator[tuple[str, EmotionType], None]:
- """
- 生成对话回复(流式)
-
- Args:
- user_text: 用户输入文本
- user_info: 用户信息字典(包含 username, bot_name)
- is_new_user: 是否为新用户(首次对话)
- chat_history: 对话历史(可选)
-
- Yields:
- (sentence, emotion): 句子片段和情感
- """
- try:
- # 构建System Prompt
- system_prompt = self._build_system_prompt(user_info, is_new_user)
-
- # 构建运行时上下文
- runtime_context = self._build_runtime_context(user_info)
-
- # 调用LLM流式API
- import time
- llm_api_start = time.time()
-
- # 🔥 连接健康检查(后台异步执行,不阻塞主流程)
- self._trigger_llm_healthcheck_background()
-
- # 构建消息列表
- messages = [
- {"role": "system", "content": system_prompt},
- {"role": "system", "content": runtime_context}, # 注入运行时上下文
- ]
-
- # 添加对话历史(如果有)
- if chat_history:
- messages.extend(chat_history)
-
- # 🔥 格式提醒:确保每次对话都生成情感标签(双重保障)
- messages.append({
- "role": "system",
- "content": "Remember: Start your response with [EMOTION:xxx] format. This is REQUIRED."
- })
-
- # 添加当前用户输入
- messages.append({"role": "user", "content": user_text})
-
- try:
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=messages,
- temperature=0.7,
- max_tokens=500,
- stream=True, # 启用流式
- )
- except Exception as api_error:
- # 🔥 检测连接错误,自动重建连接
- error_str = str(api_error).lower()
- is_connection_error = (
- 'connection' in error_str or
- 'timeout' in error_str or
- 'network' in error_str or
- 'reset' in error_str or
- 'broken pipe' in error_str or
- 'connection refused' in error_str or
- 'connection aborted' in error_str
- )
-
- if is_connection_error:
- logger.warning(f"⚠️ LLM连接错误,自动重建连接: {api_error}")
- self._recreate_http_client()
- # 重建后重试一次
- try:
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=messages,
- temperature=0.7,
- max_tokens=500,
- stream=True,
- )
- logger.info("✅ LLM连接重建后重试成功")
- except Exception as retry_error:
- logger.error(
- f"[LLM异常] 错误类型=LLM API调用失败(重建后仍失败), "
- f"异常类型={type(retry_error).__name__}, "
- f"错误消息={str(retry_error)}, "
- f"模型={settings.llm_model}, "
- f"用户输入长度={len(user_text)}, "
- f"System Prompt长度={len(system_prompt)}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- raise
- else:
- logger.error(
- f"[LLM异常] 错误类型=LLM API调用失败, "
- f"异常类型={type(api_error).__name__}, "
- f"错误消息={str(api_error)}, "
- f"模型={settings.llm_model}, "
- f"用户输入长度={len(user_text)}, "
- f"System Prompt长度={len(system_prompt)}, "
- f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
- f"使用国际版={settings.use_dashscope_intl}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- raise
-
- buffer = ""
- full_response = ""
- sentence_count = 0
- detected_emotion: EmotionType = "neutral" # 存储检测到的情感
- emotion_yielded = False # 标记是否已发送情感
- chunk_count = 0 # 计数chunk
-
- for chunk in response:
- chunk_count += 1
- if chunk.choices[0].delta.content:
- content = chunk.choices[0].delta.content
- buffer += content
- full_response += content
-
- # 🎯 优先检测情感标记(如果还没检测到)
- if detected_emotion == "neutral" and "[EMOTION:" in full_response:
- emotion_start = full_response.find("[EMOTION:")
- emotion_end = full_response.find("]", emotion_start)
- if emotion_end > emotion_start:
- emotion_str = full_response[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:
- detected_emotion = emotion_str
- logger.info(f"[LLM情感检测] ✅ 检测到情感标签: {emotion_str}, 原始片段: {full_response[:200]}")
- else:
- # 🔥 未知情感词,默认映射为 neutral,但仍然移除标签
- logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral, 原始片段: {full_response[:200]}")
- detected_emotion = "neutral"
-
- # 🚀 立即 yield 空句子传递情感(减少延迟)
- if not emotion_yielded:
- yield ("", detected_emotion) # 空句子,仅传递情感
- emotion_yielded = True
-
- # ✅ v0.3: 保留情感标签在文本中(前端需要),只从buffer中提取句子
- # 检测句子边界(完整的句子)
- # 注意:buffer 中可能包含情感标签,但我们保留它
- if self._is_complete_sentence(buffer):
- sentence = buffer.strip()
- sentence_count += 1
- # 使用已检测到的情感,但保留情感标签在文本中
- yield (sentence, detected_emotion)
- buffer = ""
-
- # ✅ v0.3: 保留情感标签在文本中(前端需要)
- # 处理buffer中的剩余内容(已输出句子之后的部分)
- remaining = buffer.strip()
- if remaining:
- sentence_count += 1
- yield (remaining, detected_emotion)
- elif not remaining and sentence_count == 0:
- # 如果一个句子都没输出,输出完整内容(保留情感标签)
- if full_response.strip():
- yield (full_response.strip(), detected_emotion)
- else:
- # 🔥 兜底逻辑:LLM没有返回任何文本(只返回了情感标记)
- default_response = "I understand. Could you tell me more?"
- yield (default_response, detected_emotion)
-
- # 🔍 调试日志:记录LLM完整响应和情感检测结果
- logger.debug(
- f"[LLM完整响应] 总chunk数={chunk_count}, "
- f"检测到的情感={detected_emotion}, "
- f"是否包含[EMOTION:={'[EMOTION:' in full_response}, "
- f"完整响应长度={len(full_response)}, "
- f"响应前500字符={full_response[:500]}"
- )
-
- # ⚠️ 警告:如果没有检测到情感标签
- if detected_emotion == "neutral" and "[EMOTION:" not in full_response:
- logger.warning(
- f"[LLM情感检测] ⚠️ 未检测到情感标签,LLM可能没有生成。"
- f"完整响应长度={len(full_response)}, "
- f"响应前500字符={full_response[:500]}"
- )
-
- except Exception as e:
- logger.error(
- f"[LLM异常] 错误类型=LLM流式生成失败, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.llm_model}, "
- f"用户输入={user_text[:100]}... (前100字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- yield ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
-
- def _is_complete_sentence(self, text: str) -> bool:
- """检测是否完整句子"""
- text = text.strip()
-
- # 必须以句子结束符结尾
- if not text.endswith(('.', '!', '?', '。', '!', '?')):
- return False
-
- # 排除常见缩写(避免误判)
- words = text.split()
- if words:
- last_word = words[-1]
- # 常见英文缩写
- common_abbrev = ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'Sr.', 'Jr.', 'etc.', 'e.g.', 'i.e.', 'vs.']
- if last_word in common_abbrev:
- return False
-
- # 🔥 优化:减少最小字符限制从5到3,让短句更快发送
- # 例如"Hi!" "OK!" "Yes!" 这种短句也能快速返回
- if len(text) < 3:
- return False
-
- return True
-
- async def generate_response(
- self,
- user_text: str,
- user_info: Optional[dict] = None,
- is_new_user: bool = False,
- chat_history: Optional[list] = None
- ) -> tuple[str, EmotionType]:
- """
- 生成对话回复(非流式)
-
- Args:
- user_text: 用户输入文本
- user_info: 用户信息字典(包含 username, bot_name)
- is_new_user: 是否为新用户(首次对话)
- chat_history: 对话历史(可选)
-
- Returns:
- (response_text, emotion): 回复文本和情感
- """
- try:
- # 构建System Prompt
- system_prompt = self._build_system_prompt(user_info, is_new_user)
-
- # 构建运行时上下文
- runtime_context = self._build_runtime_context(user_info)
-
- # 🔥 连接健康检查(长时间未使用时)
- await self._ensure_llm_connection()
-
- # 构建消息列表
- messages = [
- {"role": "system", "content": system_prompt},
- {"role": "system", "content": runtime_context},
- ]
- if chat_history:
- messages.extend(chat_history)
- messages.append({"role": "user", "content": user_text})
-
- # 调用LLM
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=messages,
- temperature=0.7,
- max_tokens=500,
- )
-
- full_response = response.choices[0].message.content or ""
-
- # 提取情感标签
- emotion: EmotionType = "neutral"
- response_text = full_response
-
- # 查找情感标签
- if "[EMOTION:" in full_response:
- emotion_start = full_response.find("[EMOTION:")
- emotion_end = full_response.find("]", emotion_start)
- if emotion_end > emotion_start:
- emotion_str = full_response[emotion_start + 9:emotion_end].strip().lower()
- # 验证情感类型
- valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry", "gentle"]
- if emotion_str in valid_emotions:
- emotion = emotion_str
- else:
- # 🔥 未知情感词,默认映射为 neutral
- logger.warning(f"[LLM] 未知情感词: {emotion_str},已映射为 neutral")
- # 移除情感标签(不发送给TTS)
- response_text = full_response[:emotion_start].strip()
-
- # 如果没有文本内容,使用默认回复
- if not response_text:
- response_text = "I understand. Please tell me more!"
- emotion = "neutral"
-
- return (response_text, emotion)
-
- except Exception as e:
- logger.error(
- f"[LLM异常] 错误类型=LLM生成失败, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.llm_model}, "
- f"用户输入={user_text[:100]}... (前100字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- return ("I'm sorry, I'm having trouble understanding. Could you please repeat that?", "neutral")
-
- async def text_to_speech_stream(
- self, text: str, emotion: EmotionType = "neutral"
- ) -> AsyncGenerator[bytes, None]:
- """
- 文本转语音(流式 - 实时返回音频块),带重试机制和速率控制
-
- Args:
- text: 要合成的文本
- emotion: 情感类型
-
- Yields:
- audio_data: PCM 音频数据块(Base64 解码后的原始数据)
- """
- import time
-
- # 🔥 TTS连接健康检查(后台异步执行,不阻塞主流程)
- self._trigger_tts_healthcheck_background()
-
- # 🚦 速率控制:确保两次TTS调用之间有最小间隔
- min_interval = settings.TTS_MIN_INTERVAL
- time_since_last_call = time.time() - AIService._last_tts_call_time
-
- if time_since_last_call < min_interval:
- wait_time = min_interval - time_since_last_call
- await asyncio.sleep(wait_time)
-
- # 更新最后调用时间
- AIService._last_tts_call_time = time.time()
-
- max_retries = settings.TTS_MAX_RETRIES
- retry_delay = settings.TTS_RETRY_DELAY
- backoff = settings.TTS_RETRY_BACKOFF
-
- for attempt in range(max_retries + 1):
- try:
- async for audio_chunk in self._text_to_speech_stream_internal(text, emotion):
- yield audio_chunk
- # 成功则退出
- return
-
- except Exception as e:
- # 检查是否为速率限制错误(429、Throttling、rate limit)
- error_str = str(e).lower()
- is_rate_limit = (
- 'rate limit' in error_str or
- 'throttling' in error_str or
- '429' in error_str or
- 'ratequota' in error_str
- )
-
- # 🔥 检测连接错误,自动重建连接
- is_connection_error = (
- 'connection' in error_str or
- 'timeout' in error_str or
- 'network' in error_str or
- 'reset' in error_str or
- 'broken pipe' in error_str or
- 'connection refused' in error_str or
- 'connection aborted' in error_str
- )
-
- logger.error(
- f"[TTS异常] 错误类型=TTS流式生成失败, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"是否为速率限制={is_rate_limit}, "
- f"是否为连接错误={is_connection_error}, "
- f"重试次数={attempt}/{max_retries}, "
- f"模型={settings.tts_model}, "
- f"音色={self._get_voice_for_emotion(emotion)}, "
- f"文本长度={len(text)}, "
- f"文本内容={text[:100]}... (前100字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
-
- # 连接错误:立即重建连接并重试
- if is_connection_error and attempt < max_retries:
- logger.warning(f"⚠️ TTS连接错误,自动重建连接: {e}")
- self._recreate_http_client()
- await asyncio.sleep(0.5) # 短暂等待后重试
- continue
-
- # 速率限制错误:等待后重试
- if is_rate_limit and attempt < max_retries:
- wait_time = retry_delay * (backoff ** attempt)
- logger.warning(
- f"[TTS警告] 速率限制,等待{wait_time:.2f}秒后重试 "
- f"(第{attempt+1}/{max_retries}次重试)"
- )
- await asyncio.sleep(wait_time)
- continue
- else:
- # 非速率限制错误或已达最大重试次数
- raise
-
- async def _text_to_speech_stream_internal(
- self, text: str, emotion: EmotionType = "neutral"
- ) -> AsyncGenerator[bytes, None]:
- """
- TTS流式调用的内部实现(不含重试逻辑)
- 使用 SpeechSynthesizer.streaming_call() 实现真正的流式传输
-
- Args:
- text: 要合成的文本
- emotion: 情感类型
-
- Yields:
- audio_data: PCM 音频数据块(原始音频数据)
- """
- try:
- import time
- tts_start = time.time()
-
- # 根据情感选择音色和构建 instruction
- voice = self._get_voice_for_emotion(emotion)
- instruction = self._build_emotion_instruction(emotion)
-
- # 🔍 调试:打印实际使用的配置
- logger.info(
- f"🔍 TTS调用 - 模型={settings.tts_model}, "
- f"音色={voice}, "
- f"语速={settings.tts_speech_rate}, "
- f"音调={settings.tts_pitch_rate}, "
- f"语言={settings.tts_language_hints}, "
- f"情感={emotion}"
- )
-
- # 🔥 使用队列在线程和异步之间传递数据
- import queue
- import threading
- from pydub import AudioSegment
- import io
-
- audio_queue = queue.Queue()
- error_holder = [None] # 用列表包装以便在线程间共享
- completion_event = threading.Event() # 用于等待流式完成
-
- # MP3 音频缓冲区(用于累积 MP3 数据,然后转换为 PCM)
- mp3_buffer = io.BytesIO()
-
- class TTSStreamCallback(ResultCallback):
- """TTS流式回调处理器"""
-
- def __init__(self, queue: queue.Queue, mp3_buffer: io.BytesIO,
- error_holder: list, completion_event: threading.Event):
- self.queue = queue
- self.mp3_buffer = mp3_buffer
- self.error_holder = error_holder
- self.completion_event = completion_event
- self.first_chunk_time = None
- self.chunk_count = 0
- self.total_audio_size = 0
- self.mp3_min_size = 4096 # MP3最小解码大小(4KB),确保能解码一个完整帧
-
- def on_open(self):
- """连接打开"""
- logger.debug("TTS流式连接已建立")
- self.first_chunk_time = time.time()
-
- def _process_mp3_buffer(self):
- """处理累积的MP3数据,转换为PCM并发送"""
- try:
- buffer_size = self.mp3_buffer.tell()
- if buffer_size < self.mp3_min_size:
- return # 数据不足,继续累积
-
- # 尝试解码MP3
- self.mp3_buffer.seek(0)
- audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
-
- # 转换为PCM格式:24kHz, 16bit, Mono
- audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
- pcm_data = audio_segment.raw_data
-
- # 分块发送PCM数据(3200字节/块)
- chunk_size = 3200
- for i in range(0, len(pcm_data), chunk_size):
- chunk = pcm_data[i:i + chunk_size]
- self.chunk_count += 1
- self.queue.put(('data', chunk))
-
- # 清空缓冲区(已处理的数据)
- self.mp3_buffer.seek(0)
- self.mp3_buffer.truncate(0)
-
- except FileNotFoundError as e:
- # ffmpeg/ffprobe 未安装
- error_msg = str(e)
- if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
- logger.error(
- f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
- f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
- )
- self.error_holder[0] = Exception(
- "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
- )
- self.queue.put(('error', self.error_holder[0]))
- return
- raise
- except Exception as e:
- # MP3数据不完整或格式错误,继续累积
- # 这是正常的,因为流式传输中MP3数据是分片到达的
- # 但如果是其他错误,记录日志
- error_str = str(e).lower()
- if 'ffprobe' not in error_str and 'ffmpeg' not in error_str and 'avprobe' not in error_str:
- # 不是 ffmpeg 相关错误,可能是数据不完整,继续累积
- pass
- else:
- # ffmpeg 相关错误,记录并抛出
- logger.error(f"[TTS错误] MP3解码失败: {e}")
- raise
-
- def on_data(self, data: bytes):
- """接收音频数据(MP3格式)"""
- try:
- if data:
- # 累积 MP3 数据
- self.mp3_buffer.write(data)
- self.total_audio_size += len(data)
-
- # 🔥 当累积足够数据时,尝试解码并发送PCM
- # 策略:累积到最小解码大小(4KB)后尝试解码
- self._process_mp3_buffer()
-
- except Exception as e:
- logger.error(f"[TTS回调异常] on_data错误: {e}")
- self.error_holder[0] = e
- self.queue.put(('error', e))
-
- def on_complete(self):
- """流式合成完成"""
- try:
- # 🔥 处理剩余的 MP3 数据
- if self.mp3_buffer.tell() > 0:
- self.mp3_buffer.seek(0)
- try:
- audio_segment = AudioSegment.from_mp3(self.mp3_buffer)
- audio_segment = audio_segment.set_frame_rate(24000).set_channels(1).set_sample_width(2)
- pcm_data = audio_segment.raw_data
-
- # 发送剩余的PCM数据
- chunk_size = 3200
- for i in range(0, len(pcm_data), chunk_size):
- chunk = pcm_data[i:i + chunk_size]
- self.chunk_count += 1
- self.queue.put(('data', chunk))
- except FileNotFoundError as e:
- # ffmpeg/ffprobe 未安装
- error_msg = str(e)
- if 'ffprobe' in error_msg or 'ffmpeg' in error_msg or 'avprobe' in error_msg:
- logger.error(
- f"[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
- f"请在生产服务器安装 ffmpeg: apt-get install ffmpeg (Ubuntu/Debian) 或 yum install ffmpeg (CentOS/RHEL)"
- )
- self.error_holder[0] = Exception(
- "系统缺少 ffmpeg,无法处理音频。请安装: apt-get install ffmpeg 或 yum install ffmpeg"
- )
- self.queue.put(('error', self.error_holder[0]))
- return
- raise
- except Exception as e:
- logger.warning(f"[TTS回调] 处理剩余数据失败: {e}")
-
- logger.debug(f"TTS流式合成完成,共{self.chunk_count}个chunk,总大小{self.total_audio_size}字节")
- self.queue.put(('done', None))
- self.completion_event.set()
-
- except Exception as e:
- logger.error(f"[TTS回调异常] on_complete错误: {e}")
- self.error_holder[0] = e
- self.queue.put(('error', e))
-
- def on_error(self, message: str):
- """发生错误"""
- error = Exception(f"TTS流式合成错误: {message}")
- logger.error(f"[TTS回调错误] {message}")
- self.error_holder[0] = error
- self.queue.put(('error', error))
- self.completion_event.set()
-
- def on_close(self):
- """连接关闭"""
- logger.debug("TTS流式连接已关闭")
-
- def stream_producer():
- """在独立线程中执行流式TTS API调用"""
- nonlocal chunk_count, first_chunk_time, total_audio_size
- try:
- # 🔥 关键修复:在锁内完成整个API调用
- with AIService._tts_call_lock:
- # 创建回调实例
- callback = TTSStreamCallback(audio_queue, mp3_buffer, error_holder, completion_event)
-
- # 创建 SpeechSynthesizer 实例(使用回调)
- # 🔥 注意:instruction 参数可能在某些情况下导致错误,先尝试不传
- synthesizer_params = {
- "model": settings.tts_model,
- "voice": voice,
- "speech_rate": settings.tts_speech_rate,
- "pitch_rate": settings.tts_pitch_rate,
- "language_hints": settings.tts_language_hints,
- "callback": callback,
- }
- # 只在 instruction 不为空时添加(避免参数错误)
- if instruction and instruction.strip():
- synthesizer_params["instruction"] = instruction
-
- synthesizer = SpeechSynthesizer(**synthesizer_params)
-
- # 🔥 使用流式调用(真正的流式传输)
- synthesizer.streaming_call(text)
-
- # 🔥 重要:必须调用 streaming_complete() 确保结尾部分被合成
- synthesizer.streaming_complete()
-
- # 等待回调完成
- completion_event.wait(timeout=30)
-
- except Exception as e:
- logger.error(
- f"[TTS异常] 错误类型=TTS流式生成线程异常, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.tts_model}, "
- f"音色={voice}, "
- f"文本长度={len(text)}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- error_holder[0] = e
- audio_queue.put(('error', e))
- completion_event.set()
-
- # 启动生产者线程
- chunk_count = 0
- first_chunk_time = None
- total_audio_size = 0
- received_data = False # 标记是否收到过数据
-
- producer_thread = threading.Thread(target=stream_producer, daemon=True)
- producer_thread.start()
-
- # 异步消费队列数据
- while True:
- # 非阻塞获取(避免阻塞事件循环)
- try:
- msg_type, data = await asyncio.to_thread(audio_queue.get, timeout=30)
-
- if msg_type == 'data':
- received_data = True
- yield data
- await asyncio.sleep(0) # 让出控制权
- elif msg_type == 'done':
- break
- elif msg_type == 'error':
- raise data
-
- except queue.Empty:
- # 检查是否已完成
- if completion_event.is_set():
- break
- error_msg = "TTS API 超时(30秒无响应)"
- logger.error(
- f"[TTS异常] 错误类型=TTS API超时, "
- f"错误消息={error_msg}, "
- f"模型={settings.tts_model}, "
- f"音色={self._get_voice_for_emotion(emotion)}, "
- f"文本长度={len(text)}, "
- f"已接收数据={received_data}, "
- f"chunk计数={chunk_count}"
- )
- raise Exception(error_msg)
-
- # 检查是否收到过数据(避免竞态条件)
- if not received_data:
- error_msg = f"TTS API 未返回任何音频数据 (text='{text}', emotion={emotion}, voice={voice})"
- if error_holder[0]:
- error_msg += f"\n生产者线程错误: {error_holder[0]}"
- logger.error(
- f"[TTS异常] 错误类型=TTS API无数据返回, "
- f"错误消息={error_msg}, "
- f"模型={settings.tts_model}, "
- f"音色={voice}, "
- f"文本长度={len(text)}, "
- f"文本内容={text[:100]}... (前100字符)"
- )
- raise Exception(error_msg)
-
- except Exception as e:
- logger.error(
- f"[TTS异常] 错误类型=TTS流式生成内部异常, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.tts_model}, "
- f"音色={self._get_voice_for_emotion(emotion)}, "
- f"文本长度={len(text)}, "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- raise
-
- def _trigger_llm_healthcheck_background(self):
- """
- 触发LLM健康检查(后台异步执行,不阻塞主流程)
-
- 优化:健康检查在后台执行,不等待结果,避免阻塞用户请求
- 如果连接有问题,在实际API调用时会捕获异常并重建连接
- """
- import time
- now = time.time()
-
- # 如果距离上次检查超过间隔,触发后台健康检查
- if now - self._last_llm_healthcheck < self._llm_healthcheck_interval:
- return
-
- # 避免重复执行健康检查任务
- if "llm" in self._healthcheck_tasks:
- return
-
- # 创建后台任务(不等待完成)
- async def background_healthcheck():
- try:
- self._healthcheck_tasks.add("llm")
- # 执行简单的健康检查:发送一个极短的测试请求
- def health_check():
- return self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=[{"role": "user", "content": "Hi"}],
- max_tokens=1,
- timeout=5.0, # 5秒超时
- )
-
- test_response = await asyncio.to_thread(health_check)
- self._last_llm_healthcheck = time.time()
- logger.debug("✅ LLM连接健康检查成功(后台)")
- except Exception as e:
- # 健康检查失败,重建HTTP客户端
- logger.warning(f"⚠️ LLM连接健康检查失败,重建连接: {e}")
- self._recreate_http_client()
- self._last_llm_healthcheck = time.time()
- finally:
- self._healthcheck_tasks.discard("llm")
-
- # 在后台执行,不等待结果
- try:
- loop = asyncio.get_event_loop()
- loop.create_task(background_healthcheck())
- except RuntimeError:
- # 如果没有事件循环,使用线程执行
- import threading
- def run_in_thread():
- new_loop = asyncio.new_event_loop()
- asyncio.set_event_loop(new_loop)
- new_loop.run_until_complete(background_healthcheck())
- new_loop.close()
- threading.Thread(target=run_in_thread, daemon=True).start()
-
- async def _ensure_llm_connection(self):
- """
- 确保LLM连接可用(同步等待版本,用于需要确保连接可用的场景)
-
- 注意:此方法已废弃,改为使用 _trigger_llm_healthcheck_background()
- 保留此方法以保持向后兼容,但不再使用
- """
- # 直接返回,不执行阻塞的健康检查
- # 如果连接有问题,在实际API调用时会捕获异常并重建连接
- pass
-
- def _trigger_tts_healthcheck_background(self):
- """
- 触发TTS健康检查(后台异步执行,不阻塞主流程)
-
- 优化:健康检查在后台执行,不等待结果,避免阻塞用户请求
- 如果连接有问题,在实际API调用时会捕获异常并重建连接
- """
- import time
- now = time.time()
-
- # 如果距离上次检查超过间隔,触发后台健康检查
- if now - self._last_tts_healthcheck < self._tts_healthcheck_interval:
- return
-
- # 避免重复执行健康检查任务
- if "tts" in self._healthcheck_tasks:
- return
-
- # 创建后台任务(不等待完成)
- async def background_healthcheck():
- try:
- self._healthcheck_tasks.add("tts")
- # 执行简单的健康检查:发送一个极短的测试TTS请求
- def health_check():
- # 使用 SpeechSynthesizer 进行健康检查
- synthesizer = SpeechSynthesizer(
- model=settings.tts_model,
- voice=settings.tts_voice,
- speech_rate=settings.tts_speech_rate,
- pitch_rate=settings.tts_pitch_rate,
- language_hints=settings.tts_language_hints,
- )
- # 发送极短文本进行测试
- audio = synthesizer.call("Hi")
- return True
-
- await asyncio.to_thread(health_check)
- self._last_tts_healthcheck = time.time()
- logger.debug("✅ TTS连接健康检查成功(后台)")
- except Exception as e:
- # 健康检查失败,重建HTTP客户端
- logger.warning(f"⚠️ TTS连接健康检查失败,重建连接: {e}")
- self._recreate_http_client()
- self._last_tts_healthcheck = time.time()
- finally:
- self._healthcheck_tasks.discard("tts")
-
- # 在后台执行,不等待结果
- try:
- loop = asyncio.get_event_loop()
- loop.create_task(background_healthcheck())
- except RuntimeError:
- # 如果没有事件循环,使用线程执行
- import threading
- def run_in_thread():
- new_loop = asyncio.new_event_loop()
- asyncio.set_event_loop(new_loop)
- new_loop.run_until_complete(background_healthcheck())
- new_loop.close()
- threading.Thread(target=run_in_thread, daemon=True).start()
-
- async def _ensure_tts_connection(self):
- """
- 确保TTS连接可用(同步等待版本,用于需要确保连接可用的场景)
-
- 注意:此方法已废弃,改为使用 _trigger_tts_healthcheck_background()
- 保留此方法以保持向后兼容,但不再使用
- """
- # 直接返回,不执行阻塞的健康检查
- # 如果连接有问题,在实际API调用时会捕获异常并重建连接
- pass
-
- def _recreate_http_client(self):
- """重建HTTP客户端(连接失效时)"""
- # 🔥 使用连接池管理器重建连接(所有实例共享)
- http_pool.recreate_client()
- # 🔥 重新创建OpenAI客户端(使用新的HTTP连接池)
- http_client = http_pool.get_client()
- self.openai_client = OpenAI(
- api_key=settings.active_dashscope_api_key,
- base_url=get_dashscope_http_base_url(),
- http_client=http_client, # 使用新的连接池
- )
- logger.info("✅ HTTP客户端已重建(通过连接池管理器)")
-
- def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
- """根据情感选择音色"""
- # 所有情感统一使用配置文件中的音色
- return settings.tts_voice
-
- def _build_emotion_instruction(self, emotion: EmotionType) -> str:
- """根据情感构建 instruction 参数(用于 cosyvoice-v3)"""
- # 🔥 根据官方文档,instruction 格式应为英文,且情感名称需小写
- # 暂时返回空字符串,避免参数错误(428),后续可根据需要启用
- # emotion_map = {
- # "neutral": "You are having a casual conversation, your emotion is neutral.",
- # "happy": "You are having a casual conversation, your emotion is happy.",
- # "sad": "You are having a casual conversation, your emotion is sad.",
- # "excited": "You are having a casual conversation, your emotion is excited.",
- # "angry": "You are having a casual conversation, your emotion is angry.",
- # "surprised": "You are having a casual conversation, your emotion is surprised.",
- # "thoughtful": "You are having a casual conversation, your emotion is thoughtful.",
- # "encouraging": "You are having a casual conversation, your emotion is encouraging.",
- # "confused": "You are having a casual conversation, your emotion is confused.",
- # "worried": "You are having a casual conversation, your emotion is worried.",
- # "satisfied": "You are having a casual conversation, your emotion is satisfied.",
- # "friendly": "You are having a casual conversation, your emotion is friendly.",
- # "sorry": "You are having a casual conversation, your emotion is sorry.",
- # "fearful": "You are having a casual conversation, your emotion is fearful.",
- # }
- # return emotion_map.get(emotion, emotion_map["neutral"])
- return "" # 暂时禁用 instruction,避免 428 错误
-
- # ===================== System Prompts (参考 prompts-v0.2.1.py 架构) =====================
-
- def _get_system_prompt_base(self, user_info: Optional[dict] = None) -> str:
- """
- 基础提示词(Lumi 的"本体")- v0.3 Pre-Adventure Bonding Phase
- 参考 prompts-v0.3.py 的 SYSTEM_PROMPT_BASE
- 注意:情感标签说明是系统级要求(用于TTS和前端动画),prompts-v0.3.py 中不包含,但实际系统需要
- Args:
- user_info: 用户信息字典(包含 username, bot_name)
- """
- # 获取用户命名的Lumi名字
- lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
- return f"""You are {lumi_name} — a warm, gentle underground spirit.
- You and the user are simply getting to know each other.
- ────────────────────────
- PRIMARY GOAL
- ────────────────────────
- Have a relaxed, friendly voice conversation that helps the user practice English comfortably.
- The feeling should be like chatting with a kind friend.
- ────────────────────────
- HOW YOU SPEAK
- ────────────────────────
- - Use natural, conversational, everyday English.
- - Keep replies simple and short (no more than 2 sentences).
- - Often end with ONE easy, open question.
- - Sound friendly and present, not poetic or expressive.
- - Do NOT jump to unrelated daily facts (e.g. breakfast, weather, routine) if the user is sharing a feeling.
- - Follow emotional continuity first.
- - Do NOT use emojis, emoticons, or decorative symbols.
- - Stay within the current topic unless the user clearly introduces a new one.
- - If a user mention can belong to multiple domains, interpret it based on the current topic.
- If the user writes Chinese, reply in simple English.
- You may briefly check understanding, but do not translate long text.
- ────────────────────────
- YOUR PERSONALITY (LUMI)
- ────────────────────────
- - Friendly, calm, and a little shy.
- - Curious about everyday human life.
- - Comfortable with simple, quiet conversation.
- ────────────────────────
- WORLD BACKGROUND (PASSIVE ONLY)
- ────────────────────────
- These facts are true but should stay in the background:
- - You live in an underground world.
- - At its centre is the Tree of Life.
- - The Tree is ill; darkness has affected some areas.
- - Glimmer Forest is where you live.
- Important rules:
- - Do NOT bring these up on your own.
- - Do NOT add world or environment details as decoration.
- - Only talk about them if the user asks directly.
- ────────────────────────
- LANGUAGE CONSTRAINT (B1 MODE)
- ────────────────────────
- Assume the user may be around CEFR B1.
- - Use simple, high-frequency words.
- - Prefer short sentences in PRESENT tense.
- ────────────────────────
- LANGUAGE SUPPORT (HIDDEN)
- ────────────────────────
- - Focus on meaning and confidence.
- - If the user makes mistakes, gently recast in your reply.
- - Never explain grammar or spelling.
- - Never mention errors.
- **Emotion Tag - REQUIRED**:
- Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 1-2 sentences of spoken response.
- Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
- **Choose based on**:
- - User's emotion (sad→sorry/encouraging, happy→excited/happy)
- - Your response tone (empathetic, supportive, cheerful, friendly)
- Example:
- [EMOTION:happy]
- Hello! I'm so glad to see you. How are you feeling today?
- Critical:
- - NEVER return ONLY the emotion tag
- - ALWAYS include actual spoken text after the tag
- - The emotion tag MUST be the FIRST line, before any other content
- - NO parentheses () or brackets [] in spoken text (they will be removed during speech synthesis)
- - NO emojis or emoticons in your response"""
- def _get_system_prompt_new_user(self) -> str:
- """新用户模式(延迟入戏,逐步熟悉)- v0.3,参考 prompts-v0.3.py 的 SYSTEM_PROMPT_NEW_USER"""
- return r"""
- USER CONTEXT: NEW USER
- This is your first time meeting the user.
- Interaction style:
- - Assume no shared history.
- - Let the user decide how much to say.
- Conversation approach:
- - Show mild curiosity and warmth.
- - Invite everyday, easy topics.
- Avoid:
- - introduce world background unless the user asks
- - references to shared memories
- - hints about future adventures or plans
- The feeling should be:
- "Two strangers sitting down quietly and starting to talk."
- """
- def _get_system_prompt_returning_user(self) -> str:
- """回归用户模式(直接入戏,推进世界)- v0.3,参考 prompts-v0.3.py 的 SYSTEM_PROMPT_RETURNING_USER"""
- return r"""
- USER CONTEXT: RETURNING USER
- You have talked with this user before.
- Interaction style:
- - Warm and familiar, but relaxed.
- - You may gently acknowledge seeing them again.
- - Continuity should feel optional, not tested.
- Conversation approach:
- - A soft reconnection is enough.
- - You may refer to general past impressions if relevant.
- - Let the user lead where the conversation goes today.
- Avoid:
- - assuming emotional closeness
- - checking memory explicitly
- - pushing deeper topics too quickly
- The feeling should be:
- "Nice to see you again — we can just pick up from here."
- """
- def _build_runtime_context(self, user_info: Optional[dict] = None) -> str:
- """
- 构建运行时上下文(每轮注入)- v0.3
- 包含用户信息、Lumi名称和历史记忆摘要
- """
- if not user_info:
- user_info = {}
-
- user_name = (user_info.get("username") or "").strip() or "Neo"
- lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
- user_id = user_info.get("user_id", "")
-
- # 尝试加载记忆摘要(从 JSON 文件)
- memory_summary = ""
- if user_id:
- try:
- import sys
- from pathlib import Path
- # 添加项目根目录到路径
- project_root = Path(__file__).parent.parent
- sys.path.insert(0, str(project_root))
- from memory_summaries.memory_service import load_memory
-
- memory = load_memory(user_id)
- memory_summary = memory.get("meta.memory_summary", "")
- if isinstance(memory_summary, str):
- memory_summary = memory_summary.strip()
- else:
- memory_summary = ""
- except Exception as e:
- logger.debug(f"加载记忆失败: {e}")
- memory_summary = ""
-
- memory_block = memory_summary if memory_summary else "(none)"
-
- return f"""
- [RUNTIME CONTEXT]
- User name: {user_name}
- Lumi name: {lumi_name}
- User level: beginner
- Current zone: Glimmer Forest
- Last mission: Warm-up near the Tree
- {memory_block}
- """.strip()
- def _build_system_prompt(
- self,
- user_info: Optional[dict] = None,
- is_new_user: bool = False
- ) -> str:
- """
- 构建System Prompt(模块化架构,参考 prompts-v0.2.1.py)
-
- Args:
- user_info: 用户信息字典(包含 username, bot_name)
- is_new_user: 是否为新用户(首次对话)
-
- Returns:
- 完整的 System Prompt
- """
- # 基础提示词
- base_prompt = self._get_system_prompt_base(user_info)
-
- # 根据用户类型选择模式
- if is_new_user:
- user_mode_prompt = self._get_system_prompt_new_user()
- else:
- user_mode_prompt = self._get_system_prompt_returning_user()
-
- # 组合提示词
- system_prompt = base_prompt + "\n\n" + user_mode_prompt
-
- return system_prompt
-
- def _build_simplified_prompt(self, learner_proficiency: str, age_tag: str, topic_tag: str, experiment_tag: str) -> str:
- """构建简化版System Prompt(优化延迟,减少token)"""
- prompt = f"""You are KELLY, a friendly English tutor in London.
- **Target**: {learner_proficiency} level, {age_tag}, {topic_tag}
- **Guidelines**:
- 1. Lead conversation actively, 2-3 short simple sentences
- 2. Use A1-A2 level vocabulary only
- 3. Ask easy, personal follow-up questions
- 4. If unclear, ask for clarification then wait
- 5. Transition smoothly between topics
- 6. Be empathetic but natural
- """
-
- # 实验组:简化的纠错规则
- if experiment_tag == "experiment":
- prompt += """
- **Feedback**:
- - Recast grammar errors naturally (one at a time)
- - Supportive tone, praise progress
- - Never say "you made a mistake"
- """
-
- # 情感标签(必需)
- prompt += """
- **Emotion Tag - REQUIRED**:
- Add [EMOTION:name] at the FIRST line, then ALWAYS follow with 2-3 sentences of spoken response.
- Emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
- **Choose based on**:
- - Learner's emotion (sad→sorry/encouraging, happy→excited)
- - Your response tone (empathetic, supportive, cheerful)
- Example:
- [EMOTION:sorry]
- I'm sorry to hear that. What happened?
- Critical:
- - NEVER return ONLY the emotion tag
- - ALWAYS include actual spoken text after the tag
- - NO parentheses () or brackets [] in spoken text
- """
-
- return prompt
-
- async def generate_summary(self, prompt: str) -> str:
- """
- 生成对话总结(非流式)
-
- Args:
- prompt: 总结提示词
-
- Returns:
- 总结文本
- """
- try:
- # 🔥 连接健康检查(长时间未使用时)
- await self._ensure_llm_connection()
-
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=[
- {"role": "system", "content": "你是一位专业的英语学习助手,擅长总结学习对话的关键信息。"},
- {"role": "user", "content": prompt},
- ],
- temperature=0.5,
- max_tokens=300,
- )
-
- summary = response.choices[0].message.content or ""
-
- return summary.strip()
-
- except Exception as e:
- logger.error(
- f"[LLM异常] 错误类型=生成总结失败, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={settings.llm_model}, "
- f"提示词长度={len(prompt)}, "
- f"提示词内容={prompt[:200]}... (前200字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- return ""
- # 全局AI服务实例
- ai_service = AIService()
|