| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538 |
- """
- AI服务:集成STT、LLM、TTS模型
- """
- import base64
- import json
- import asyncio
- import tempfile
- import os
- import logging
- import traceback
- from typing import AsyncGenerator, Optional, Dict, Any
- 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 # 🔥 使用连接池管理器
- from scenario_config import is_special_ab_experiment_user
- from prompt_constants import AB_TASK_EMOTION_APPENDIX
- from ab_experiment_media_config import resolve_llm_model, resolve_tts_params, TtsParams
- 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 ab_group_greeting_line(
- self,
- user_name: str,
- lumi_name: str,
- is_new_user: bool,
- lumi_first_line: Optional[str] = None,
- ) -> str:
- """A/B 实验用户首句:优先使用任务表 lumi_first_line,否则降级为固定问候。"""
- if lumi_first_line and lumi_first_line.strip():
- return self._render_lumi_first_line(lumi_first_line, user_name, lumi_name)
- user_name = (user_name or "").strip() or "Neo"
- lumi_name = (lumi_name or "").strip() or "Lumi"
- if is_new_user:
- return f"Hi {user_name}, I'm {lumi_name}. How are you today?"
- return f"Hi {user_name}, welcome back. How are you today?"
- def _render_lumi_first_line(self, template: str, user_name: str, lumi_name: str) -> str:
- user_name = (user_name or "").strip() or "Neo"
- lumi_name = (lumi_name or "").strip() or "Lumi"
- text = template.strip()
- text = text.replace("{user_name}", user_name).replace("{lumi_name}", lumi_name)
- if lumi_name != "Lumi":
- text = text.replace("Lumi", lumi_name)
- return text
- 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})
-
- llm_model = resolve_llm_model(user_info)
- try:
- response = self.openai_client.chat.completions.create(
- model=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,
- ab_task: Optional[Dict[str, Any]] = None,
- ) -> AsyncGenerator[tuple[str, EmotionType], None]:
- """
- 生成对话回复(流式)
-
- Args:
- user_text: 用户输入文本
- user_info: 用户信息字典(包含 username, bot_name)
- is_new_user: 是否为新用户(首次对话)
- chat_history: 对话历史(可选)
-
- Yields:
- (sentence, emotion): 句子片段和情感
- """
- try:
- 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
- llm_model = resolve_llm_model(user_info)
- # 构建System Prompt
- system_prompt = self._build_system_prompt(
- user_info,
- is_new_user,
- ab_task_prompt=ab_task_prompt,
- )
-
- # 构建运行时上下文
- runtime_context = self._build_runtime_context(user_info, ab_task_name=ab_task_name)
-
- # 调用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=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=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"模型={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"模型={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 not chunk.choices:
- continue
- content = chunk.choices[0].delta.content
- if not content:
- continue
- 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"模型={resolve_llm_model(user_info)}, "
- 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,
- ab_task: Optional[Dict[str, Any]] = None,
- ) -> tuple[str, EmotionType]:
- """
- 生成对话回复(非流式)
-
- Args:
- user_text: 用户输入文本
- user_info: 用户信息字典(包含 username, bot_name)
- is_new_user: 是否为新用户(首次对话)
- chat_history: 对话历史(可选)
-
- Returns:
- (response_text, emotion): 回复文本和情感
- """
- try:
- 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
- llm_model = resolve_llm_model(user_info)
- # 构建System Prompt
- system_prompt = self._build_system_prompt(
- user_info,
- is_new_user,
- ab_task_prompt=ab_task_prompt,
- )
-
- # 构建运行时上下文
- runtime_context = self._build_runtime_context(user_info, ab_task_name=ab_task_name)
-
- # 🔥 连接健康检查(长时间未使用时)
- 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=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"模型={resolve_llm_model(user_info)}, "
- f"用户输入={user_text[:100]}... (前100字符), "
- f"堆栈跟踪:\n{traceback.format_exc()}"
- )
- return ("", "neutral")
-
- async def text_to_speech_stream(
- self, text: str, emotion: EmotionType = "neutral", user_info: Optional[dict] = None
- ) -> AsyncGenerator[bytes, None]:
- """
- 文本转语音(流式 - 实时返回音频块),带重试机制和速率控制
-
- Args:
- text: 要合成的文本
- emotion: 情感类型
-
- Yields:
- audio_data: PCM 音频数据块(Base64 解码后的原始数据)
- """
- import time
-
- tts_params = resolve_tts_params(user_info)
-
- # 🔥 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, tts_params=tts_params
- ):
- 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"模型={tts_params['model']}, "
- f"音色={tts_params['voice']}, "
- 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",
- tts_params: Optional[TtsParams] = None,
- ) -> AsyncGenerator[bytes, None]:
- """
- TTS流式调用的内部实现(不含重试逻辑)
- 使用 SpeechSynthesizer.streaming_call() 实现真正的流式传输
-
- Args:
- text: 要合成的文本
- emotion: 情感类型
- tts_params: TTS 模型/音色/语速/音调(A/B 与普通用户可不同)
-
- Yields:
- audio_data: PCM 音频数据块(原始音频数据)
- """
- try:
- import time
- tts_start = time.time()
-
- if tts_params is None:
- tts_params = resolve_tts_params(None)
-
- voice = tts_params["voice"]
- instruction = self._build_emotion_instruction(emotion)
-
- # 🔍 调试:打印实际使用的配置
- logger.info(
- f"🔍 TTS调用 - 模型={tts_params['model']}, "
- f"音色={voice}, "
- f"语速={tts_params['speech_rate']}, "
- f"音调={tts_params['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.first_pcm_sent = False
- # 首包用小阈值加快首音;后续用大阈值减少 ffmpeg 调用次数
- self.mp3_min_size_first = 1024
- self.mp3_min_size_next = 8192
-
- def _current_mp3_min_size(self) -> int:
- if not self.first_pcm_sent:
- return self.mp3_min_size_first
- return self.mp3_min_size_next
-
- def _decode_mp3_to_pcm_chunks(self, force: bool = False) -> bool:
- """解码缓冲区 MP3 为 PCM 块;成功发出数据返回 True。"""
- buffer_size = self.mp3_buffer.tell()
- min_size = 0 if force else self._current_mp3_min_size()
- if buffer_size < min_size:
- return False
- self.mp3_buffer.seek(0)
- 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
- 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.first_pcm_sent = True
- self.mp3_buffer.seek(0)
- self.mp3_buffer.truncate(0)
- return True
-
- def _process_mp3_buffer(self, force: bool = False):
- """处理累积的MP3数据,转换为PCM并发送"""
- try:
- self._decode_mp3_to_pcm_chunks(force=force)
- except FileNotFoundError as e:
- error_msg = str(e)
- if (
- "ffprobe" in error_msg
- or "ffmpeg" in error_msg
- or "avprobe" in error_msg
- ):
- logger.error(
- "[TTS错误] 系统缺少 ffmpeg,无法解码 MP3 音频。"
- "请安装: apt-get install ffmpeg 或 yum install ffmpeg"
- )
- 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:
- error_str = str(e).lower()
- if (
- "ffprobe" not in error_str
- and "ffmpeg" not in error_str
- and "avprobe" not in error_str
- ):
- return
- logger.error(f"[TTS错误] MP3解码失败: {e}")
- raise
-
- def on_open(self):
- """连接打开"""
- logger.debug("TTS流式连接已建立")
- self.first_chunk_time = time.time()
-
- def on_data(self, data: bytes):
- """接收音频数据(MP3格式)"""
- try:
- if data:
- self.mp3_buffer.write(data)
- self.total_audio_size += len(data)
- 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:
- if self.mp3_buffer.tell() > 0:
- self._process_mp3_buffer(force=True)
-
- 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": tts_params["model"],
- "voice": voice,
- "speech_rate": tts_params["speech_rate"],
- "pitch_rate": tts_params["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"模型={tts_params['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"模型={tts_params['model']}, "
- f"音色={voice}, "
- 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"模型={tts_params['model']}, "
- f"音色={voice}, "
- f"文本长度={len(text)}, "
- f"文本内容={text[:100]}... (前100字符)"
- )
- raise Exception(error_msg)
-
- except Exception as e:
- fallback_params = tts_params if tts_params is not None else resolve_tts_params(None)
- logger.error(
- f"[TTS异常] 错误类型=TTS流式生成内部异常, "
- f"异常类型={type(e).__name__}, "
- f"错误消息={str(e)}, "
- f"模型={fallback_params['model']}, "
- f"音色={fallback_params['voice']}, "
- 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 _personalize_task_prompt(self, task_prompt: str, user_info: Optional[dict]) -> str:
- lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi" if user_info else "Lumi"
- text = task_prompt.strip()
- if lumi_name != "Lumi":
- if text.startswith("You are Lumi,"):
- text = f"You are {lumi_name}," + text[len("You are Lumi,"):]
- elif text.startswith("You are Lumi "):
- text = text.replace("You are Lumi ", f"You are {lumi_name} ", 1)
- return text
- def _build_ab_system_prompt(
- self,
- user_info: Optional[dict],
- ab_task_prompt: Optional[str],
- ) -> str:
- base = (ab_task_prompt or "").strip()
- if not base:
- logger.warning("[AIService] A/B user missing task prompt, falling back to default base")
- base = self._get_system_prompt_base(user_info)
- else:
- base = self._personalize_task_prompt(base, user_info)
- return f"{base}\n\n{AB_TASK_EMOTION_APPENDIX}"
- def _build_runtime_context(
- self, user_info: Optional[dict] = None, ab_task_name: Optional[str] = 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"
- if is_special_ab_experiment_user(user_info):
- task_line = (ab_task_name or "").strip() or "Active learning task"
- return f"""[RUNTIME CONTEXT]
- User name: {user_name}
- Lumi name: {lumi_name}
- Active task: {task_line}
- """.strip()
- 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,
- ab_task_prompt: Optional[str] = None,
- ) -> str:
- """
- 构建 System Prompt:基础人设始终注入;仅新用户追加 USER CONTEXT: NEW USER。
- A/B 实验用户:使用任务表 prompt + EMOTION 规则,不追加 new_user 段。
- 老用户不再使用「回归用户模式」文案(产品要求)。
- """
- if is_special_ab_experiment_user(user_info):
- return self._build_ab_system_prompt(user_info, ab_task_prompt)
- 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()
|