Add russian translate

This commit is contained in:
2026-04-14 14:40:52 +03:00
parent cc8cbefe18
commit a0618c961d
4 changed files with 110 additions and 24 deletions

View File

@@ -15,3 +15,8 @@ elevenlabs
# Wake word
openwakeword
# Русская морфология для нормализации текста под TTS
num2words
pymorphy3
pymorphy3-dicts-ru

View File

@@ -27,11 +27,11 @@ def is_reset_command(text: str) -> bool:
def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
"""
Отправляет запрос к OpenClaw gateway как полноценный агент.
История хранится на стороне gateway (session_key).
conv параметр сохранён для обратной совместимости, не используется.
"""
"""Отправляет запрос к OpenClaw gateway и озвучивает ответ."""
def _maybe_speak(t: str):
if t.strip():
speak(t, agent_id)
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
gateway_url = cfg["gateway_url"]
session = cfg["session"]
@@ -61,21 +61,21 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
msg = "Не могу связаться с сервером, попробуй ещё раз."
print(f"⚠️ {msg}")
play_error_sound()
speak(msg, agent_id)
_maybe_speak(msg)
return msg
except requests.Timeout:
log.exception("Gateway таймаут")
msg = "Сервер не ответил вовремя, попробуй ещё раз."
print(f"⚠️ {msg}")
play_error_sound()
speak(msg, agent_id)
_maybe_speak(msg)
return msg
except requests.HTTPError:
log.exception(f"Gateway HTTP ошибка {resp.status_code}")
msg = "Ошибка сервера, попробуй ещё раз."
print(f"⚠️ Gateway {resp.status_code}: {resp.text}")
play_error_sound()
speak(msg, agent_id)
_maybe_speak(msg)
return msg
full_text = ""
@@ -99,8 +99,7 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
last_punct = find_sentence_end(buffer, min_len=120)
if last_punct > -1:
sentence = clean_for_speech(buffer[:last_punct + 1])
if sentence.strip():
speak(sentence, agent_id)
_maybe_speak(sentence)
buffer = buffer[last_punct + 1:].lstrip()
except (json.JSONDecodeError, KeyError, IndexError):
@@ -111,18 +110,15 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
if not full_text:
msg = "Не получил ответ, попробуй ещё раз."
speak(msg, agent_id)
_maybe_speak(msg)
return msg
result = clean_for_speech(full_text)
if TTS_MODE == "full":
if result.strip():
speak(result, agent_id)
_maybe_speak(result)
else:
if buffer.strip():
tail = clean_for_speech(buffer)
if tail:
speak(tail, agent_id)
_maybe_speak(clean_for_speech(buffer))
return result

View File

@@ -6,6 +6,9 @@ from .audio import record
from .tts import speak, stop_speaking
from .llm import ask_agent_stream, is_reset_command
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
def _handle_reset(text: str, agent_id: str) -> bool:
"""Команда сброса — на сервере OpenClaw сессия рулится session_key, клиент только сообщает."""
if is_reset_command(text):
@@ -17,8 +20,7 @@ def _handle_reset(text: str, agent_id: str) -> bool:
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит.
Выходит когда в течение MAX_DURATION не было речи."""
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит."""
while True:
text = record()
if not text:
@@ -32,8 +34,6 @@ def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
response = ask_agent_stream(text, agent_id=agent_id)
print(f"🤖 {agent_name}: {response}\n")
# после ответа — следующая итерация с новым record()
# record() сам гасит эхо через ECHO_WARMUP
def run_with_enter():
@@ -76,8 +76,6 @@ def run_with_porcupine():
stream = audio.open(rate=16000, channels=1, format=pyaudio.paInt16,
input=True, frames_per_buffer=1280)
print("✅ Слушаю через OpenWakeWord...")
print("\nСкажи 'Космо'...\n")
# print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси
try:
@@ -90,8 +88,7 @@ def run_with_porcupine():
if cosmo_score > 0.1:
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
if cosmo_score > 0.5:
print("✅ Услышал 'Космо'!")
if cosmo_score > WAKE_THRESHOLD:
stream.stop_stream()
_conversation_loop("cosmo", "Cosmo")
cosmo_model.reset()

View File

@@ -1,5 +1,79 @@
import re
from num2words import num2words
import pymorphy3
_morph = pymorphy3.MorphAnalyzer()
# Падеж по предлогу перед временем
_PREP_CASE = {
"с": "gent", "со": "gent", "до": "gent", "от": "gent", "после": "gent", "около": "gent",
"к": "datv", "ко": "datv",
"в": "accs", "во": "accs", "на": "accs", "через": "accs", "за": "accs",
"перед": "ablt", "между": "ablt",
"о": "loct", "об": "loct", "при": "loct",
}
def _inflect_num(n: int, case: str, gender: str = "masc") -> str:
"""Число → слова в нужном падеже (одиннадцать → одиннадцати)."""
words = num2words(n, lang="ru", to="cardinal")
if case == "nomn":
return words
parts = words.split()
out = []
for w in parts:
p = _morph.parse(w)[0]
infl = p.inflect({case})
out.append(infl.word if infl else w)
return " ".join(out)
def _hours_word(n: int, case: str) -> str:
"""Правильная форма 'час': 1 час, 2-4 часа, 5+ часов — с учётом падежа."""
last2 = n % 100
last1 = n % 10
if 11 <= last2 <= 14:
base = "часов"
elif last1 == 1:
base = "час"
elif 2 <= last1 <= 4:
base = "часа"
else:
base = "часов"
if case in ("nomn", "accs"):
return base
p = _morph.parse(base)[0]
infl = p.inflect({case, "plur" if base == "часов" else "sing"})
return infl.word if infl else base
def _minutes_word(n: int, case: str) -> str:
last2 = n % 100
last1 = n % 10
if 11 <= last2 <= 14:
base = "минут"
elif last1 == 1:
base = "минута"
elif 2 <= last1 <= 4:
base = "минуты"
else:
base = "минут"
if case in ("nomn", "accs"):
return base
p = _morph.parse(base)[0]
infl = p.inflect({case})
return infl.word if infl else base
def _format_time(h: int, mm: int, case: str) -> str:
h_words = _inflect_num(h, case, gender="masc")
out = f"{h_words} {_hours_word(h, case)}"
if mm:
m_words = _inflect_num(mm, case, gender="femn")
out += f" {m_words} {_minutes_word(mm, case)}"
return out
# Единицы измерения со слэшем — раскрываем до чтения слэша
UNIT_SLASH = [
@@ -29,6 +103,20 @@ def clean_for_speech(text: str) -> str:
text = re.sub(r'(^|\s)-(\d)', r'\1минус \2', text)
text = re.sub(r'±(\d)', r'плюс-минус \1', text)
# время "HH:MM" → слова в падеже по предшествующему предлогу
def _time_repl(m):
prep = (m.group(1) or "").lower()
h, mm = int(m.group(2)), int(m.group(3))
if not (0 <= h <= 23 and 0 <= mm <= 59):
return m.group(0)
case = _PREP_CASE.get(prep, "nomn")
words = _format_time(h, mm, case)
return f"{prep} {words}" if prep else words
text = re.sub(
r'(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b',
_time_repl, text, flags=re.IGNORECASE,
)
# дроби и отношения "12/15" → "12 из 15", "5/10" → "5 из 10"
text = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1 из \2', text)
# одиночный слэш — как союз "или"