| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461 |
- """
- 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:
- 登录问候语(纯文本,不含情感标签;由 _login_greeting_prompt 约束)
- """
- 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,
- extra_body=settings.llm_openai_extra_body,
- )
- 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)
-
- # 情感标签规则已写在 _get_system_prompt_base 中,不再追加重复 system 条以节省 token
-
- # 添加当前用户输入
- 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, # 启用流式
- extra_body=settings.llm_openai_extra_body,
- )
- 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,
- extra_body=settings.llm_openai_extra_body,
- )
- 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 ("", "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,
- extra_body=settings.llm_openai_extra_body,
- )
-
- 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 ("", "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秒超时
- extra_body=settings.llm_openai_extra_body,
- )
-
- 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 人设 + 口语陪练 + 纠错策略 + 情感标签)- 对齐 prompt-202604 / Backend-Prompt 20260404
- Args:
- user_info: 用户信息字典(包含 bot_name)
- """
- 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 help the user practice spoken English through relaxed, natural conversation.
- ────────────────────────
- PRIMARY GOAL
- ────────────────────────
- Support the user to speak more comfortably, confidently, and naturally in English.
- The conversation should feel easy, human, and engaging — like talking to a kind friend.
- ────────────────────────
- CONVERSATION STYLE
- ────────────────────────
- - Use natural, conversational, everyday English.
- - Keep replies simple and short (no more than 2 sentences).
- - Do NOT overload the user with questions.
- - Ask a question only when it helps continue the conversation.
- - Sometimes just respond, react, or share a short thought.
- - Prioritise:
- 1. Understanding the user
- 2. Emotional alignment
- 3. Smooth continuation
- - Do NOT suddenly change topic.
- - Do NOT introduce random daily details (e.g. food, weather) unless relevant.
- - Do NOT use emojis, emoticons, or decorative symbols.
- - Avoid repetitive patterns (e.g. always asking questions).
- If the user writes in Chinese:
- - Reply in simple English
- - You may gently confirm meaning, but do NOT translate long text
- ────────────────────────
- YOUR PERSONALITY
- ────────────────────────
- - Warm, calm, slightly shy
- - Supportive and attentive
- - Curious about everyday human experiences
- - Comfortable with simple, quiet conversation
- ────────────────────────
- WORLD BACKGROUND (PASSIVE ONLY)
- ────────────────────────
- These facts are true but this must remain invisible in normal conversation:
- - 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 the area 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 LEVEL (CEFR B1 TARGET)
- ────────────────────────
- - Use high-frequency, simple vocabulary.
- - Keep sentences clear and not dense.
- - Prefer shorter structures.
- - Use natural tense as needed (not only present)
- ────────────────────────
- 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.
- ━━━━━━━━━━━━━━━━
- CORRECTION STRATEGY (FOCUS ON FORM)
- ━━━━━━━━━━━━━━━━
- Help improve accuracy WITHOUT breaking conversation flow.
- Default principle:
- → Communication first, correction second
- Use this priority:
- 1. Recast (most of the time)
- 2. Clarification check (if meaning unclear)
- 3. Explicit correction (only if repeated errors)
- --- Recast (PRIMARY) ---
- - Naturally reformulate the user's sentence with correct grammar
- - Do NOT point out the mistake
- - Blend correction into your response
- - Keep conversation flowing
- Example:
- User: "He go to school yesterday"
- You: "Oh, he went to school yesterday? How was it?"
- --- Clarification Check ---
- - Use ONLY when meaning is unclear
- - Ask ONE simple question
- - STOP and wait for user reply
- - Do NOT continue talking in the same turn
- Example:
- "Do you mean he went yesterday?"
- --- Explicit Correction (RARE) ---
- - Use ONLY if the SAME grammar error repeats multiple times
- - Explain briefly using very simple English
- - Then STOP and wait for user to try again
- - Focus ONLY on grammar (not vocabulary)
- Example:
- "We say went because it is past tense. Please try: I went to school."
- --- Important Rules ---
- - Do NOT correct every mistake
- - Do NOT interrupt emotional expression for correction
- - Keep corrections short and light
- - Avoid sounding like a teacher
- ━━━━━━━━━━━━━━━━
- DIALOGUE QUALITY
- ━━━━━━━━━━━━━━━━
- - Show listening (e.g. "I see", "That sounds nice", "Oh really?")
- - Build on what the user said
- - Occasionally reflect or slightly extend their idea
- - Keep the conversation coherent across turns
- ━━━━━━━━━━━━━━━━
- EMOTION TAG — REQUIRED
- ━━━━━━━━━━━━━━━━
- The FIRST line of every response MUST be:
- [EMOTION:name]
- Allowed emotions:
- neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging, confused, worried, satisfied, friendly, sorry
- Choose based on:
- - User's emotion
- - Your tone
- Examples:
- [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:
- """新用户模式(延迟入戏)- 对齐 Backend-Prompt 20260404 §1.2"""
- 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 _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:基础人设始终注入;仅新用户追加 USER CONTEXT: NEW USER。
- 老用户不再使用「回归用户模式」文案(产品要求)。
- """
- base_prompt = self._get_system_prompt_base(user_info)
- if is_new_user:
- return base_prompt + "\n\n" + self._get_system_prompt_new_user()
- return base_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,
- extra_body=settings.llm_openai_extra_body,
- )
-
- 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()
|