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
|
|
@ -1275,3 +1275,35 @@ github_summarize_pr:
|
||||||
- что в последнем pr
|
- что в последнем pr
|
||||||
- разбор pr
|
- разбор pr
|
||||||
action: {type: github_summarize_pr}
|
action: {type: github_summarize_pr}
|
||||||
|
|
||||||
|
llm_switch_local:
|
||||||
|
phrases:
|
||||||
|
- переключись на локальный
|
||||||
|
- переключись на локальный мозг
|
||||||
|
- включи локальный режим
|
||||||
|
- перейди на локальный
|
||||||
|
- используй локальный
|
||||||
|
- перейди на оллама
|
||||||
|
- используй оллама
|
||||||
|
action: {type: llm_switch, target: ollama}
|
||||||
|
|
||||||
|
llm_switch_cloud:
|
||||||
|
phrases:
|
||||||
|
- переключись на облако
|
||||||
|
- переключись на облачный
|
||||||
|
- переключись на грок
|
||||||
|
- переключись на гроку
|
||||||
|
- используй облако
|
||||||
|
- используй грок
|
||||||
|
- перейди на облако
|
||||||
|
action: {type: llm_switch, target: groq}
|
||||||
|
|
||||||
|
llm_status:
|
||||||
|
phrases:
|
||||||
|
- какой у тебя мозг
|
||||||
|
- какой ллм
|
||||||
|
- какой у тебя llm
|
||||||
|
- облако или локально
|
||||||
|
- ты сейчас где работаешь
|
||||||
|
- что за модель
|
||||||
|
action: {type: llm_status}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import memory_store
|
import memory_store
|
||||||
|
import llm_backend
|
||||||
|
|
||||||
_speak_fn = print
|
_speak_fn = print
|
||||||
|
|
||||||
|
|
@ -146,25 +147,11 @@ def do_codebase_ask(action, voice):
|
||||||
|
|
||||||
digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks)
|
digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks)
|
||||||
|
|
||||||
try:
|
client = llm_backend.current_client()
|
||||||
import config as cfg
|
if client is None:
|
||||||
except ImportError:
|
_speak("LLM не настроен.")
|
||||||
cfg = None
|
|
||||||
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
|
|
||||||
if not token:
|
|
||||||
_speak("LLM не настроен — нужен GROQ_TOKEN.")
|
|
||||||
return
|
return
|
||||||
|
model = llm_backend.current_model()
|
||||||
try:
|
|
||||||
from openai import OpenAI
|
|
||||||
except ImportError:
|
|
||||||
_speak("OpenAI клиент не установлен.")
|
|
||||||
return
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key=token,
|
|
||||||
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
|
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
|
||||||
|
|
@ -174,7 +161,7 @@ def do_codebase_ask(action, voice):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.chat.completions.create(
|
resp = client.chat.completions.create(
|
||||||
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
|
model=model,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'},
|
{'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'},
|
||||||
{'role': 'user', 'content': user_prompt},
|
{'role': 'user', 'content': user_prompt},
|
||||||
|
|
@ -291,25 +278,11 @@ def do_github_summarize_pr(action, voice):
|
||||||
files = pr.get('changedFiles', '?')
|
files = pr.get('changedFiles', '?')
|
||||||
author = (pr.get('author') or {}).get('login', '(unknown)')
|
author = (pr.get('author') or {}).get('login', '(unknown)')
|
||||||
|
|
||||||
try:
|
client = llm_backend.current_client()
|
||||||
import config as cfg
|
if client is None:
|
||||||
except ImportError:
|
_speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.")
|
||||||
cfg = None
|
|
||||||
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
|
|
||||||
if not token:
|
|
||||||
_speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.")
|
|
||||||
return
|
return
|
||||||
|
model = llm_backend.current_model()
|
||||||
try:
|
|
||||||
from openai import OpenAI
|
|
||||||
except ImportError:
|
|
||||||
_speak("OpenAI клиент не установлен.")
|
|
||||||
return
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key=token,
|
|
||||||
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n"
|
f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n"
|
||||||
|
|
@ -321,7 +294,7 @@ def do_github_summarize_pr(action, voice):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.chat.completions.create(
|
resp = client.chat.completions.create(
|
||||||
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
|
model=model,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."},
|
{'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."},
|
||||||
{'role': 'user', 'content': user_prompt},
|
{'role': 'user', 'content': user_prompt},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import macros_store
|
||||||
import scheduler_store
|
import scheduler_store
|
||||||
import vision_handler
|
import vision_handler
|
||||||
import dev_handlers
|
import dev_handlers
|
||||||
|
import llm_backend
|
||||||
|
|
||||||
_speak_fn = print
|
_speak_fn = print
|
||||||
_set_clipboard_fn = None
|
_set_clipboard_fn = None
|
||||||
|
|
@ -828,11 +829,12 @@ def do_scheduler_cancel_by_text(action, voice):
|
||||||
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
|
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
|
||||||
|
|
||||||
def do_interesting_fact(action, voice):
|
def do_interesting_fact(action, voice):
|
||||||
"""Asks the configured Groq LLM for a fun fact. Requires GROQ_TOKEN."""
|
"""Asks the active LLM for a fun fact. Backend chosen by llm_backend."""
|
||||||
import config as cfg
|
client = llm_backend.current_client()
|
||||||
if not getattr(cfg, 'GROQ_TOKEN', None):
|
if client is None:
|
||||||
_speak("LLM не настроен.")
|
_speak("LLM не настроен.")
|
||||||
return
|
return
|
||||||
|
model = llm_backend.current_model()
|
||||||
|
|
||||||
topic = ''
|
topic = ''
|
||||||
low = (voice or '').lower()
|
low = (voice or '').lower()
|
||||||
|
|
@ -850,19 +852,9 @@ def do_interesting_fact(action, voice):
|
||||||
else:
|
else:
|
||||||
prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'."
|
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:
|
try:
|
||||||
resp = client.chat.completions.create(
|
resp = client.chat.completions.create(
|
||||||
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
|
model=model,
|
||||||
messages=[
|
messages=[
|
||||||
{'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'},
|
{'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'},
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': prompt},
|
||||||
|
|
@ -879,6 +871,48 @@ def do_interesting_fact(action, voice):
|
||||||
_speak("Не получилось получить факт.")
|
_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) ──────────────────────────────────
|
# ── codebase Q&A (proxies to dev_handlers) ──────────────────────────────────
|
||||||
|
|
||||||
def do_codebase_set(action, voice):
|
def do_codebase_set(action, voice):
|
||||||
|
|
|
||||||
152
llm_backend.py
Normal file
152
llm_backend.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`.
|
||||||
|
|
||||||
|
Supports two backends:
|
||||||
|
- Groq (cloud) — uses config.GROQ_TOKEN
|
||||||
|
- Ollama (local) — connects to OLLAMA_BASE_URL (default localhost:11434/v1)
|
||||||
|
|
||||||
|
Active choice persists in `<here>/llm_backend.txt`. Loaded on first call.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
current_client() -> OpenAI client for the active backend, or None
|
||||||
|
current_backend() -> 'groq' | 'ollama' | 'none'
|
||||||
|
current_model() -> model name string
|
||||||
|
swap_to(name) -> 'groq' | 'ollama', persists choice
|
||||||
|
parse_backend(name) -> normalized 'groq' | 'ollama' | None
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt')
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_client = None
|
||||||
|
_backend = 'none'
|
||||||
|
_model = ''
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_persisted() -> str:
|
||||||
|
try:
|
||||||
|
with open(_CHOICE_FILE, encoding='utf-8') as f:
|
||||||
|
return f.read().strip().lower()
|
||||||
|
except OSError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _persist(name: str) -> None:
|
||||||
|
try:
|
||||||
|
with open(_CHOICE_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(name)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"[llm] persist: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_backend(name: str) -> str | None:
|
||||||
|
"""Normalize a user-facing name to 'groq' / 'ollama' / None."""
|
||||||
|
n = (name or '').strip().lower()
|
||||||
|
if n in ('groq', 'cloud', 'облако', 'клауд'):
|
||||||
|
return 'groq'
|
||||||
|
if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'):
|
||||||
|
return 'ollama'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_groq():
|
||||||
|
"""Try to construct a Groq client. Returns (client, model) or raises."""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("config.py не найден")
|
||||||
|
if not getattr(cfg, 'GROQ_TOKEN', None):
|
||||||
|
raise RuntimeError("GROQ_TOKEN отсутствует в config")
|
||||||
|
from openai import OpenAI
|
||||||
|
base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1')
|
||||||
|
model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||||
|
return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ollama():
|
||||||
|
"""Construct an Ollama-talking OpenAI-compatible client."""
|
||||||
|
from openai import OpenAI
|
||||||
|
base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1')
|
||||||
|
model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b')
|
||||||
|
# Ollama doesn't need a real key, but openai lib insists on a non-empty string.
|
||||||
|
return OpenAI(api_key='ollama', base_url=base), model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_for(backend: str):
|
||||||
|
if backend == 'groq':
|
||||||
|
return _build_groq()
|
||||||
|
if backend == 'ollama':
|
||||||
|
return _build_ollama()
|
||||||
|
raise ValueError(f"unknown backend: {backend}")
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_detect_backend() -> str:
|
||||||
|
"""Pick a default backend if none persisted: Groq if token present, else Ollama."""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
if getattr(cfg, 'GROQ_TOKEN', None):
|
||||||
|
return 'groq'
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return 'ollama'
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_init():
|
||||||
|
"""Build the client based on persisted choice, env, or auto-detect."""
|
||||||
|
global _client, _backend, _model, _initialized
|
||||||
|
if _initialized:
|
||||||
|
return
|
||||||
|
persisted = _read_persisted()
|
||||||
|
env_choice = os.environ.get('JARVIS_LLM', '').strip().lower()
|
||||||
|
backend = persisted or env_choice or _auto_detect_backend()
|
||||||
|
try:
|
||||||
|
client, model = _build_for(backend)
|
||||||
|
_client = client
|
||||||
|
_backend = backend
|
||||||
|
_model = model
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[llm] init {backend} failed: {exc}")
|
||||||
|
_client = None
|
||||||
|
_backend = 'none'
|
||||||
|
_model = ''
|
||||||
|
_initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def current_client():
|
||||||
|
"""Get the active client (or None if uninitialised). Cheap after first call."""
|
||||||
|
with _lock:
|
||||||
|
_ensure_init()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def current_backend() -> str:
|
||||||
|
with _lock:
|
||||||
|
_ensure_init()
|
||||||
|
return _backend
|
||||||
|
|
||||||
|
|
||||||
|
def current_model() -> str:
|
||||||
|
with _lock:
|
||||||
|
_ensure_init()
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
def swap_to(name: str) -> str:
|
||||||
|
"""Hot-swap to a different backend. Persists to disk. Returns new name."""
|
||||||
|
target = parse_backend(name)
|
||||||
|
if not target:
|
||||||
|
raise ValueError(f"unknown backend: {name}")
|
||||||
|
with _lock:
|
||||||
|
global _client, _backend, _model, _initialized
|
||||||
|
client, model = _build_for(target) # may raise
|
||||||
|
_client = client
|
||||||
|
_backend = target
|
||||||
|
_model = model
|
||||||
|
_initialized = True
|
||||||
|
_persist(target)
|
||||||
|
print(f"[llm] swapped → {target} ({model})")
|
||||||
|
return target
|
||||||
4
main.py
4
main.py
|
|
@ -1626,6 +1626,10 @@ def run_action(action):
|
||||||
extensions.do_github_list_prs(action, _current_voice or '')
|
extensions.do_github_list_prs(action, _current_voice or '')
|
||||||
elif t == 'github_summarize_pr':
|
elif t == 'github_summarize_pr':
|
||||||
extensions.do_github_summarize_pr(action, _current_voice or '')
|
extensions.do_github_summarize_pr(action, _current_voice or '')
|
||||||
|
elif t == 'llm_switch':
|
||||||
|
extensions.do_llm_switch(action, _current_voice or '')
|
||||||
|
elif t == 'llm_status':
|
||||||
|
extensions.do_llm_status(action, _current_voice or '')
|
||||||
|
|
||||||
|
|
||||||
_current_voice: str = ''
|
_current_voice: str = ''
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,13 @@ def _take_screenshot() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def _vision_call(prompt: str, image_b64: str) -> str | None:
|
def _vision_call(prompt: str, image_b64: str) -> str | None:
|
||||||
|
# Vision is Groq-specific (Ollama doesn't expose vision via OpenAI-compat
|
||||||
|
# endpoint in our stack). Use config.GROQ_TOKEN directly regardless of the
|
||||||
|
# active text-LLM backend.
|
||||||
|
try:
|
||||||
import config as cfg
|
import config as cfg
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
if not getattr(cfg, 'GROQ_TOKEN', None):
|
if not getattr(cfg, 'GROQ_TOKEN', None):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue