J.A.R.V.I.S-py/dev_handlers.py
Bossiara13 8d3da4ea06 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).
2026-05-16 00:44:42 +03:00

313 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs.
Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files
(depth-limited, size-capped) and feeds a digest to the LLM.
GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or
reviews the latest one via LLM.
Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint).
"""
import json
import os
import re
import subprocess
import memory_store
import llm_backend
_speak_fn = print
def set_speak(fn):
global _speak_fn
_speak_fn = fn
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[dev] speak: {exc}")
# ── codebase Q&A ────────────────────────────────────────────────────────────
_EXT_OK = {
'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte',
'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs',
'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml',
'json', 'md',
}
_SKIP_DIR = {
'.git', 'target', 'node_modules', 'dist', 'build',
'__pycache__', '.venv', 'venv', '.idea', '.vscode',
}
_MAX_FILE_BYTES = 4096
_MAX_TOTAL_BYTES = 50_000
_MAX_FILES = 30
_MAX_DEPTH = 3
def _walk_codebase(root: str) -> list[tuple[str, str]]:
"""Return list of (relative_path, content) tuples — capped per the constants above."""
chunks = []
total_bytes = 0
root = os.path.normpath(root)
if not os.path.isdir(root):
return chunks
for dirpath, dirnames, filenames in os.walk(root):
# Depth gate
rel = os.path.relpath(dirpath, root)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > _MAX_DEPTH:
dirnames[:] = []
continue
# Prune skip dirs in-place
dirnames[:] = [d for d in dirnames
if d not in _SKIP_DIR and not d.startswith('.')]
for fname in filenames:
if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES:
return chunks
_, dot_ext = os.path.splitext(fname)
ext = (dot_ext or '').lstrip('.').lower()
if ext not in _EXT_OK:
continue
fp = os.path.join(dirpath, fname)
try:
with open(fp, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read(_MAX_FILE_BYTES + 1)
except (OSError, ValueError):
continue
if len(content) > _MAX_FILE_BYTES:
content = content[:_MAX_FILE_BYTES] + "\n... [truncated]"
rel_path = os.path.relpath(fp, root)
chunks.append((rel_path, content))
total_bytes += len(content)
return chunks
def do_codebase_set(action, voice):
"""'Укажи проект C:\\Jarvis\\rust' — stores root via memory."""
body = (voice or '').strip()
low = body.lower()
for trig in ('укажи папку проекта', 'укажи кодовую базу',
'укажи проект', 'выбери проект', 'проект сейчас'):
if low.startswith(trig):
body = body[len(trig):].strip(' ,.:')
break
if not body:
_speak("Укажите путь к папке проекта.")
return
if not os.path.isdir(body):
_speak(f"Папка не найдена: {body}.")
return
memory_store.remember("codebase.root", body)
_speak("Проект установлен.")
def do_codebase_where(action, voice):
root = memory_store.recall("codebase.root")
if not root:
_speak("Проект не выбран. Скажите: укажи проект и путь.")
return
_speak(f"Сейчас работаю с проектом {root}.")
def do_codebase_ask(action, voice):
"""'Что делает функция X' / 'найди в коде Y'."""
root = memory_store.recall("codebase.root")
if not root:
_speak("Сначала укажите проект.")
return
body = (voice or '').strip()
low = body.lower()
for trig in ('что делает функция', 'вопрос по коду',
'спроси код', 'найди в проекте',
'найди в коде', 'что в коде', 'объясни код'):
idx = low.find(trig)
if idx >= 0:
body = body[idx + len(trig):].strip(' ,.:')
break
if not body:
_speak("Сформулируйте вопрос.")
return
chunks = _walk_codebase(root)
if not chunks:
_speak("Не нашёл исходников в проекте.")
return
digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks)
client = llm_backend.current_client()
if client is None:
_speak("LLM не настроен.")
return
model = llm_backend.current_model()
user_prompt = (
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n"
f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}"
)
try:
resp = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'},
{'role': 'user', 'content': user_prompt},
],
max_tokens=400, temperature=0.2, timeout=45,
)
answer = resp.choices[0].message.content.strip()
except Exception as exc:
print(f"[dev] codebase LLM: {exc}")
_speak("Не получилось получить ответ.")
return
if not answer:
_speak("Пустой ответ.")
return
_speak(answer)
# ── github PR ───────────────────────────────────────────────────────────────
def _gh(*args, timeout=15):
"""Run gh CLI, return (success, stdout, stderr)."""
try:
res = subprocess.run(
['gh', *args],
capture_output=True, text=True, encoding='utf-8',
timeout=timeout,
)
return res.returncode == 0, res.stdout, res.stderr
except FileNotFoundError:
return False, '', 'gh CLI not installed'
except Exception as exc:
return False, '', str(exc)
def do_github_set_repo(action, voice):
body = (voice or '').strip()
low = body.lower()
for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'):
if low.startswith(trig):
body = body[len(trig):].strip(' ,.:')
break
if not body or '/' not in body:
_speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.")
return
memory_store.remember("github.repo", body)
_speak(f"Репозиторий {body} установлен.")
def do_github_list_prs(action, voice):
repo = memory_store.recall("github.repo")
if not repo:
_speak("Сначала укажите репозиторий: текущий репо owner/repo.")
return
ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open',
'--json', 'number,title,author', '--limit', '10')
if not ok:
print(f"[dev] gh list: {err[:200]}")
_speak("gh CLI не отвечает. Установите gh и выполните gh auth login.")
return
try:
prs = json.loads(out or '[]')
except json.JSONDecodeError:
prs = []
if not prs:
_speak("Открытых пиаров нет.")
return
line = f"{len(prs)} открытых пиаров. "
titles = [p.get('title', '') for p in prs[:3] if p.get('title')]
if titles:
line += "Первые: " + ". ".join(titles) + "."
_speak(line)
def do_github_summarize_pr(action, voice):
repo = memory_store.recall("github.repo")
if not repo:
_speak("Сначала укажите репозиторий.")
return
# Most recent open PR number
ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open',
'--json', 'number', '--limit', '1')
if not ok:
_speak("gh CLI не отвечает.")
return
try:
items = json.loads(out or '[]')
number = items[0]['number'] if items else None
except (json.JSONDecodeError, KeyError, IndexError):
number = None
if not number:
_speak("Открытых пиаров нет.")
return
ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo,
'--json', 'title,body,additions,deletions,changedFiles,author')
if not ok:
_speak("Не получилось получить PR.")
return
try:
pr = json.loads(out)
except json.JSONDecodeError:
_speak("gh вернул битый JSON.")
return
title = pr.get('title', '(no title)')
body = (pr.get('body') or '')[:3000]
additions = pr.get('additions', '?')
deletions = pr.get('deletions', '?')
files = pr.get('changedFiles', '?')
author = (pr.get('author') or {}).get('login', '(unknown)')
client = llm_backend.current_client()
if client is None:
_speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.")
return
model = llm_backend.current_model()
user_prompt = (
f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n"
f"Changed files: {files} (+{additions}, -{deletions})\n"
f"Description:\n{body}\n\n"
"Кратко (3-5 предложений) на русском: что меняет этот PR, "
"какие риски, стоит ли мёржить."
)
try:
resp = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."},
{'role': 'user', 'content': user_prompt},
],
max_tokens=400, temperature=0.2, timeout=45,
)
review = resp.choices[0].message.content.strip()
except Exception as exc:
print(f"[dev] PR LLM: {exc}")
_speak("Не получилось проанализировать.")
return
if not review:
_speak("Пустая сводка.")
return
_speak(f"Пиар номер {number}: {review}")