J.A.R.V.I.S-py/dev_handlers.py
Bossiara13 31ff997782 feat: codebase_qa + github_pr in Python (142 → 148 commands)
Closes the Python parity gap with rust. All major imba features now mirrored
EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice-
switchable like rust).

dev_handlers.py (new, ~250 lines)
  Codebase Q&A:
    do_codebase_set        "укажи проект C:\path" → memory_store.remember("codebase.root", ...)
    do_codebase_where      "какой проект сейчас"
    do_codebase_ask        "что делает функция X" / "найди в коде Y"
                           Walks the root with same caps as rust:
                             depth=3, files=30, file_bytes=4096, total=50000
                           Filters by extension (rs/py/ts/lua/go/...).
                           Skips .git/target/node_modules/__pycache__/.venv etc.
                           Feeds digest to Groq LLM with "senior reviewer" prompt.

  GitHub PR review (requires `gh` CLI authenticated):
    do_github_set_repo     "текущий репо owner/repo" → memory_store
    do_github_list_prs     "какие пиары" → `gh pr list --json number,title,author`,
                           speaks count + first 3 titles
    do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open
                           PR, feeds title+body+diff stat to LLM, speaks 3-5
                           sentence review.

extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it.

main.py — 6 new action_type dispatches in run_action.

commands.yaml (+6 entries, 142 → 148):
  codebase_set, codebase_where, codebase_ask,
  github_set_repo, github_list_prs, github_summarize_pr.

Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries.

PYTHON PARITY VS RUST IS NOW COMPLETE except:
  ✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB;
    python config.GROQ_TOKEN is read once at startup)
  ✗ GUI pages (rust has Tauri+Svelte, python is console-only)

Everything else has full parity: memory / profiles / vision / macros /
scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00

340 lines
12 KiB
Python
Raw 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
_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)
try:
import config as cfg
except ImportError:
cfg = None
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
if not token:
_speak("LLM не настроен — нужен GROQ_TOKEN.")
return
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 = (
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n"
f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}"
)
try:
resp = client.chat.completions.create(
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
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)')
try:
import config as cfg
except ImportError:
cfg = None
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
if not token:
_speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.")
return
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 = (
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=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
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}")