first commit
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Модуль 4: brain — «мозг» ассистента (LLM через OpenRouter) + очистка текста.
|
||||
|
||||
Использование:
|
||||
|
||||
from modules.brain import get_provider
|
||||
|
||||
brain = get_provider("openrouter", model="openai/gpt-4o-mini")
|
||||
result = brain.ask("Какая погода?")
|
||||
print(result.cleaned_text) # чистый текст для озвучки
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import DialogHistory, LlmProvider, LlmResult
|
||||
from .clean_text import clean_for_speech
|
||||
from .prompts import SYSTEM_PROMPT
|
||||
from .provider_openrouter import OpenRouterProvider
|
||||
|
||||
__all__ = [
|
||||
"DialogHistory",
|
||||
"LlmProvider",
|
||||
"LlmResult",
|
||||
"OpenRouterProvider",
|
||||
"SYSTEM_PROMPT",
|
||||
"clean_for_speech",
|
||||
"get_provider",
|
||||
]
|
||||
|
||||
_PROVIDERS = {
|
||||
OpenRouterProvider.name: OpenRouterProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> LlmProvider:
|
||||
"""Фабрика провайдеров по имени (выбор из конфига)."""
|
||||
try:
|
||||
provider_cls = _PROVIDERS[name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Неизвестный LLM-провайдер: {name!r}. Доступны: {sorted(_PROVIDERS)}"
|
||||
) from None
|
||||
return provider_cls(**kwargs)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Базовый интерфейс «мозга» (LLM) + результат запроса.
|
||||
|
||||
История диалога — простой список {"role": "user"|"assistant", "text": str},
|
||||
чтобы провайдеры и будущая машина состояний не зависели от формата API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
DialogHistory = List[dict] # [{"role": "user"|"assistant", "text": str}, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmResult:
|
||||
"""Ответ LLM: сырой текст + очищенный для озвучки."""
|
||||
|
||||
question: str
|
||||
raw_text: str # как прислала модель (с разметкой, если была)
|
||||
cleaned_text: str # после clean_for_speech() — именно это озвучиваем
|
||||
generation_sec: float
|
||||
model: str = ""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (f"[{self.model}] {self.generation_sec:.2f} c, "
|
||||
f"{len(self.cleaned_text)} симв.: {self.cleaned_text!r}")
|
||||
|
||||
|
||||
class LlmProvider(ABC):
|
||||
"""Интерфейс провайдера LLM (сейчас OpenRouter, при надобности — локальный ollama)."""
|
||||
|
||||
name: str = "base"
|
||||
context_block: str = "" # доп. контекст (долговременная память); подставляется в system
|
||||
|
||||
@abstractmethod
|
||||
def ask(self, question: str, history: Optional[DialogHistory] = None) -> LlmResult:
|
||||
"""Задать вопрос, получить ответ. history — предыдущие реплики диалога."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Освободить ресурсы (по умолчанию — ничего)."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Очистка ответа LLM перед озвучкой.
|
||||
|
||||
По ТЗ: ИИ должен говорить чистым «человеческим» текстом — без звёздочек,
|
||||
решёток, списков, ссылок и эмодзи. Первая линия обороны — системный промпт
|
||||
(запрещает разметку), эта функция — вторая (убирает то, что просочилось).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Эмодзи и декоративные символы (TTS их либо молчит, либо читает мусором)
|
||||
_EMOJI = re.compile(
|
||||
"["
|
||||
"\U0001F000-\U0001FAFF" # эмодзи и пиктограммы
|
||||
"\U00002600-\U000027BF" # misc symbols
|
||||
"\U0001F1E6-\U0001F1FF" # флаги
|
||||
"\U00002B00-\U00002BFF" # стрелки/звёзды
|
||||
"\U0000FE00-\U0000FE0F" # variation selectors
|
||||
"\U00002190-\U000021FF" # стрелки
|
||||
"]+"
|
||||
)
|
||||
|
||||
_CODE_FENCE = re.compile(r"```[\s\S]*?(```|$)")
|
||||
# Служебный маркер многочастных ответов: [ЧАСТЬ 2 ИЗ 5] — вырезается до озвучки
|
||||
_PART_MARKER = re.compile(r"\s*\[ЧАСТЬ\s+\d+\s+ИЗ\s+\d+\]\s*", re.I)
|
||||
_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
||||
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_HEADER = re.compile(r"^[ \t]{0,3}#{1,6}[ \t]*", re.M)
|
||||
_BOLD = re.compile(r"(\*\*\*|\*\*|__)(?=\S)(.+?\S)\1")
|
||||
_ITALIC = re.compile(r"(?<![\w*\\])(\*|_)(?=\S)(.+?\S)\1(?![\w*])")
|
||||
_STRIKE = re.compile(r"~~(.+?)~~")
|
||||
_BULLET = re.compile(r"(?:^|\n)[ \t]*[-*+][ \t]+")
|
||||
_NUMBERED = re.compile(r"(?:^|\n)[ \t]*\d{1,3}[.)][ \t]+")
|
||||
_BLOCKQUOTE = re.compile(r"(?:^|\n)[ \t]*>[ \t]?")
|
||||
_HRULE = re.compile(r"(?:^|\n)[ \t]*([-_=*])[ \t]*\1[ \t]*\1[\1 \t=-]*(?:\n|$)")
|
||||
_INLINE_CODE = re.compile(r"`([^`]*)`")
|
||||
_MULTI_WS = re.compile(r"[ \t\f\v]+")
|
||||
_MULTI_NL = re.compile(r"\s*\n\s*")
|
||||
|
||||
|
||||
def clean_for_speech(text: str) -> str:
|
||||
"""Markdown/эмодзи → одна чистая строка, пригодная для TTS."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
t = _CODE_FENCE.sub(" ", text)
|
||||
t = _PART_MARKER.sub(" ", t) # служебный маркер части — не для ушей
|
||||
t = _IMAGE.sub(r"\1", t)
|
||||
t = _LINK.sub(r"\1", t)
|
||||
t = _HEADER.sub("", t)
|
||||
t = _BOLD.sub(r"\2", t)
|
||||
t = _ITALIC.sub(r"\2", t)
|
||||
t = _STRIKE.sub(r"\1", t)
|
||||
t = _BULLET.sub(" ", t)
|
||||
t = _NUMBERED.sub(" ", t)
|
||||
t = _BLOCKQUOTE.sub(" ", t)
|
||||
t = _HRULE.sub(" ", t)
|
||||
t = _INLINE_CODE.sub(r"\1", t)
|
||||
t = _EMOJI.sub(" ", t)
|
||||
|
||||
# Переносы строк — в пробелы (для озвучки это одна речь)
|
||||
t = t.replace("\r", "")
|
||||
t = _MULTI_WS.sub(" ", t)
|
||||
t = _MULTI_NL.sub(" ", t)
|
||||
t = t.strip()
|
||||
|
||||
# Двойные знаки препинания после чистки («…!» и т.п.)
|
||||
t = re.sub(r"\s+([,.!?;:])", r"\1", t)
|
||||
t = re.sub(r"([,.!?;:])\1+", r"\1", t)
|
||||
return t
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Загрузка секретов: переменные окружения + файл .env в корне проекта.
|
||||
|
||||
Ключи НЕ хранятся в коде. Порядок:
|
||||
1) переменная окружения (OPENROUTER_API_KEY=...);
|
||||
2) файл .env в корне проекта (строка вида OPENROUTER_API_KEY=sk-or-...).
|
||||
|
||||
Файл .env.example — шаблон: cp .env.example .env и вписать свой ключ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def get_secret(name: str) -> str | None:
|
||||
"""Взять секрет из окружения или .env (первый источник побеждает)."""
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value.strip()
|
||||
|
||||
env_file = _PROJECT_ROOT / ".env"
|
||||
if env_file.is_file():
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
if key.strip() == name:
|
||||
return val.strip().strip('"').strip("'")
|
||||
return None
|
||||
|
||||
|
||||
def get_api_key() -> str | None:
|
||||
return get_secret("OPENROUTER_API_KEY")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Системный промпт ассистента.
|
||||
|
||||
Главная линия обороны от «грязного» текста: короткие ответы, разговорный
|
||||
язык, явный запрет разметки. Экономит и время (меньше токенов), и слух отца.
|
||||
|
||||
Многочастные длинные ответы (идея юзера): если произведение не влезает в
|
||||
один ответ, модель САМА делит его на части и помечает маркерами [ЧАСТЬ i ИЗ n],
|
||||
отдаёт следующую часть по команде «дальше». Ассистент бдит о полноте не кодом,
|
||||
а инструкцией модели.
|
||||
"""
|
||||
SYSTEM_PROMPT = (
|
||||
"Ты — голосовой ассистент для пожилого незрячего человека. "
|
||||
"Обычно отвечай ко��отко: одно-три простых предложения. "
|
||||
"Говори простым, тёплым и ясным языком, без сложных терминов. "
|
||||
"Никакой разметки: без списков, звёздочек, заголовков, кода и эмодзи. "
|
||||
"Только чистый текст, который приятно слушать. "
|
||||
"Если просят сделать что-то на компьютере — объясняй словами просто и по шагам. "
|
||||
"НО если просят прочитать стихотворение, сказку, историю или рассказать "
|
||||
"подробно — дай полный текст целиком, не сокращай и не пересказывай кратко. "
|
||||
"Стихи читай полностью, с строфами через перенос строки. "
|
||||
"\n\nЕСЛИ произведение длинное (стихотворение более 30 строк, длинная сказка "
|
||||
"или история) и может не поместиться в один ответ целиком: дай первую "
|
||||
"осмысленную часть, в самом кон��е добавь ровно строку '[ЧАСТЬ i ИЗ n]' "
|
||||
"(например [ЧАСТЬ 1 ИЗ 3]) и на этом остановись. Когда человек скажет "
|
||||
"«дальше» или «продолжай» — отдай следующую часть с таким же маркером в "
|
||||
"конце, продолжая ровно с места обрыва, без повторов. В последней части "
|
||||
"маркер не ставь и спроси, не хочет ли человек послушать ещё что-нибудь. "
|
||||
"Маркер вслух не читается, он служебный."
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Провайдер LLM через OpenRouter (openai-совместимый API).
|
||||
|
||||
Сеть (проверено 2026-09-11):
|
||||
- OpenRouter API доступен из РФ напрямую (200), НО часть апстрим-провайдеров
|
||||
(OpenAI, Google) блокирует запросы с российских IP по своим ToS (403).
|
||||
- Решение: опциональный прокси (по умолчанию берётся из .env: OPENROUTER_PROXY),
|
||||
запросы выходят через зарубежный egress — тогда доступны все модели.
|
||||
|
||||
Модель — параметр конструктора (задаётся в тесте/конфиге).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .base import DialogHistory, LlmProvider, LlmResult
|
||||
from .clean_text import clean_for_speech
|
||||
from .config import get_api_key, get_secret
|
||||
from .prompts import SYSTEM_PROMPT
|
||||
|
||||
|
||||
class OpenRouterProvider(LlmProvider):
|
||||
name = "openrouter"
|
||||
|
||||
# Вопросы, требующие свежих данных → модель с суффиксом :online (веб-поиск OpenRouter).
|
||||
# Онлайн-поиск медленнее (+4-9 c) и чуть дороже, поэтому только для «актуальных» тем.
|
||||
ONLINE_MODEL_ENV = "OPENROUTER_MODEL_ONLINE"
|
||||
ONLINE_HINTS = (
|
||||
"погод", "курс", "доллар", "евро", "рубл", "новост", "сегодняшн",
|
||||
"сейчас на улице", "за окном", "курс валют", "биткоин", "акци",
|
||||
"спорт", "счет матча", "результат матч", "последн",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# openai/* заблокированы ToS-фильтром OpenAI по аккаунту OpenRouter с РФ-биллингом
|
||||
# (403 даже через зарубежный egress). Рабочие из РФ (проверено 2026-09-11):
|
||||
# google/gemma-3-27b-it, deepseek/deepseek-chat-v3-0324, qwen/qwen-2.5-72b-instruct,
|
||||
# mistralai/mistral-small-3.1-24b-instruct, meta-llama/llama-3.3-70b-instruct,
|
||||
# z-ai/glm-5.3-flash (reasoning-модель: тратит бюджет max_tokens на размышления).
|
||||
# None → берётся OPENROUTER_MODEL из .env (там google/gemma-3-27b-it).
|
||||
model: str | None = None,
|
||||
online_model: str | None = None, # None → OPENROUTER_MODEL_ONLINE (glm-5.3-flash:online)
|
||||
api_key: str | None = None,
|
||||
base_url: str = "https://openrouter.ai/api/v1",
|
||||
proxy: str | None = None, # http://хост:порт; None → OPENROUTER_PROXY из .env
|
||||
temperature: float = 0.6,
|
||||
# Reasoning-модели (z-ai/glm-*) тратят этот бюджет на «размышления» ДО ответа
|
||||
# (у glm-5.3-flash на простую фразу ушло 65 reasoning-токенов из 78).
|
||||
# Если бюджет мал, reasoning съедает его и content приходит ПУСТЫМ.
|
||||
# 2000 хватает на длинное стихотворение/историю; это ~$0.0003–0.0008 на ответ.
|
||||
max_tokens: int = 2000,
|
||||
timeout_sec: float = 30.0,
|
||||
) -> None:
|
||||
from openai import OpenAI
|
||||
|
||||
key = api_key or get_api_key()
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"Нет API-ключа. Создай файл .env в корне проекта со строкой\n"
|
||||
"OPENROUTER_API_KEY=sk-or-v1-...\n"
|
||||
"(или export OPENROUTER_API_KEY=... перед запуском)"
|
||||
)
|
||||
proxy_url = proxy or get_secret("OPENROUTER_PROXY")
|
||||
|
||||
http_client = None
|
||||
if proxy_url:
|
||||
import httpx
|
||||
http_client = httpx.Client(proxy=proxy_url, timeout=timeout_sec)
|
||||
|
||||
self._model = model or get_secret("OPENROUTER_MODEL") or "google/gemma-3-27b-it"
|
||||
# Модель с веб-поиском для «актуальных» вопросов (погода/курсы/новости)
|
||||
self._online_model = (online_model or get_secret(self.ONLINE_MODEL_ENV)
|
||||
or "z-ai/glm-5.3-flash:online")
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key=key,
|
||||
timeout=timeout_sec,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# --- API -------------------------------------------------------------
|
||||
def _needs_online(self, question: str, history: Optional[DialogHistory] = None) -> bool:
|
||||
"""Похоже ли, что вопрос требует свежих данных из интернета.
|
||||
|
||||
Смотрим не только на текущую фразу, но и на последние реплики диалога:
|
||||
уточнение «я поэтому и спрашиваю...» после вопроса про акции должно
|
||||
пойти в online-модель, хотя в самой фразе нет слов «акции/курс».
|
||||
"""
|
||||
q = question.lower()
|
||||
if any(hint in q for hint in self.ONLINE_HINTS):
|
||||
return True
|
||||
# контекст последних 4 реплик (2 вопроса+ответа) тоже проверяем
|
||||
for turn in (history or [])[-4:]:
|
||||
text = turn.get("text", "").lower()
|
||||
if any(hint in text for hint in self.ONLINE_HINTS):
|
||||
return True
|
||||
# маркеры продолжения/уточнения («я поэтому и спрашиваю», «а теперь...»)
|
||||
if history and re.search(r"\b(поэтому и спрашиваю|я же спрашивал|ты не ответила|"
|
||||
r"а теперь|так вот|продолжай|уточню)\b", q):
|
||||
return True
|
||||
return False
|
||||
|
||||
def ask(self, question: str, history: Optional[DialogHistory] = None) -> LlmResult:
|
||||
system = SYSTEM_PROMPT
|
||||
if self.context_block:
|
||||
system += "\n\n" + self.context_block
|
||||
|
||||
messages = [{"role": "system", "content": system}]
|
||||
for turn in history or []:
|
||||
messages.append({"role": turn["role"], "content": turn["text"]})
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
# Вопрос про погоду/курсы/новости (или уточнение к такому) → модель с веб-поиском
|
||||
used_model = self._online_model if self._needs_online(question, history) else self._model
|
||||
|
||||
t0 = time.perf_counter()
|
||||
response = self._client.chat.completions.create(
|
||||
model=used_model,
|
||||
messages=messages,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
raw = response.choices[0].message.content or "" if response.choices else ""
|
||||
# Убираем маркеры источников, которые добавляет веб-плагин (ссылки в конце)
|
||||
raw = re.sub(r"\s*\b[a-z0-9.-]+\.(?:ru|com|net|org|ai)\b\s*$", "", raw, flags=re.I)
|
||||
return LlmResult(
|
||||
question=question,
|
||||
raw_text=raw.strip(),
|
||||
cleaned_text=clean_for_speech(raw),
|
||||
generation_sec=elapsed,
|
||||
model=used_model,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Тест Модуля 4: brain (LLM) + очистка Markdown + полная цепочка.
|
||||
|
||||
Запуск (нужен ключ: файл .env со строкой OPENROUTER_API_KEY=sk-or-v1-...):
|
||||
python modules/brain/test_brain.py clean # тест очистки без сети
|
||||
python modules/brain/test_brain.py --ask "Привет!" # вопрос → ответ (raw + чистый + время)
|
||||
python modules/brain/test_brain.py --ask "Привет!" --play # и озвучить ответ
|
||||
python modules/brain/test_brain.py --model google/gemini-flash-1.5 --ask "..."
|
||||
python modules/brain/test_brain.py --mic # ПОЛНАЯ ЦЕПЬ: говоришь → слышишь ответ
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from modules.brain import clean_for_speech, get_provider
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from modules.brain import clean_for_speech, get_provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def clean_selftest() -> None:
|
||||
"""Проверка очистки Markdown без сети: грязные строки → чистый текст."""
|
||||
cases = [
|
||||
("**Привет!** Как *дела*?", "Привет! Как дела?"),
|
||||
("## Заголовок\n- пункт один\n- пункт два", "Заголовок пункт один пункт два"),
|
||||
("1. Включи компьютер\n2. Открой программу", "Включи компьютер Открой программу"),
|
||||
("Ссылка: [Google](https://google.com)", "Ссылка: Google"),
|
||||
("Код: `python -m venv` и ```print(1)```", "Код: python -m venv и"),
|
||||
("Смайл 😊 и стрелка ➡️ тут", "Смайл и стрелка тут"),
|
||||
("Текст выше\n> Важная цитата\nТекст ниже", "Текст выше Важная цитата Текст ниже"),
|
||||
("Раздел один\n---\nРаздел два", "Раздел один Раздел два"),
|
||||
]
|
||||
ok = True
|
||||
for dirty, expected in cases:
|
||||
got = clean_for_speech(dirty)
|
||||
status = "OK " if got == expected else "FAIL"
|
||||
ok &= got == expected
|
||||
print(f"[{status}] {dirty!r}\n → {got!r}" + ("" if got == expected else f"\n ожидалось {expected!r}"))
|
||||
print("\nОЧИСТКА:", "ПРОЙДЕНА ✅" if ok else "ЕСТЬ ОШИБКИ ❌")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def ask_once(brain, question: str, play: bool, history=None):
|
||||
result = brain.ask(question, history=history)
|
||||
print(f"\nВопрос: {question}")
|
||||
print(f"Ответ ({result.generation_sec:.2f} c): {result.cleaned_text}")
|
||||
if result.raw_text != result.cleaned_text:
|
||||
print(f"(raw был: {result.raw_text!r})")
|
||||
if play:
|
||||
from modules.audio_io import Player
|
||||
from modules.tts import get_provider as get_tts
|
||||
|
||||
tts = get_tts("edge")
|
||||
speech = tts.synthesize(result.cleaned_text)
|
||||
print(f"TTS: синтез {speech.generation_sec:.2f} c, аудио {speech.duration_sec:.1f} c")
|
||||
Player().play(speech.audio, speech.samplerate, blocking=True)
|
||||
return result
|
||||
|
||||
|
||||
def mic_loop(brain) -> None:
|
||||
"""Полная цепочка: запись с микрофона → STT → LLM → TTS → воспроизведение."""
|
||||
from modules.audio_io import Player, Recorder
|
||||
from modules.stt import get_provider as get_stt
|
||||
from modules.tts import get_provider as get_tts
|
||||
|
||||
print("Загружаю STT (faster-whisper small)...")
|
||||
stt = get_stt("faster-whisper", model_size="small")
|
||||
tts = get_tts("edge")
|
||||
player = Player()
|
||||
rec = Recorder()
|
||||
history = []
|
||||
|
||||
print("\nГОТОВ. [Enter] → говори вопрос → [Enter] → услышишь ответ. Пустой Enter — выход.")
|
||||
while True:
|
||||
cmd = input("> ").strip()
|
||||
if cmd == "":
|
||||
break
|
||||
|
||||
rec.start()
|
||||
input(" 🔴 запись... Enter = закончить ")
|
||||
audio = rec.stop()
|
||||
if len(audio) == 0:
|
||||
print(" Слишком коротко — повтори.")
|
||||
continue
|
||||
|
||||
stt_res = stt.transcribe(audio)
|
||||
question = stt_res.text.strip()
|
||||
if not question:
|
||||
print(" STT ничего не расслышал. Повтори.")
|
||||
continue
|
||||
print(f" Ты: {question}")
|
||||
|
||||
llm = brain.ask(question, history=history)
|
||||
print(f" AI ({llm.generation_sec:.2f} c): {llm.cleaned_text}")
|
||||
|
||||
speech = tts.synthesize(llm.cleaned_text)
|
||||
print(f" TTS ({speech.generation_sec:.2f} c), играю...")
|
||||
player.play(speech.audio, speech.samplerate, blocking=True)
|
||||
|
||||
history.append({"role": "user", "text": question})
|
||||
history.append({"role": "assistant", "text": llm.cleaned_text})
|
||||
history = history[-8:] # держим контекст, но ограничиваем
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Тест Модуля 4: brain")
|
||||
p.add_argument("mode", nargs="?", default="ask", choices=["ask", "clean", "mic"],
|
||||
help="clean = офлайн-тест очистки; ask = текстовый вопрос; mic = полная цепь")
|
||||
p.add_argument("--ask", "--question", dest="question", default="Привет! Кто ты и что умеешь?",
|
||||
help="вопрос к модели (режим ask)")
|
||||
p.add_argument("--model", default="google/gemma-3-27b-it",
|
||||
help="модель OpenRouter (deepseek/deepseek-chat-v3-0324, qwen/qwen-2.5-72b-instruct...)")
|
||||
p.add_argument("--play", action="store_true", help="озвучить ответ (edge-tts)")
|
||||
p.add_argument("--turns", type=int, default=0,
|
||||
help="сколько вопросов подряд в режиме ask с общей историей")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
if args.mode == "clean":
|
||||
clean_selftest()
|
||||
return 0
|
||||
|
||||
brain = get_provider("openrouter", model=args.model)
|
||||
print(f"Модель: {args.model}")
|
||||
|
||||
if args.mode == "mic":
|
||||
mic_loop(brain)
|
||||
return 0
|
||||
|
||||
questions = [args.question]
|
||||
if args.turns > 1:
|
||||
extra = input(f"Введи ещё {args.turns - 1} вопроса через ';' : ")
|
||||
questions = [q.strip() for q in extra.split(";") if q.strip()] or questions
|
||||
|
||||
history = []
|
||||
for q in questions:
|
||||
result = ask_once(brain, q, play=args.play, history=history)
|
||||
history += [{"role": "user", "text": q}, {"role": "assistant", "text": result.cleaned_text}]
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user