feat: LLM hot-swap voice command + Ollama backend (148 → 151 commands)

Closes the last functional parity gap with rust. Python now has voice-driven
Ollama↔Groq switching, persistent across restarts.

llm_backend.py (new, ~130 lines)
  Singleton module:
    current_client()     → active OpenAI client (or None)
    current_backend()    → 'groq' | 'ollama' | 'none'
    current_model()      → active model name
    swap_to(name)        → hot-swap + persist to <here>/llm_backend.txt
    parse_backend(name)  → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama)

  Backends:
    - Groq:   config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults)
    - Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) +
              OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder,
              openai lib insists on a non-empty string; Ollama ignores it).

  Init precedence (idempotent _ensure_init):
    1. Persisted choice from llm_backend.txt
    2. JARVIS_LLM env var
    3. Auto-detect: Groq if token present, else Ollama

Voice commands (commands.yaml, +3 entries → 151)
  llm_switch_local  → ollama
  llm_switch_cloud  → groq
  llm_status        → speaks current backend

extensions.py
  + do_llm_switch / do_llm_status handlers
  + llm_backend import
  do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_*

dev_handlers.py
  + llm_backend import
  do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_*

vision_handler.py
  Vision call documented as Groq-specific (Ollama doesn't expose vision via
  OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading.

Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries.

PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE.
Only remaining rust-only feature: the Tauri GUI (python is console-only).
This commit is contained in:
Bossiara13 2026-05-16 00:44:42 +03:00
parent 31ff997782
commit 8d3da4ea06
6 changed files with 254 additions and 53 deletions

View file

@ -26,6 +26,7 @@ import macros_store
import scheduler_store
import vision_handler
import dev_handlers
import llm_backend
_speak_fn = print
_set_clipboard_fn = None
@ -828,11 +829,12 @@ def do_scheduler_cancel_by_text(action, voice):
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
def do_interesting_fact(action, voice):
"""Asks the configured Groq LLM for a fun fact. Requires GROQ_TOKEN."""
import config as cfg
if not getattr(cfg, 'GROQ_TOKEN', None):
"""Asks the active LLM for a fun fact. Backend chosen by llm_backend."""
client = llm_backend.current_client()
if client is None:
_speak("LLM не настроен.")
return
model = llm_backend.current_model()
topic = ''
low = (voice or '').lower()
@ -850,19 +852,9 @@ def do_interesting_fact(action, voice):
else:
prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'."
try:
from openai import OpenAI
except ImportError:
_speak("OpenAI клиент не установлен.")
return
client = OpenAI(
api_key=cfg.GROQ_TOKEN,
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
)
try:
resp = client.chat.completions.create(
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
model=model,
messages=[
{'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'},
{'role': 'user', 'content': prompt},
@ -879,6 +871,48 @@ def do_interesting_fact(action, voice):
_speak("Не получилось получить факт.")
# ── LLM hot-swap ────────────────────────────────────────────────────────────
def do_llm_switch(action, voice):
"""Hot-swap LLM backend. Action.target preferred, else parse from voice."""
target = action.get('target') or ''
if not target:
# Try to extract from voice
low = (voice or '').lower()
if 'локал' in low or 'оллам' in low or 'ollama' in low:
target = 'ollama'
elif 'облак' in low or 'грок' in low or 'клауд' in low or 'groq' in low:
target = 'groq'
parsed = llm_backend.parse_backend(target)
if not parsed:
_speak("Не понял какой движок.")
return
try:
actual = llm_backend.swap_to(parsed)
except Exception as exc:
print(f"[llm] swap failed: {exc}")
human = "локальный" if parsed == 'ollama' else "облачный"
_speak(f"Не получилось переключиться на {human}.")
return
if actual == 'groq':
_speak("Переключился на облачный мозг.")
else:
_speak("Переключился на локальный мозг.")
def do_llm_status(action, voice):
backend = llm_backend.current_backend()
if backend == 'groq':
_speak("Сейчас работаю на облаке.")
elif backend == 'ollama':
_speak("Сейчас работаю локально.")
else:
_speak("LLM не настроен.")
# ── codebase Q&A (proxies to dev_handlers) ──────────────────────────────────
def do_codebase_set(action, voice):