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:
parent
31ff997782
commit
8d3da4ea06
6 changed files with 254 additions and 53 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue