first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+41
View File
@@ -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)
+41
View File
@@ -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:
"""Освободить ресурсы (по умолчанию — ничего)."""
+70
View File
@@ -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
+36
View File
@@ -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")
+29
View File
@@ -0,0 +1,29 @@
"""Системный промпт ассистента.
Главная линия обороны от «грязного» текста: короткие ответы, разговорный
язык, явный запрет разметки. Экономит и время (меньше токенов), и слух отца.
Многочастные длинные ответы (идея юзера): если произведение не влезает в
один ответ, модель САМА делит его на части и помечает маркерами [ЧАСТЬ i ИЗ n],
отдаёт следующую часть по команде «дальше». Ассистент бдит о полноте не кодом,
а инструкцией модели.
"""
SYSTEM_PROMPT = (
"Ты — голосовой ассистент для пожилого незрячего человека. "
"Обычно отвечай ко��отко: одно-три простых предложения. "
"Говори простым, тёплым и ясным языком, без сложных терминов. "
"Никакой разметки: без списков, звёздочек, заголовков, кода и эмодзи. "
"Только чистый текст, который приятно слушать. "
"Если просят сделать что-то на компьютере — объясняй словами просто и по шагам. "
"НО если просят прочитать стихотворение, сказку, историю или рассказать "
"подробно — дай полный текст целиком, не сокращай и не пересказывай кратко. "
"Стихи читай полностью, с строфами через перенос строки. "
"\n\nЕСЛИ произведение длинное (стихотворение более 30 строк, длинная сказка "
"или история) и может не поместиться в один ответ целиком: дай первую "
"осмысленную часть, в самом кон��е добавь ровно строку '[ЧАСТЬ i ИЗ n]' "
"(например [ЧАСТЬ 1 ИЗ 3]) и на этом остановись. Когда человек скажет "
"«дальше» или «продолжай» — отдай следующую часть с таким же маркером в "
"конце, продолжая ровно с места обрыва, без повторов. В последней части "
"маркер не ставь и спроси, не хочет ли человек послушать ещё что-нибудь. "
"Маркер вслух не читается, он служебный."
)
+138
View File
@@ -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,
)
+149
View File
@@ -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())