From a0618c961dec59eb38fd8d2390ffcb803ad01d58 Mon Sep 17 00:00:00 2001 From: Daniil Klimov Date: Tue, 14 Apr 2026 14:40:52 +0300 Subject: [PATCH] Add russian translate --- requirements.txt | 5 +++ satellite/llm.py | 28 +++++++-------- satellite/modes.py | 13 +++---- satellite/text.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9243be7..454ffb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,8 @@ elevenlabs # Wake word openwakeword + +# Русская морфология для нормализации текста под TTS +num2words +pymorphy3 +pymorphy3-dicts-ru diff --git a/satellite/llm.py b/satellite/llm.py index c72d2b0..ff76793 100644 --- a/satellite/llm.py +++ b/satellite/llm.py @@ -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 diff --git a/satellite/modes.py b/satellite/modes.py index 894ff2a..e901a57 100644 --- a/satellite/modes.py +++ b/satellite/modes.py @@ -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() diff --git a/satellite/text.py b/satellite/text.py index 7a9e3ae..951138d 100644 --- a/satellite/text.py +++ b/satellite/text.py @@ -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) # одиночный слэш — как союз "или"