| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- """
- AI服务:集成STT、LLM、TTS模型
- """
- import base64
- import json
- import asyncio
- import tempfile
- import os
- from typing import AsyncGenerator, Optional
- import dashscope
- from openai import OpenAI
- from config import settings
- from models import EmotionType
- # 设置API Key
- dashscope.api_key = settings.dashscope_api_key
- class AIService:
- """AI服务管理器"""
-
- def __init__(self):
- import httpx
- import os
-
- # 临时清除代理环境变量(只在这个进程中)
- env_backup = {}
- proxy_vars = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']
- for var in proxy_vars:
- if var in os.environ:
- env_backup[var] = os.environ[var]
- del os.environ[var]
-
- # 创建不使用代理的 HTTP 客户端
- http_client = httpx.Client(
- proxies={}, # 明确设置为空字典,禁用所有代理
- timeout=60.0,
- trust_env=False, # 不信任环境变量中的代理设置
- )
-
- self.openai_client = OpenAI(
- api_key=settings.dashscope_api_key,
- base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
- http_client=http_client, # 使用自定义 HTTP 客户端
- )
-
- # 恢复环境变量(避免影响其他模块)
- for var, value in env_backup.items():
- os.environ[var] = value
-
- # STT现在使用流式识别,在websocket_handler中直接处理
-
- async def generate_response_stream(
- self, user_text: str
- ) -> AsyncGenerator[tuple[str, EmotionType], None]:
- """
- 生成对话回复(流式)
-
- Args:
- user_text: 用户输入文本
-
- Yields:
- (sentence, emotion): 句子片段和情感
- """
- try:
- print(f"\n========== LLM 流式开始 ==========")
- print(f"用户输入: {user_text}")
-
- # 构建System Prompt
- system_prompt = """You are a friendly English learning assistant.
- Please respond to the user in English to help them practice.
- Be encouraging and supportive.
- At the end of your response, on a new line, add ONLY the emotion tag in this exact format:
- [EMOTION:emotion_name]
- Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging
- Example response:
- Great job! That's the correct pronunciation. Keep practicing and you'll get even better!
- [EMOTION:encouraging]"""
-
- print(f"调用 LLM 流式: {settings.llm_model}")
-
- # 调用LLM流式API
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=[
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": user_text},
- ],
- temperature=0.7,
- max_tokens=500,
- stream=True, # 启用流式
- )
-
- buffer = ""
- full_response = ""
- sentence_count = 0
-
- for chunk in response:
- if chunk.choices[0].delta.content:
- content = chunk.choices[0].delta.content
- buffer += content
- full_response += content
-
- # 检测句子边界(完整的句子)
- if self._is_complete_sentence(buffer):
- sentence = buffer.strip()
- sentence_count += 1
- print(f"LLM 流式输出句子{sentence_count}: {sentence}")
- yield (sentence, "neutral") # 情感稍后统一提取
- buffer = ""
-
- # 处理剩余内容和提取情感
- emotion: EmotionType = "neutral"
-
- # 从完整响应中提取情感
- 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"]
- if emotion_str in valid_emotions:
- emotion = emotion_str
- # 移除情感标签部分
- full_response = full_response[:emotion_start].strip()
-
- # 处理buffer中的剩余内容(已输出句子之后的部分)
- remaining = buffer.strip()
- if remaining and not remaining.startswith("[EMOTION"):
- sentence_count += 1
- print(f"LLM 流式输出最后{sentence_count}: {remaining}")
- yield (remaining, emotion)
- elif not remaining and sentence_count == 0:
- # 如果一个句子都没输出,输出完整内容
- if full_response:
- print(f"LLM 流式输出完整: {full_response}")
- yield (full_response, emotion)
-
- print(f"LLM 完整响应: {full_response}")
- print(f"提取情感: {emotion}")
- print(f"========== LLM 流式成功 ==========\n")
-
- except Exception as e:
- print(f"========== LLM 流式错误 ==========")
- print(f"错误: {e}")
- import traceback
- traceback.print_exc()
- print(f"========== LLM 流式错误结束 ==========\n")
- 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个字符(避免"Hi."这种过短的)
- if len(text) < 5:
- return False
-
- return True
-
- async def generate_response(
- self, user_text: str
- ) -> tuple[str, EmotionType]:
- """
- 生成对话回复
-
- Args:
- user_text: 用户输入文本
-
- Returns:
- (response_text, emotion): 回复文本和情感
- """
- try:
- print(f"\n========== LLM 开始 ==========")
- print(f"用户输入: {user_text}")
-
- # 构建System Prompt,要求返回情感标签
- system_prompt = """You are a friendly English learning assistant.
- Please respond to the user in English to help them practice.
- Be encouraging and supportive.
- At the end of your response, on a new line, add ONLY the emotion tag in this exact format:
- [EMOTION:emotion_name]
- Available emotions: neutral, happy, sad, excited, angry, surprised, thoughtful, encouraging
- Example response:
- Great job! That's the correct pronunciation. Keep practicing and you'll get even better!
- [EMOTION:encouraging]"""
-
- print(f"调用 LLM: {settings.llm_model}")
-
- # 调用LLM
- response = self.openai_client.chat.completions.create(
- model=settings.llm_model,
- messages=[
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": user_text},
- ],
- temperature=0.7,
- max_tokens=500,
- )
-
- full_response = response.choices[0].message.content or ""
- print(f"LLM 原始响应: {full_response}")
-
- # 提取情感标签
- 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"]
- if emotion_str in valid_emotions:
- emotion = emotion_str
- print(f"提取到情感标签: {emotion}")
- # 移除情感标签(不发送给TTS)
- response_text = full_response[:emotion_start].strip()
- print(f"已移除情感标签,纯文本内容: {response_text}")
-
- # 如果没有文本内容,使用默认回复
- if not response_text:
- response_text = "I understand. Please tell me more!"
- emotion = "neutral"
-
- print(f"✅ 将发送给TTS的文本: {response_text}")
- print(f"✅ 情感: {emotion}")
- print(f"========== LLM 成功 ==========\n")
-
- return (response_text, emotion)
-
- except Exception as e:
- print(f"========== LLM 错误 ==========")
- print(f"错误: {e}")
- import traceback
- traceback.print_exc()
- print(f"========== LLM 错误结束 ==========\n")
- 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: 音频数据块
- """
- try:
- print(f"\n========== TTS 开始 ==========")
- print(f"要合成的文本: {text}")
- print(f"情感: {emotion}")
-
- # 根据情感选择音色或参数
- voice = self._get_voice_for_emotion(emotion)
- print(f"使用音色: {voice}")
- print(f"TTS模型: {settings.tts_model}")
-
- # 在线程池中执行同步调用,避免事件循环冲突
- # 添加重试机制以应对网络不稳定
- def sync_tts():
- max_retries = 3
- retry_delay = 1 # 秒
-
- for attempt in range(max_retries):
- try:
- return dashscope.audio.qwen_tts.SpeechSynthesizer.call(
- model=settings.tts_model,
- text=text,
- voice=voice,
- )
- except Exception as e:
- if attempt < max_retries - 1:
- print(f"TTS调用失败(尝试 {attempt + 1}/{max_retries}): {e}")
- print(f"等待{retry_delay}秒后重试...")
- import time
- time.sleep(retry_delay)
- retry_delay *= 2 # 指数退避
- else:
- raise
-
- print("调用 DashScope TTS API...")
- # 调用TTS API
- response = await asyncio.to_thread(sync_tts)
-
- print(f"TTS API 响应类型: {type(response)}")
-
- # 获取音频数据
- # response.output 包含 audio 字典,其中有 data、url、id、expires_at
- audio_bytes = None
-
- if hasattr(response, 'output'):
- output = response.output
- print(f"output 类型: {type(output)}")
-
- # 打印finish_reason用于调试
- if isinstance(output, dict):
- print(f"finish_reason: {output.get('finish_reason', 'N/A')}")
- audio_info = output.get('audio', {})
- elif hasattr(output, 'finish_reason'):
- print(f"finish_reason: {output.finish_reason}")
- audio_info = output.get('audio', {}) if isinstance(output, dict) else getattr(output, 'audio', {})
- else:
- audio_info = {}
-
- # 检查是否有URL(新版API返回URL)
- if isinstance(audio_info, dict) and 'url' in audio_info and audio_info['url']:
- audio_url = audio_info['url']
- print(f"从URL下载音频: {audio_url}")
-
- # 下载音频数据
- import aiohttp
- async with aiohttp.ClientSession() as session:
- async with session.get(audio_url) as resp:
- if resp.status == 200:
- audio_bytes = await resp.read()
- print(f"音频下载成功,大小: {len(audio_bytes)} 字节")
- else:
- raise Exception(f"下载音频失败,HTTP状态码: {resp.status}")
-
- # 检查是否有直接的数据(旧版API)
- elif isinstance(audio_info, dict) and 'data' in audio_info and audio_info['data']:
- audio_data = audio_info['data']
- if isinstance(audio_data, bytes):
- audio_bytes = audio_data
- print(f"音频数据(bytes): {len(audio_bytes)} 字节")
- elif isinstance(audio_data, str):
- audio_bytes = base64.b64decode(audio_data)
- print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
-
- # 如果audio_info直接是bytes或str
- elif isinstance(audio_info, bytes):
- audio_bytes = audio_info
- print(f"音频数据(bytes): {len(audio_bytes)} 字节")
- elif isinstance(audio_info, str):
- audio_bytes = base64.b64decode(audio_info)
- print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
-
- if audio_bytes:
-
- # 将音频数据分块返回
- chunk_size = 3200 # 每块3200字节
- chunk_count = (len(audio_bytes) + chunk_size - 1) // chunk_size
- print(f"将分为 {chunk_count} 个数据块发送")
-
- for i in range(0, len(audio_bytes), chunk_size):
- chunk = audio_bytes[i:i + chunk_size]
- yield chunk
- await asyncio.sleep(0) # 让出控制权
-
- print(f"========== TTS 成功 ==========\n")
- else:
- error_msg = "TTS API未返回音频数据"
- print(f"错误: {error_msg}")
- print(f"========== TTS 失败 ==========\n")
- raise Exception(error_msg)
-
- except Exception as e:
- print(f"========== TTS 错误 ==========")
- print(f"错误: {e}")
- import traceback
- traceback.print_exc()
- print(f"========== TTS 错误结束 ==========\n")
- raise
-
- def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
- """根据情感选择音色"""
- # 情感到音色的映射
- voice_map = {
- "neutral": "Cherry",
- "happy": "Cherry",
- "sad": "Cherry",
- "excited": "Cherry",
- "angry": "Cherry",
- "surprised": "Cherry",
- "thoughtful": "Cherry",
- "encouraging": "Cherry",
- }
- return voice_map.get(emotion, settings.tts_voice)
- # 全局AI服务实例
- ai_service = AIService()
|