Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: AI Review

# Автоматическое AI-ревью каждого PR: пайплайн достаёт diff, гоняет RAG
# (документация + код) + LLM и постит разбор комментарием в пул-реквест.
#
# Запускается на self-hosted раннере (машина с Ollama для эмбеддингов bge-m3 и
# готовыми FAISS-индексами в ~/rag-kotlin и ~/.jarvis). Ключ DeepSeek — из
# secrets репозитория, gh авторизуется через GH_TOKEN.

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write

concurrency:
# Новый пуш в PR отменяет ещё не законченное ревью прошлого коммита.
group: ai-review-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
review:
runs-on: [self-hosted]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup venv + deps
run: |
python3 -m venv .venv
./.venv/bin/pip install --quiet --upgrade pip
./.venv/bin/pip install --quiet -e ".[rag]"

- name: AI review
env:
GH_TOKEN: ${{ github.token }}
LLM_PROVIDER: deepseek
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
# Пути к готовым индексам на self-hosted машине (переопредели при желании).
RAG_INDEX_PATH: ~/.jarvis/rag/docs_index
CODE_INDEX_PATH: ~/rag-kotlin/index_jarvis_full
RAG_STRATEGY: structural
run: |
./.venv/bin/python review_pr.py "${{ github.event.pull_request.number }}"
26 changes: 26 additions & 0 deletions app/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from domain.mcp import McpServerConfig, McpTool, ToolResult
from domain.profile import Profile
from domain.retrieval import RetrievedChunk
from domain.review import PrDiff
from domain.task import Task
from domain.working_memory import WorkingMemory

Expand Down Expand Up @@ -160,6 +161,31 @@ def current_branch(self) -> Optional[str]:
...


class DiffProvider(Protocol):
"""Источник изменений пул-реквеста для AI-ревью.

Абстрагирует, откуда берётся diff: конкретная реализация в infra/
ходит за ним в GitHub через `gh`. Use case `review_pull_request`
знает только про этот порт, поэтому в тестах подменяется фейком.
"""

def fetch(self, pr: str) -> PrDiff:
"""Получить diff и список изменённых файлов пул-реквеста `pr`."""
...


class ReviewPublisher(Protocol):
"""Публикация текста ревью обратно в пул-реквест.

Реализация в infra/ постит комментарий через `gh pr comment`.
Пайплайн (composition root) выбирает, публиковать или только печатать.
"""

def publish(self, pr: str, body: str) -> None:
"""Опубликовать ревью `body` комментарием к пул-реквесту `pr`."""
...


class McpConfigRepository(Protocol):
"""Хранилище конфигурации MCP-серверов (~/.jarvis/mcp/servers.json)."""

Expand Down
130 changes: 130 additions & 0 deletions app/pr_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Use case: AI-ревью пул-реквеста по его diff + RAG-контексту проекта.

Ассистент получает diff и список изменённых файлов, подмешивает релевантные
фрагменты документации и кода (RAG), и выдаёт структурированное ревью:
потенциальные баги, архитектурные проблемы, рекомендации.

Оркестрирует два порта — `RetrievalEngine` и `LLMClient` — и ничего не знает
об их реализациях (FAISS/Ollama, HTTP, gh). Diff приходит уже готовым (его
достаёт `DiffProvider` в composition root), поэтому use case чист и тестируется
на фейках.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional

from app.ports import LLMClient, RetrievalEngine
from domain.retrieval import RetrievedChunk

SYSTEM_PROMPT = (
"Ты — старший инженер, делающий код-ревью пул-реквеста в проекте jarvis-cli. "
"Проект придерживается слоистой архитектуры (cli → app → domain → infra), "
"внедрения зависимостей через конструктор и обязательных тестов на фейках. "
"Анализируй ТОЛЬКО присланный diff, опираясь на приведённый контекст из "
"документации и кода проекта. Не выдумывай изменений, которых нет в diff. "
"Ответ дай на русском строго в таком формате из трёх разделов Markdown:\n"
"## 🐞 Потенциальные баги\n"
"## 🏛 Архитектурные проблемы\n"
"## 💡 Рекомендации\n"
"В каждом пункте ссылайся на конкретный файл (и по возможности строку). "
"Если в разделе замечаний нет — напиши «— замечаний нет». "
"Будь конкретным и кратким, без общих слов."
)

# Diff может быть огромным; ограничиваем, чтобы не раздувать промпт и не упираться
# в контекст модели. Ревьюим «голову» изменений — обычно самое важное сверху.
_MAX_DIFF_CHARS = 12000

_EMPTY_REVIEW = ("В пул-реквесте нет изменений для ревью "
"(пустой diff и список файлов).")


@dataclass
class ReviewResult:
"""Результат AI-ревью пул-реквеста."""
text: str
sources: list[RetrievedChunk] = field(default_factory=list)
files: list[str] = field(default_factory=list)
used_context: bool = True


def _added_lines(diff: str, limit: int = 40) -> list[str]:
"""Содержательные добавленные строки diff (без заголовков `+++`)."""
out: list[str] = []
for line in diff.splitlines():
if line.startswith("+") and not line.startswith("+++"):
body = line[1:].strip()
if body:
out.append(body)
if len(out) >= limit:
break
return out


def _retrieval_query(changed_files: list[str], diff: str) -> str:
"""Собрать поисковый запрос к RAG из путей файлов и добавленного кода."""
parts: list[str] = []
if changed_files:
parts.append("Изменённые файлы: " + ", ".join(changed_files))
added = _added_lines(diff)
if added:
parts.append("\n".join(added))
return "\n".join(parts).strip()


def _build_context(chunks: list[RetrievedChunk]) -> str:
parts: list[str] = []
for i, c in enumerate(chunks, 1):
loc = c.section or c.title or c.source
header = f"[{i}] {c.source}" + (f" — {loc}" if loc and loc != c.source else "")
parts.append(f"{header}\n{c.text.strip()}")
return "\n\n".join(parts)


def _clip_diff(diff: str) -> str:
if len(diff) <= _MAX_DIFF_CHARS:
return diff
return diff[:_MAX_DIFF_CHARS] + "\n… (diff обрезан по размеру)"


def review_pull_request(
diff: str,
changed_files: list[str],
engine: Optional[RetrievalEngine],
client: LLMClient,
params: dict,
top_k: int = 5,
) -> ReviewResult:
"""Найти по diff релевантный контекст проекта и сгенерировать текст ревью."""
changed_files = list(changed_files or [])
diff = diff or ""

if not diff.strip() and not changed_files:
return ReviewResult(
text=_EMPTY_REVIEW,
sources=[],
files=changed_files,
used_context=False,
)

chunks: list[RetrievedChunk] = []
if engine is not None and engine.is_ready():
query = _retrieval_query(changed_files, diff)
if query:
chunks = engine.retrieve(query, top_k=top_k)

context = _build_context(chunks) if chunks else "(контекст проекта не найден)"
files_line = ", ".join(changed_files) if changed_files else "(список файлов недоступен)"
user_msg = (
f"Контекст проекта (документация и код):\n\n{context}\n\n"
f"---\nИзменённые файлы: {files_line}\n\n"
f"---\nDiff пул-реквеста:\n\n{_clip_diff(diff)}"
)
messages = [{"role": "user", "content": user_msg}]

aux = dict(params)
aux.setdefault("temperature", 0.2) # фактологичный разбор, минимум фантазии
text = client.chat(messages, aux, SYSTEM_PROMPT)

return ReviewResult(text=text, sources=chunks, files=changed_files)
9 changes: 9 additions & 0 deletions cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ def resolve_provider(env_value: str) -> str:
# ── RAG ─────────────────────────────────────────────────────────────────────

DEFAULT_RAG_INDEX_PATH = os.path.expanduser("~/rag-kotlin/index")
# Индекс по коду проекта — второй источник RAG для AI-ревью PR (доки + код).
DEFAULT_CODE_INDEX_PATH = os.path.expanduser("~/rag-kotlin/index_jarvis_full")
DEFAULT_RAG_STRATEGY = "structural"
DEFAULT_RAG_TOP_K = 5
DEFAULT_RAG_FETCH_K = 20
Expand Down Expand Up @@ -149,6 +151,13 @@ def _env_bool(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "да")


def code_index_path() -> str:
"""Путь к индексу по коду проекта (для AI-ревью PR). .env → дефолт."""
return os.path.expanduser(
os.environ.get("CODE_INDEX_PATH", "").strip() or DEFAULT_CODE_INDEX_PATH
)


def load_rag_config() -> RetrievalConfig:
"""Собрать конфиг RAG из окружения (.env уже должен быть подгружен)."""
index_path = os.path.expanduser(
Expand Down
20 changes: 19 additions & 1 deletion cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@
WORKING_DIR,
DEFAULT_EMBED_MODEL,
DEFAULT_OLLAMA_URL,
code_index_path,
default_model_for,
load_env,
load_rag_config,
resolve_provider,
)
from cli.help_commands import handle_help
from cli.review_commands import handle_review
from cli.invariant_commands import handle_inv
from cli.know_commands import handle_know
from cli.mcp_commands import handle_mcp
Expand Down Expand Up @@ -98,9 +100,10 @@
from infra.mcp_config_repository import FileMcpConfigRepository
from infra.mcp_git import McpGitContextProvider
from infra.mcp_registry import StdioMcpRegistry
from infra.pr_diff import GhDiffProvider
from infra.profile_repository import FileProfileRepository
from infra.query_rewriter import LLMQueryRewriter
from infra.rag_retrieval import FaissOllamaRetrievalEngine
from infra.rag_retrieval import CompositeRetrievalEngine, FaissOllamaRetrievalEngine
from infra.rerankers import HeuristicReranker, LLMReranker
from infra.session_repository import FileSessionRepository
from infra.task_repository import FileTaskRepository
Expand Down Expand Up @@ -199,6 +202,18 @@ def main():
if rag_config.enabled and not rag_engine.is_ready():
rag_config.enabled = False # индекс/зависимости недоступны — тихо в обычный режим

# AI-ревью PR (/review): RAG по двум индексам сразу — документация + код.
# Берём базовые движки (без rewrite/rerank): для разбора diff достаточно
# прямого косинусного поиска, объединённого композитом.
code_base_engine = FaissOllamaRetrievalEngine(
index_path = code_index_path(),
strategy = rag_config.strategy,
embed_model = DEFAULT_EMBED_MODEL,
ollama_url = DEFAULT_OLLAMA_URL,
)
review_engine = CompositeRetrievalEngine([rag_base_engine, code_base_engine])
diff_provider = GhDiffProvider()

orchestrator = build_default_orchestrator(task_repo)

# MCP: поднимаем все включённые серверы. Если ни одного — registry просто
Expand Down Expand Up @@ -406,6 +421,9 @@ def main():
elif cmd == "/help" or cmd.startswith("/help "):
handle_help(user_input, rag_engine, git_provider, client,
params, top_k=rag_config.top_k)
elif cmd == "/review" or cmd.startswith("/review "):
handle_review(user_input, review_engine, diff_provider, client,
params, top_k=rag_config.top_k)
else:
print(f"{YELLOW}Неизвестная команда. Введите /help.{RESET}")
continue
Expand Down
61 changes: 61 additions & 0 deletions cli/review_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""CLI-обработчик /review.

/review <номер PR> — сгенерировать AI-ревью пул-реквеста: достать diff и
изменённые файлы через GitHub, подмешать RAG-контекст
(документация + код) и вывести структурированный разбор.

Тонкий слой: парсит номер, зовёт use case `review_pull_request`, печатает
результат. В чате ревью только выводится; публикацию комментарием в PR делает
CI-энтрипойнт review_pr.py. Вся логика — в `app/pr_review.py`.
"""
from __future__ import annotations

from typing import Optional

from app.ports import DiffProvider, LLMClient, RetrievalEngine
from app.pr_review import review_pull_request
from cli.ansi import BOLD, CYAN, DIM, GREEN, MAGENTA, RESET, YELLOW
from cli.spinner import Spinner


def handle_review(cmd_str: str,
engine: Optional[RetrievalEngine],
diff_provider: DiffProvider,
client: LLMClient,
params: dict,
top_k: int = 5) -> None:
arg = cmd_str[len("/review"):].strip()

if not arg:
print(f"{YELLOW} Укажи номер PR: {RESET}{DIM}/review 12{RESET}")
return
if not arg.isdigit():
print(f"{YELLOW} Номер PR должен быть числом, например: {RESET}{DIM}/review 12{RESET}")
return

try:
with Spinner(f"Достаю diff PR #{arg} через GitHub..."):
pr = diff_provider.fetch(arg)
except Exception as e:
print(f"{YELLOW} Не удалось получить PR #{arg}: {e}{RESET}")
print(f"{DIM} Проверь, что gh авторизован (gh auth status) и номер PR верный.{RESET}")
return

with Spinner("Анализирую изменения (RAG + LLM)..."):
result = review_pull_request(pr.diff, pr.files, engine, client, params, top_k=top_k)

print(f"\n{BOLD}{GREEN}AI-ревью PR #{arg}:{RESET}\n")
print(result.text)
print()

if result.files:
print(f"{DIM} Изменённые файлы: {CYAN}{', '.join(result.files)}{RESET}")
if result.sources:
locs: list[str] = []
for c in result.sources:
loc = c.section or c.title or c.source
label = loc if loc == c.source else f"{c.source} — {loc}"
if label not in locs:
locs.append(label)
print(f"{DIM} Контекст (RAG): {MAGENTA}{' · '.join(locs)}{RESET}")
print()
1 change: 1 addition & 0 deletions cli/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ def print_help() -> None:
{CYAN}/mem{RESET} — показать все слои памяти
{CYAN}/help{RESET} — эта справка
{CYAN}/help <вопрос>{RESET} — ответ о проекте по документации (RAG) + git-ветка через MCP
{CYAN}/review <PR>{RESET} — AI-ревью пул-реквеста: diff через GitHub + RAG (доки+код)

{DIM}Что куда сохраняется:
краткосрочная → текущий диалог (messages), авто
Expand Down
22 changes: 22 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ Tool calling работает только для DeepSeek. После `add`/`rm
Примеры: `/help какие слои у проекта?`, `/help как устроена память?`,
`/help на какой ветке я работаю?`.

## AI-ревью пул-реквеста (`/review`)

| Команда | Описание |
|---|---|
| `/review <номер PR>` | Сгенерировать AI-ревью PR: diff и изменённые файлы берутся через GitHub (`gh`), контекст — RAG по документации **и** коду, ответ — структурированный разбор |

Ревью выводится тремя разделами: **🐞 Потенциальные баги**, **🏛 Архитектурные
проблемы**, **💡 Рекомендации**. В чате ревью только печатается; в CI его постит
комментарием в PR энтрипойнт `review_pr.py`.

Тот же пайплайн запускается автоматически на каждый PR через GitHub Action
`.github/workflows/ai-review.yml` (self-hosted раннер: локальные эмбеддинги
Ollama + FAISS-индексы). Ручной запуск пайплайна:

```
python3 review_pr.py <номер PR> # сгенерировать и запостить комментарий
python3 review_pr.py <номер PR> --no-comment # только напечатать
```

Второй индекс — по коду проекта — задаётся `CODE_INDEX_PATH`
(по умолчанию `~/rag-kotlin/index_jarvis_full`).

## Задачи и инварианты

- `/task new · list · resume · advance · back · log · abort · done · delete` —
Expand Down
Loading
Loading