add banbanmini backend

This commit is contained in:
HycJack
2026-03-24 15:04:36 +08:00
parent 0d0f995dc2
commit 7510ca6df1
197 changed files with 13008 additions and 0 deletions

View File

View File

@@ -0,0 +1,210 @@
import pycld2 as cld2
from utils.logger import session_logger
import re
import unicodedata
import asyncio
import concurrent.futures
class LanguageDetector:
LANGUAGE_UNICODE_RANGES = {
'zh': [ # 中文
(0x4E00, 0x9FFF), # CJK统一汉字
(0x3400, 0x4DBF), # CJK扩展A
(0xF900, 0xFAFF), # CJK兼容汉字
]
}
LANGUAGE_FEATURES = {
'zh': ['', '', '', '', '', '', '', '', '', '', '', '', '什么', '怎么', '为什么',
'好的', '', '', '', '', '', '', '', '', '', '您好', '谢谢', '对不起', '没关系',
'好啊', '', '可以', '不行', '不可以', '是的', '不是', '', '不要', '', '能不能', '请问',
'', '帮我', '告诉我', '知道', '不知道', '明白', '不明白', '', '不对', '听见', '听不见'],
'en': ['i', 'you', 'he', 'she', 'we', 'they', 'this', 'that', 'what', 'how', 'why', 'where', 'when',
'okay', 'yes', 'no', 'yeah', 'oh', 'hey', 'hi', 'hello', 'thanks', 'thank', 'sorry', 'please',
'can', 'could', 'would', 'will', 'want', 'need', 'like', 'tell', 'know', 'think', 'mean',
'say', 'said', 'help', 'hear', 'listen', 'understand', 'right', 'wrong', 'good', 'bad',
'ok', 'alright', 'excuse', 'pardon', 'well', 'sure', 'maybe', 'perhaps'],
'de': ['ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'sie', 'das', 'was', 'wie', 'warum', 'wo', 'wann',
'ja', 'nein', 'okay', 'hallo', 'danke', 'bitte', 'entschuldigung', 'tschüss', 'guten', 'tag',
'morgen', 'abend', 'kann', 'könnte', 'würde', 'möchte', 'wollen', 'brauchen', 'verstehe',
'weiß', 'denke', 'meine', 'helfen', 'hören', 'zuhören', 'richtig', 'falsch', 'gut', 'schlecht',
'alles', 'klar', 'natürlich', 'vielleicht'],
'fr': ['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'ce', 'ça', 'quoi', 'comment', 'pourquoi',
'', 'quand', 'oui', 'non', 'salut', 'bonjour', 'merci', 'pardon', 'excusez', 'au revoir',
'peux', 'pouvez', 'voulez', 'veux', 'besoin', 'comprends', 'sais', 'pense', 'dire', 'aider',
'entendre', 'écouter', 'correct', 'incorrect', 'bon', 'bien', 'mauvais', 'mal',
'd\'accord', 'bien sûr', 'peut-être'],
'es': ['yo', '', 'él', 'ella', 'nosotros', 'vosotros', 'ellos', 'ellas', 'esto', 'eso', 'qué', 'cómo',
'por qué', 'dónde', 'cuándo', '', 'no', 'hola', 'gracias', 'perdón', 'disculpe', 'adiós',
'puede', 'puedes', 'quiere', 'quiero', 'necesito', 'entiendo', '', 'pienso', 'digo', 'ayudar',
'oír', 'escuchar', 'correcto', 'incorrecto', 'bueno', 'malo', 'vale', 'claro', 'quizás', 'tal vez']
}
LANGUAGE_PUNCTUATION = {
'zh': ['', '', '', '', '', '', '', '"', '"', '', '', '', '', '', '', '——', '……', ''],
'en': [], # 英语标点大多通用,不作为特征
'de': ['', '"'], # 德语特有引号
'fr': ['«', '»', ''], # 法语特有引号和省略号
'es': ['¿', '¡'] # 西班牙语特有的倒置问号和感叹号
}
SPEECH_FILLERS = {
'zh': ['', '', '', '', '', '那个', '这个', '就是', '其实', '然后', '所以', '但是'],
'en': ['um', 'uh', 'er', 'ah', 'like', 'you know', 'i mean', 'well', 'so', 'anyway', 'actually'],
'de': ['äh', 'ähm', 'hmm', 'na ja', 'also', 'sozusagen', 'quasi', 'naja', 'eigentlich', 'tja'],
'fr': ['euh', 'ben', 'bah', 'eh bien', 'bon', 'alors', 'donc', 'voilà', 'en fait', 'quoi'],
'es': ['eh', 'este', 'em', 'pues', 'bueno', 'o sea', 'vale', 'vamos', 'mira', 'entonces']
}
SPEECH_PATTERNS = {
'zh': [r'^(你好|您好|喂|在吗|请问|那个|这个|帮我|麻烦|请|要|需要)'],
'en': [r'^(hello|hi|hey|excuse me|sorry|please|can you|could you|would you|I want|I need)'],
'de': [r'^(hallo|guten tag|entschuldigung|bitte|können sie|könntest du|ich möchte|ich brauche)'],
'fr': [r'^(bonjour|salut|excusez-moi|pardon|s\'il vous plaît|pouvez-vous|pourriez-vous|je voudrais|j\'ai besoin)'],
'es': [r'^(hola|buenos días|perdón|disculpe|por favor|puede usted|puedes|quiero|necesito)']
}
_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=4,
thread_name_prefix="lang_detector"
)
@staticmethod
async def detect_language(text):
try:
SUPPORTED_LANGUAGES = ["zh", "en", "fr", "de", "es"]
if not text:
return "en" # 默认返回英文
cleaned_text = ' '.join(text.lower().split())
loop = asyncio.get_running_loop()
speech_pattern_lang = await loop.run_in_executor(
LanguageDetector._executor,
LanguageDetector._detect_by_speech_patterns,
cleaned_text
)
if speech_pattern_lang != "unknown":
return speech_pattern_lang
char_based_lang = await loop.run_in_executor(
LanguageDetector._executor,
LanguageDetector._detect_by_characters,
cleaned_text
)
if char_based_lang != "unknown":
return char_based_lang
feature_based_lang = await loop.run_in_executor(
LanguageDetector._executor,
LanguageDetector._detect_by_features,
cleaned_text
)
if feature_based_lang != "unknown":
return feature_based_lang
try:
is_reliable, _, details = cld2.detect(cleaned_text)
if is_reliable and details[0][1] in SUPPORTED_LANGUAGES:
return details[0][1]
except:
pass
for ch in cleaned_text:
if '\u4e00' <= ch <= '\u9fff': # 检测中文字符
return "zh"
return "en" # 默认返回英语
except Exception as e:
session_logger.error("system", "language_detector", f"语言检测错误: {str(e)}")
return "en" # 出错时默认使用英文
@staticmethod
def _detect_by_speech_patterns(text: str) -> str:
"""检测常见的语音对话开头模式"""
for lang, patterns in LanguageDetector.SPEECH_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, text):
return lang
return "unknown"
@staticmethod
def _detect_by_characters(text: str) -> str:
"""基于字符分布的语言检测,特别适合短文本"""
text = text.strip()
text = text.lower()
text_len = max(1, len(text))
char_counts = {lang: 0 for lang in LanguageDetector.LANGUAGE_UNICODE_RANGES}
for char in text:
code_point = ord(char)
for lang, ranges in LanguageDetector.LANGUAGE_UNICODE_RANGES.items():
for start, end in ranges:
if start <= code_point <= end:
char_counts[lang] += 1
for lang, count in char_counts.items():
ratio = count / text_len
if lang == 'zh' and ratio > 0.12:
return 'zh'
latin_chars = sum(1 for c in text if unicodedata.category(c).startswith('L'))
latin_ratio = latin_chars / text_len
if latin_ratio > 0.4: # 降低拉丁字符检测阈值,更灵敏地捕捉语音转文本
if any(c in text for c in "äöüß"):
return "de" # 德语特有字符
elif any(c in text for c in "éèêëàâçùûüÿôœæ"):
return "fr" # 法语特有字符
elif any(c in text for c in "áéíóúñ¿¡"):
return "es" # 西班牙语特有字符
if "¿" in text or "¡" in text:
return "es" # 西班牙语特有标点
elif "«" in text or "»" in text:
return "fr" # 法语引号
return "en" # 默认英语
return "unknown"
@staticmethod
def _detect_by_features(text: str) -> str:
"""基于特征词和语气词的语言检测,针对口语场景优化"""
text_lower = text.lower()
matches = {lang: 0 for lang in LanguageDetector.LANGUAGE_FEATURES}
for lang, features in LanguageDetector.LANGUAGE_FEATURES.items():
for word in features:
if lang == 'zh': # 中文不需要空格分隔
if word in text_lower:
matches[lang] += 2
elif f" {word} " in f" {text_lower} ":
matches[lang] += 1
for lang, fillers in LanguageDetector.SPEECH_FILLERS.items():
for filler in fillers:
if lang == 'zh':
count = text_lower.count(filler)
else:
count = text_lower.count(f" {filler} ")
matches[lang] += count * 3
for lang, puncts in LanguageDetector.LANGUAGE_PUNCTUATION.items():
for punct in puncts:
matches[lang] += text.count(punct) * 5
nonzero_matches = {lang: score for lang, score in matches.items() if score > 0}
if nonzero_matches:
best_lang = max(nonzero_matches.items(), key=lambda x: x[1])
if best_lang[1] > 0:
return best_lang[0]
return "unknown"

View File

@@ -0,0 +1,92 @@
import os
from typing import Optional
from loguru import logger
class SessionLogger:
def __init__(self):
self.is_main_process = os.environ.get("UVICORN_WID", "0") == "0"
# 创建日志目录
os.makedirs("logs", exist_ok=True)
# 配置loguru
logger.remove() # 移除默认handler
# 控制台输出
logger.add(
lambda msg: print(msg, end=""),
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> - <level>{level}</level> - [设备: {extra[device_id]}] [会话: {extra[session_id]}] {message}",
level="INFO"
)
# 文件输出 - 持久化保存
logger.add(
"logs/talkingq_{time:YYYY-MM-DD}.log",
format="{time:YYYY-MM-DD HH:mm:ss} - {level} - [设备: {extra[device_id]}] [会话: {extra[session_id]}] {message}",
rotation="00:00", # 每天轮转
retention="30 days", # 保留30天
compression="zip", # 压缩旧日志
level="INFO"
)
self.logger = logger
def log(
self,
level: str,
device_id: Optional[str],
session_id: Optional[str],
message: str,
**kwargs
):
if isinstance(device_id, str) and device_id.startswith("{"):
try:
import json
device_info = json.loads(device_id)
device_id = device_info.get("device_id", device_id)
except:
pass # 如果解析失败,保持原样
# 绑定额外信息到logger
bound_logger = self.logger.bind(
device_id=device_id or "unknown",
session_id=session_id or "unknown"
)
# 根据level调用对应方法
if level.upper() == "INFO":
bound_logger.info(message)
elif level.upper() == "ERROR":
bound_logger.error(message)
elif level.upper() == "WARNING":
bound_logger.warning(message)
else:
bound_logger.log(level, message)
def system_log(self, level: str, session_id: str, message: str):
if self.is_main_process:
self.log(level, "system", session_id, message)
def info(self, device_id: Optional[str], session_id: Optional[str], message: str):
self.log("INFO", device_id, session_id, message)
def system_info(self, session_id: str, message: str):
self.system_log("INFO", session_id, message)
def error(
self,
device_id: Optional[str],
session_id: Optional[str],
message: str,
**kwargs
):
self.log("ERROR", device_id, session_id, message, **kwargs)
def warning(
self, device_id: Optional[str], session_id: Optional[str], message: str
):
self.log("WARNING", device_id, session_id, message)
session_logger = SessionLogger()

View File

@@ -0,0 +1,145 @@
import re
SENTENCE_ENDINGS = set("。!?!?;:.。\n")
PUNCTUATION_MARKS = set('。!?!?.,;:~…—_\'"""》《)》《<>()[]{}【】\'、~~¡¿äöüßáéíóúüñç\n')
APOSTROPHE_SUFFIXES = {'s', 't', 've', 'll', 're', 'd', 'm'}
LANGUAGE_CONTRACTIONS = {
'fr': ["l'", "d'", "s'", "qu'", "c'", "j'", "n'", "m'", "t'", "jusqu'"],
'de': ["geht's", "gibt's"],
'es': ["el-", "del", "al"],
'ms': ["-lah", "-kah", "-nya"]
}
def is_numbered_list_item(text):
pattern = r"^\s*\d+\.\s"
return bool(re.search(pattern, text))
def split_into_sentences(text):
return _custom_split_sentences(text)
def _custom_split_sentences(text):
text = re.sub(r"([。!?!?;])", r"\1\n", text)
sentences = []
current_sentence = ""
i = 0
while i < len(text):
current_sentence += text[i]
if text[i] in SENTENCE_ENDINGS and not is_special_context(text, i):
if current_sentence.strip():
sentences.append(current_sentence)
current_sentence = ""
i += 1
if current_sentence.strip():
sentences.append(current_sentence)
merged_sentences = []
temp = ""
for s in sentences:
s_stripped = s.strip()
if len(s_stripped) < 5 and not any(p in s for p in "。!?!?"):
temp += s
else:
if temp:
merged_sentences.append(temp + s)
temp = ""
else:
merged_sentences.append(s)
if temp:
merged_sentences.append(temp)
final_sentences = []
for idx, sentence in enumerate(merged_sentences):
sentence_start = sentence.strip()
if idx > 0:
prev_sentence_end = merged_sentences[idx-1].strip()
if (sentence_start.startswith("s ") and prev_sentence_end.endswith("'")) or \
any(sentence_start.startswith(suffix + " ") for suffix in APOSTROPHE_SUFFIXES) and prev_sentence_end.endswith("'"):
final_sentences[-1] += sentence
continue
if any(sentence_start.startswith(contraction) for contraction in LANGUAGE_CONTRACTIONS['fr']):
final_sentences[-1] += sentence
continue
if (any(prev_sentence_end.endswith(contraction) for contraction in LANGUAGE_CONTRACTIONS['de']) or
any(sentence_start.startswith(contraction) for contraction in LANGUAGE_CONTRACTIONS['es']) or
any(prev_sentence_end.endswith(contraction) for contraction in LANGUAGE_CONTRACTIONS['ms'])):
final_sentences[-1] += sentence
continue
if sentence_start.startswith("¿") or sentence_start.startswith("¡"):
final_sentences.append(sentence)
continue
final_sentences.append(sentence)
return final_sentences
def is_special_context(text, pos):
"""判断是否是特殊上下文,不应该在此处断句"""
if text[pos] == "." and pos > 0 and pos < len(text) - 1:
if text[pos - 1].isdigit() and text[pos + 1].isdigit():
return True
if text[pos] == "'" and pos > 0 and pos < len(text) - 1:
if text[pos - 1].isalpha(): # 前面是字母
for suffix in APOSTROPHE_SUFFIXES:
if pos + len(suffix) <= len(text) and text[pos+1:pos+1+len(suffix)] == suffix:
if pos + len(suffix) + 1 >= len(text) or not text[pos+1+len(suffix)].isalpha():
return True
if text[pos] == "." and pos > 0 and pos < len(text) - 2:
if text[pos - 1].isupper() and text[pos + 1].isupper():
return True
if text[pos] == "." and pos > 0 and pos < len(text) - 1:
if not text[pos - 1].isspace() and not text[pos + 1].isspace():
left_context = text[max(0, pos-15):pos]
if "www." in left_context or "http" in left_context or "@" in left_context:
return True
if text[pos] == "." and pos > 0 and pos < len(text) - 1:
if pos + 2 < len(text) and text[pos:pos+3] == "...":
return True
if text[pos] == "'" and pos > 0 and pos < len(text) - 1:
if pos > 1 and text[pos-2:pos+1] in ["l'", "d'", "c'", "j'", "n'", "m'", "t'"]:
return True
if text[pos] == "." and pos > 0:
if pos >= 3 and text[pos-3:pos] in ["bzw", "usw", "etc"]:
return True
if text[pos] in "¿¡" and pos < len(text) - 1:
return True
if text[pos] == "-" and pos > 0 and pos < len(text) - 2:
if text[pos+1:pos+4] in ["lah", "kah", "nya"]:
return True
return False
def should_skip_tts(text_chunk):
"""判断一段文本是否应该跳过TTS处理"""
if not text_chunk.strip():
return True
if is_numbered_list_item(text_chunk) and len(text_chunk.strip()) <= 5:
return True
if all(c in PUNCTUATION_MARKS for c in text_chunk.strip()):
return True
if len(text_chunk.strip()) <= 2 and not any(c.isalnum() for c in text_chunk):
return True
return False