Compare commits
8 Commits
feature/op
...
cc9de661cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9de661cc | ||
|
|
182e7875ab | ||
| a0618c961d | |||
| cc8cbefe18 | |||
|
|
24c8e38be6 | ||
| 09d22177cd | |||
| 0494c24c47 | |||
| 28cccbdac1 |
@@ -35,4 +35,5 @@ FOLLOWUP_TIMEOUT=8
|
||||
# Логирование
|
||||
LOG_FILE=errors.log
|
||||
|
||||
VOICE_SESSION_KEY=agent:main:voice:home
|
||||
COSMO_SESSION_KEY=agent:voice:voice:home
|
||||
LUSYA_SESSION_KEY=agent:wife:voice:home
|
||||
|
||||
@@ -15,3 +15,8 @@ elevenlabs
|
||||
|
||||
# Wake word
|
||||
openwakeword
|
||||
|
||||
# Русская морфология для нормализации текста под TTS
|
||||
num2words
|
||||
pymorphy3
|
||||
pymorphy3-dicts-ru
|
||||
|
||||
@@ -21,17 +21,30 @@ RESET_PATTERNS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Фразы-заглушки которые агент генерирует ДО вызова инструмента
|
||||
FILLER_PATTERNS = re.compile(
|
||||
r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю'
|
||||
r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник'
|
||||
r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
def strip_fillers(text: str) -> str:
|
||||
return FILLER_PATTERNS.sub('', text).strip()
|
||||
|
||||
|
||||
|
||||
|
||||
def is_reset_command(text: str) -> bool:
|
||||
return bool(RESET_PATTERNS.search(text))
|
||||
|
||||
|
||||
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 +74,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,9 +112,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():
|
||||
print(f"🔊 Говорю: {sentence}")
|
||||
speak(sentence, agent_id)
|
||||
_maybe_speak(sentence)
|
||||
buffer = buffer[last_punct + 1:].lstrip()
|
||||
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
@@ -112,19 +123,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)
|
||||
result = clean_for_speech(strip_fillers(full_text))
|
||||
|
||||
if TTS_MODE == "full":
|
||||
if result.strip():
|
||||
print(f"🔊 Говорю: {result}")
|
||||
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
|
||||
|
||||
@@ -3,27 +3,15 @@ import sys
|
||||
|
||||
from .config import GATEWAY_URL, AGENT, log
|
||||
from .audio import record
|
||||
from .tts import speak, stop_speaking
|
||||
from .llm import ask_agent_stream, Conversation, is_reset_command
|
||||
from .tts import speak, stop_speaking, is_speaking
|
||||
from .llm import ask_agent_stream, is_reset_command
|
||||
|
||||
# Персистентные сессии — одна на день для каждого агента
|
||||
_sessions: dict[str, Conversation] = {}
|
||||
|
||||
|
||||
def _get_session(agent_id: str) -> Conversation:
|
||||
"""Возвращает текущую сессию, создаёт новую если день сменился"""
|
||||
conv = _sessions.get(agent_id)
|
||||
if conv is None or conv.is_expired():
|
||||
conv = Conversation(agent_id=agent_id)
|
||||
_sessions[agent_id] = conv
|
||||
print(f"🆕 Новая сессия для {agent_id}")
|
||||
return conv
|
||||
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
|
||||
|
||||
|
||||
def _handle_reset(text: str, agent_id: str) -> bool:
|
||||
"""Проверяет команду сброса. Возвращает True если сброс произошёл."""
|
||||
"""Команда сброса — на сервере OpenClaw сессия рулится session_key, клиент только сообщает."""
|
||||
if is_reset_command(text):
|
||||
_sessions[agent_id] = Conversation(agent_id=agent_id)
|
||||
msg = "Начинаю новую сессию."
|
||||
print(f"🔄 {msg}")
|
||||
speak(msg, agent_id)
|
||||
@@ -32,10 +20,7 @@ def _handle_reset(text: str, agent_id: str) -> bool:
|
||||
|
||||
|
||||
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
||||
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит.
|
||||
Выходит когда в течение MAX_DURATION не было речи."""
|
||||
conv = _get_session(agent_id)
|
||||
|
||||
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит."""
|
||||
while True:
|
||||
text = record()
|
||||
if not text:
|
||||
@@ -45,13 +30,10 @@ def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
||||
print(f"📝 Ты → {agent_name}: {text}")
|
||||
|
||||
if _handle_reset(text, agent_id):
|
||||
conv = _get_session(agent_id)
|
||||
continue
|
||||
|
||||
response = ask_agent_stream(text, conv=conv, agent_id=agent_id)
|
||||
response = ask_agent_stream(text, agent_id=agent_id)
|
||||
print(f"🤖 {agent_name}: {response}\n")
|
||||
# после ответа — следующая итерация с новым record()
|
||||
# record() сам гасит эхо через ECHO_WARMUP
|
||||
|
||||
|
||||
def run_with_enter():
|
||||
@@ -94,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:
|
||||
@@ -108,8 +88,13 @@ 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:
|
||||
if is_speaking():
|
||||
# Barge-in: прерываем TTS
|
||||
print("✋ Barge-in: прерываю ответ")
|
||||
stop_speaking()
|
||||
cosmo_model.reset()
|
||||
continue
|
||||
stream.stop_stream()
|
||||
_conversation_loop("cosmo", "Cosmo")
|
||||
cosmo_model.reset()
|
||||
|
||||
@@ -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)
|
||||
# одиночный слэш — как союз "или"
|
||||
|
||||
@@ -70,19 +70,20 @@ def _speak_elevenlabs(text: str, agent_id: str):
|
||||
return
|
||||
|
||||
voice_settings = VoiceSettings(
|
||||
stability=0.65, # ниже = живее интонация (для multilingual_v2)
|
||||
similarity_boost=0.6,
|
||||
style=0.45, # выше = эмоциональнее
|
||||
stability=0.5, # ниже = живее интонация (для multilingual_v2)
|
||||
similarity_boost=0.8,
|
||||
style=0.0, # выше = эмоциональнее
|
||||
use_speaker_boost=True,
|
||||
speed=1.05
|
||||
speed=1.0
|
||||
)
|
||||
|
||||
audio_stream = client.text_to_speech.convert(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
model_id=ELEVENLABS_MODEL,
|
||||
output_format="mp3_44100_128",
|
||||
voice_settings=voice_settings
|
||||
output_format="mp3_22050_32",
|
||||
voice_settings=voice_settings,
|
||||
optimize_streaming_latency=3
|
||||
)
|
||||
|
||||
with _process_lock:
|
||||
|
||||
Reference in New Issue
Block a user