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 }}"
39 changes: 39 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 @@ -147,6 +148,44 @@ def all_tools(self) -> list[McpTool]: ...
def shutdown(self) -> None: ...


class GitContextProvider(Protocol):
"""Источник сведений о git-состоянии проекта.

Абстрагирует способ получения данных: конкретная реализация в infra/
ходит за ними в MCP-сервер (`mcp-server-git`). Use case `/help` знает
только про этот порт, поэтому в тестах подменяется фейком.
"""

def current_branch(self) -> Optional[str]:
"""Текущая ветка репозитория или None, если определить не удалось."""
...


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)
84 changes: 84 additions & 0 deletions app/project_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Use case: ответить на вопрос о проекте по его документации.

`/help <вопрос>` — ассистент отвечает, опираясь на RAG-поиск по документации
проекта (README + папка docs + CLAUDE.md) и на текущий git-контекст, полученный
через MCP. Оркестрирует три порта — `RetrievalEngine`, `GitContextProvider`,
`LLMClient` — и ничего не знает об их реализациях (FAISS, mcp-server-git, HTTP).
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional

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

SYSTEM_PROMPT = (
"Ты — ассистент по проекту jarvis-cli. Отвечай на вопрос ТОЛЬКО по "
"приведённому контексту из документации проекта. "
"Если контекста недостаточно — честно скажи об этом, не выдумывай. "
"Указывай конкретные файлы/разделы, откуда взят ответ. "
"Отвечай кратко и по делу, на языке вопроса."
)


@dataclass
class ProjectHelpResult:
"""Результат ответа на вопрос о проекте."""
reply: str
sources: list[RetrievedChunk] = field(default_factory=list)
branch: Optional[str] = None
used_context: bool = True


def _build_context(chunks: list[RetrievedChunk], branch: Optional[str]) -> str:
parts: list[str] = []
if branch:
parts.append(f"[git] Текущая ветка проекта: {branch}")
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 answer_project_question(
question: str,
engine: RetrievalEngine,
git: Optional[GitContextProvider],
client: LLMClient,
params: dict,
top_k: int = 5,
) -> ProjectHelpResult:
"""Найти релевантные куски документации, подмешать git-ветку, спросить LLM."""
question = question.strip()
branch = None
if git is not None:
try:
branch = git.current_branch()
except Exception:
branch = None # git-контекст необязателен — /help работает и без него

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

if not chunks and not branch:
return ProjectHelpResult(
reply="Не нашёл в документации проекта ничего по этому вопросу. "
"Проверь, что RAG-индекс собран и путь RAG_INDEX_PATH указывает "
"на него (см. /rag status).",
sources=[],
branch=branch,
used_context=False,
)

context = _build_context(chunks, branch)
user_msg = f"Контекст проекта:\n\n{context}\n\n---\nВопрос: {question}"
messages = [{"role": "user", "content": user_msg}]

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

return ProjectHelpResult(reply=reply, sources=chunks, branch=branch)
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
57 changes: 57 additions & 0 deletions cli/help_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""CLI-обработчик /help.

/help — статичная справка по командам (как раньше).
/help <вопрос> — ответ на вопрос о проекте по его документации (RAG)
+ текущая git-ветка через MCP.

Тонкий слой: парсит ввод, зовёт use case `answer_project_question`, печатает
ответ и источники. Вся логика — в `app/project_help.py`.
"""
from __future__ import annotations

from typing import Optional

from app.ports import GitContextProvider, LLMClient, RetrievalEngine
from app.project_help import answer_project_question
from cli.ansi import BOLD, CYAN, DIM, GREEN, MAGENTA, RESET, YELLOW
from cli.spinner import Spinner
from cli.views import print_help


def handle_help(cmd_str: str,
engine: Optional[RetrievalEngine],
git: Optional[GitContextProvider],
client: LLMClient,
params: dict,
top_k: int = 5) -> None:
# Отрезаем саму команду «/help», остаётся вопрос.
question = cmd_str[len("/help"):].strip()

if not question:
print_help()
return

if engine is None or not engine.is_ready():
print(f"{YELLOW} RAG-индекс не готов — не могу ответить по документации.{RESET}")
print(f"{DIM} Проверь путь индекса: /rag status. Собрать индекс — "
f"ingest.py по README + docs.{RESET}")
return

with Spinner("Ищу в документации проекта..."):
result = answer_project_question(question, engine, git, client, params, top_k=top_k)

print(f"\n{BOLD}{GREEN}Справка по проекту:{RESET} {result.reply}\n")

if result.branch:
print(f"{DIM} git-ветка (через MCP): {CYAN}{result.branch}{RESET}")
if result.sources:
locs = []
for c in result.sources:
loc = c.section or c.title or c.source
locs.append(loc if loc == c.source else f"{c.source} — {loc}")
seen: list[str] = []
for l in locs:
if l not in seen:
seen.append(l)
print(f"{DIM} Источники: {MAGENTA}{' · '.join(seen)}{RESET}")
print()
Loading
Loading