diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 0000000..a0b3b60 --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -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 }}" diff --git a/app/ports.py b/app/ports.py index ed571b0..8c4a1a5 100644 --- a/app/ports.py +++ b/app/ports.py @@ -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 @@ -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).""" diff --git a/app/pr_review.py b/app/pr_review.py new file mode 100644 index 0000000..3beebed --- /dev/null +++ b/app/pr_review.py @@ -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) diff --git a/app/project_help.py b/app/project_help.py new file mode 100644 index 0000000..ae5afd4 --- /dev/null +++ b/app/project_help.py @@ -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) diff --git a/cli/config.py b/cli/config.py index d798ef3..8705a06 100644 --- a/cli/config.py +++ b/cli/config.py @@ -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 @@ -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( diff --git a/cli/help_commands.py b/cli/help_commands.py new file mode 100644 index 0000000..678cc69 --- /dev/null +++ b/cli/help_commands.py @@ -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() diff --git a/cli/main.py b/cli/main.py index 6f6de8f..35bceec 100644 --- a/cli/main.py +++ b/cli/main.py @@ -46,11 +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 @@ -79,7 +82,6 @@ from cli.views import ( announce_guard_result, announce_task_transitions, - print_help, print_mem_detail, print_memory_status, print_settings, @@ -96,10 +98,12 @@ from infra.invariant_repository import FileInvariantRepository from infra.knowledge_repository import FileKnowledgeRepository 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 @@ -198,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 просто @@ -214,6 +230,14 @@ def main(): for sid, err in mcp_registry.failures(): print(f"{YELLOW}MCP[{sid}] не стартовал: {err}{RESET}") + # Git-контекст для /help берётся через MCP-сервер git. Путь репозитория — + # корень jarvis-cli (переопределяется JARVIS_REPO_PATH). + repo_path = os.path.expanduser( + os.environ.get("JARVIS_REPO_PATH", "").strip() + or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + git_provider = McpGitContextProvider(mcp_registry, repo_path) + tool_router = ToolRouter(client, mcp_registry) \ if provider == DEEPSEEK and mcp_registry.all_tools() else None if tool_router is not None: @@ -394,8 +418,12 @@ def main(): print(f"{BOLD}{GREEN}Jarvis CLI{RESET} {DIM}(новая сессия, /help — справка){RESET}") print(f"{DIM}провайдер: {provider}{RESET}\n") print(f"{DIM}Краткосрочная память очищена. Старая сессия сохранена в истории.{RESET}") - elif cmd == "/help": - print_help() + 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 diff --git a/cli/review_commands.py b/cli/review_commands.py new file mode 100644 index 0000000..a311090 --- /dev/null +++ b/cli/review_commands.py @@ -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() diff --git a/cli/views.py b/cli/views.py index 96d0911..2946809 100644 --- a/cli/views.py +++ b/cli/views.py @@ -360,6 +360,8 @@ def print_help() -> None: {BOLD}Обзор:{RESET} {CYAN}/mem{RESET} — показать все слои памяти {CYAN}/help{RESET} — эта справка + {CYAN}/help <вопрос>{RESET} — ответ о проекте по документации (RAG) + git-ветка через MCP + {CYAN}/review {RESET} — AI-ревью пул-реквеста: diff через GitHub + RAG (доки+код) {DIM}Что куда сохраняется: краткосрочная → текущий диалог (messages), авто diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d262e3f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,60 @@ +# Архитектура Jarvis CLI + +Jarvis CLI — терминальный чат с LLM на Python 3. Единственная обязательная +внешняя зависимость для рантайма — `requests`. Проект движется от «всё в одном +`chat.py`» к чистой слоистой архитектуре. + +## Слои + +Зависимости направлены строго сверху вниз: верхний слой знает об абстракциях +нижнего, но не о его деталях. + +1. **`cli/`** — REPL, парсинг и диспетчеризация команд, ввод/вывод. Здесь нет + бизнес-логики и прямого I/O с диском или сетью. Точка сборки графа + зависимостей (composition root) — `cli/main.py`. +2. **`app/`** — сценарии (use cases): «отправить сообщение», «сохранить + знание», «продвинуть стадию задачи», «ответить на вопрос о проекте». + Оркестрирует домен и порты, но сам не лезет в файлы и сеть. +3. **`domain/`** — чистые модели и правила: `Task`, `TaskState`, + `WorkingMemory`, `Profile`, `Knowledge`, `RetrievalConfig`, `Invariant`. + Без внешних библиотек и без I/O. +4. **`infra/`** — реализации портов: файловые репозитории под `~/.jarvis/`, + LLM-клиенты (`DeepSeekClient`, `RequestsGigaChatClient`), FAISS/Ollama + retrieval, MCP-реестр, ANSI-вывод, спиннер. + +## Порты и Dependency Injection + +Слой `app/` оперирует абстракциями из `app/ports.py` (`SessionRepository`, +`LLMClient`, `RetrievalEngine`, `McpRegistry`, `Clock` и т.д.), а не +конкретными `open()` и `requests`. Конкретные реализации создаются только в +`cli/main.py` и прокидываются в use cases через конструкторы и параметры +функций. Глобального состояния и module-level кэшей нет — всё передаётся явно. +В тестах порты подменяются фейками, реализующими тот же `typing.Protocol`. + +## Провайдеры LLM + +Провайдер выбирается через `LLM_PROVIDER` в `.env` или командой `/provider`: + +- **`deepseek`** (по умолчанию) — OpenAI-совместимый API `api.deepseek.com`, + ключ `DEEPSEEK_API_KEY`. Только для DeepSeek работает вызов инструментов + (tool calling), поэтому MCP-тулы доступны именно на нём. +- **`gigachat`** — Sber GigaChat, OAuth-ключ `GIGACHAT_AUTH_KEY`. TLS-проверка + отключена — особенность Sber API. +- **`ollama`** — локальные модели через `/local`. + +## RAG и MCP + +- **RAG**: `infra/rag_retrieval.py::FaissOllamaRetrievalEngine` читает готовый + FAISS-индекс (`.faiss` + `.meta.json`) и ищет по нему, + эмбеддя запрос локальной моделью Ollama `bge-m3` (1024-dim). Поверх базового + движка `RetrievalPipeline` добавляет rewrite → fetch_k → порог → реранк → + top_k. Настройки — `domain/retrieval.py::RetrievalConfig`, команда `/rag`. +- **MCP**: `infra/mcp_registry.py::StdioMcpRegistry` поднимает настроенные + MCP-серверы (stdio/http), обнаруживает их тулы, а `ToolRouter` даёт модели + их вызывать. Управление — команда `/mcp`. + +## Тесты + +`pytest` обязателен. Структура `tests/` зеркалит пакеты (`tests/app/`, +`tests/domain/`, `tests/infra/`, `tests/cli/`). Новый код без тестов не +считается готовым; зелёный `pytest` — условие сдачи. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..a01535d --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,105 @@ +# Команды Jarvis CLI + +Все команды чата начинаются с `/`. Диспетчер живёт в `cli/main.py`, справка — +`cli/views.py::print_help()`. Новая команда добавляется в оба места. + +## Чат и настройки + +| Команда | Описание | +|---|---| +| `/local` | Быстро переключиться на локальную модель (Ollama) | +| `/provider` | Переключить провайдера: deepseek / gigachat / ollama | +| `/model` | Выбрать модель (список зависит от провайдера) | +| `/temp` | Задать temperature | +| `/tokens` | Задать max_tokens | +| `/ctx` | Задать num_ctx (окно контекста, только Ollama) | +| `/settings` | Текущие настройки | +| `/clear` | Очистить краткосрочную память (диалог) | +| `/quit`, Ctrl+D | Выход | + +## Память + +| Команда | Описание | +|---|---| +| `/mem` | Показать все слои памяти | +| `/wm` | Показать рабочую память | +| `/wm task <описание>` | Установить текущую задачу | +| `/wm set <ключ> <значение>` | Сохранить факт в контекст | +| `/wm note <текст>` | Добавить заметку | +| `/wm del <ключ>` | Удалить ключ из контекста | +| `/wm clear` | Очистить рабочую память | +| `/know list` | Список записей в базе знаний | +| `/know save <имя>` | Сохранить знание | +| `/know show <имя>` | Показать запись | +| `/profile [new\|edit\|delete]` | Управление профилями агента | + +## RAG — поиск по базе документов (`/rag`) + +| Команда | Описание | +|---|---| +| `/rag` | Статус конвейера: rewrite → fetch_k → порог → реранк → top_k | +| `/rag on` · `/rag off` | Включить/выключить подмешивание контекста из базы | +| `/rag reranker ` | Реранкер: none · heuristic · llm | +| `/rag rewrite on\|off` | Переформулировка запроса через LLM перед поиском | +| `/rag threshold ` | Порог косинусной близости (0 — выкл.) | +| `/rag fetchk ` | Сколько кандидатов брать до фильтра/реранка | +| `/rag topk ` | Сколько чанков оставить после реранка | + +Индекс задаётся `RAG_INDEX_PATH` (по умолчанию `~/rag-kotlin/index`), стратегия +— `RAG_STRATEGY` (`structural` / `fixed`). + +## MCP-серверы (`/mcp`) + +| Команда | Описание | +|---|---| +| `/mcp list` | Настроенные серверы и статус | +| `/mcp tools` | Все обнаруженные тулы | +| `/mcp add ...` | Зарегистрировать stdio-сервер | +| `/mcp add --http [--header "K: V"]` | Зарегистрировать http-сервер | +| `/mcp rm ` | Удалить из конфига | +| `/mcp enable\|disable ` | Включить/выключить (нужен рестарт) | + +Tool calling работает только для DeepSeek. После `add`/`rm` — рестарт чата. +Пример подключения git через MCP: + +``` +/mcp add git uvx mcp-server-git --repository ~/AndroidStudioProjects/jarvis-cli +``` + +## Помощь о проекте (`/help`) + +| Команда | Описание | +|---|---| +| `/help` | Статичная справка по всем командам | +| `/help <вопрос>` | Ответ на вопрос о проекте по документации (RAG) + текущая git-ветка через MCP | + +Примеры: `/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` — + движок стадийных задач. +- `/inv list · show · add · rm · edit` — нерушимые ограничения проекта. diff --git a/docs/memory-model.md b/docs/memory-model.md new file mode 100644 index 0000000..25e7b3d --- /dev/null +++ b/docs/memory-model.md @@ -0,0 +1,56 @@ +# Модель памяти Jarvis CLI + +Jarvis использует три изолированных слоя памяти. Каждый хранится отдельно под +`~/.jarvis/` (не в репозитории), данные из слоёв выбираются явно и объединяются +в system prompt при каждом запросе через `app/system_prompt.py::build_system_prompt()`. + +## Слой 1 — Краткосрочная (диалог) + +- Все сообщения текущего диалога (`messages`). +- Сохраняется автоматически в `~/.jarvis/sessions/*.json`, ротация + `MAX_SESSIONS=20`. +- Живёт до команды `/clear`. + +## Слой 2 — Рабочая память + +- Текущая задача, структурированный контекст (ключ→значение) и заметки. +- Сохраняется вручную командами `/wm` в `~/.jarvis/working/current.json`. +- Персистентна между запусками, пока не сброшена `/wm clear`. +- Автоматически попадает в system prompt. + +## Слой 3 — Долговременная память + +- **Профили** — системный промпт агента в markdown, `~/.jarvis/profiles/`, + команда `/profile`. +- **База знаний** — произвольные записи, `~/.jarvis/knowledge/`, команда + `/know`. +- Всегда присутствуют в system prompt. + +## Инварианты (отдельный слой) + +Инварианты — это нерушимые ограничения проекта (стек, архитектура, +бизнес-правила). Их **не путают с памятью**: память можно менять свободно, а +инварианты ограничивают то, что ассистент вправе предлагать. + +- Хранятся по одному JSON-файлу на инвариант в `~/.jarvis/invariants/.json`. +- Доменная модель — `domain/invariant.py` (`Invariant`, `InvariantSet`, + `Violation`, `check()`). Порт — `app.ports.InvariantRepository`. +- Команды — `/inv list · show · add · rm · edit`. + +Двойная защита: + +1. **В prompt** — `build_system_prompt()` добавляет блок `[ИНВАРИАНТЫ — …]` с + явным правилом: если запрос противоречит ограничению — не следовать ему. +2. **Пост-проверка** — `app/invariant_guard.py::guarded_chat()` прогоняет ответ + модели через `InvariantSet.check()`. Нарушения уровня `block` вызывают + feedback-ретрай с просьбой переделать; уровень `warn` не блокирует, но + показывается в UI. + +## Сводка + +| Слой | Где | Жизненный цикл | +|---|---|---| +| Краткосрочная (диалог) | `~/.jarvis/sessions/*.json` | До `/clear` | +| Рабочая (задача/контекст/заметки) | `~/.jarvis/working/current.json` | До `/wm clear` | +| Долговременная (профиль + знания) | `~/.jarvis/profiles/`, `~/.jarvis/knowledge/` | Постоянно | +| Инварианты | `~/.jarvis/invariants/.json` | Постоянно | diff --git a/domain/review.py b/domain/review.py new file mode 100644 index 0000000..b2eb877 --- /dev/null +++ b/domain/review.py @@ -0,0 +1,26 @@ +"""Доменные модели AI-ревью пул-реквестов. + +Чистые данные без I/O: сырой diff пул-реквеста и список изменённых файлов. +Получение этих данных (через `gh`/GitHub) — в infra за портом +`app.ports.DiffProvider`; генерация текста ревью — в use case `app/pr_review.py`. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class PrDiff: + """Изменения одного пул-реквеста для ревью. + + number — номер PR (строкой, как приходит из CI/gh); + diff — unified diff всего PR (base..head); + files — пути изменённых файлов относительно корня репозитория. + """ + number: str + diff: str + files: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + """Нечего ревьюить — ни diff, ни файлов.""" + return not self.diff.strip() and not self.files diff --git a/infra/gh.py b/infra/gh.py new file mode 100644 index 0000000..5820354 --- /dev/null +++ b/infra/gh.py @@ -0,0 +1,27 @@ +"""Тонкая обёртка над GitHub CLI (`gh`). + +Общий транспорт для infra-реализаций, которым нужен GitHub: получить diff PR, +список файлов, оставить комментарий. Держим её отдельно, чтобы `gh`-вызов был в +одном месте и легко подменялся фейком в тестах (провайдеры принимают `run` +через конструктор). +""" +from __future__ import annotations + +import subprocess +from typing import Optional + + +def run_gh(args: list[str], stdin: Optional[str] = None) -> str: + """Выполнить `gh ` и вернуть stdout. Упасть с понятной ошибкой на ненулевом коде.""" + proc = subprocess.run( + ["gh", *args], + input=stdin, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError( + f"gh {' '.join(args)} завершился с кодом {proc.returncode}: " + f"{proc.stderr.strip()}" + ) + return proc.stdout diff --git a/infra/mcp_git.py b/infra/mcp_git.py new file mode 100644 index 0000000..18315a3 --- /dev/null +++ b/infra/mcp_git.py @@ -0,0 +1,73 @@ +"""Получение git-ветки проекта через MCP-сервер (`mcp-server-git`). + +Реализует порт `app.ports.GitContextProvider`. Находит среди запущенных +MCP-серверов подходящий git-тул, вызывает его и вытаскивает имя текущей ветки +из текстового ответа. Ничего не парсит из локального git напрямую — данные +идут именно через MCP, как требует задание. + +Регистрация сервера в чате: + /mcp add git uvx mcp-server-git --repository ~/AndroidStudioProjects/jarvis-cli +""" +from __future__ import annotations + +import re +from typing import Optional + +from app.ports import McpRegistry + +# Порядок предпочтения тулов: git_status печатает «On branch », +# git_branch — список веток с «* » у текущей. +_TOOL_PREFERENCE = ("git_status", "git_branch") + +_ON_BRANCH = re.compile(r"On branch (\S+)") +_CURRENT_BRANCH_MARK = re.compile(r"^\*\s+(\S+)", re.MULTILINE) + + +def parse_branch(text: str) -> Optional[str]: + """Достать имя ветки из вывода git_status или git_branch.""" + if not text: + return None + m = _ON_BRANCH.search(text) + if m: + return m.group(1) + m = _CURRENT_BRANCH_MARK.search(text) + if m: + return m.group(1) + return None + + +class McpGitContextProvider: + """Берёт текущую git-ветку у первого доступного git-MCP-сервера.""" + + def __init__(self, registry: McpRegistry, repo_path: str): + self._registry = registry + self._repo_path = repo_path + + def _find_tool(self): + """Вернуть (client, tool_name) для наиболее подходящего git-тула.""" + tools = self._registry.all_tools() + by_name = {t.name: t for t in tools} + chosen = None + for pref in _TOOL_PREFERENCE: + if pref in by_name: + chosen = by_name[pref] + break + if chosen is None: # ни status/branch — берём любой git-тул как запасной + for t in tools: + if t.name.startswith("git") or "git" in t.server_id.lower(): + chosen = t + break + if chosen is None: + return None + client = self._registry.get(chosen.server_id) + return (client, chosen.name) if client is not None else None + + def current_branch(self) -> Optional[str]: + found = self._find_tool() + if found is None: + return None + client, tool_name = found + result = client.call_tool(tool_name, {"repo_path": self._repo_path}) + if result.is_error: + return None + return parse_branch(result.text) diff --git a/infra/pr_diff.py b/infra/pr_diff.py new file mode 100644 index 0000000..a307567 --- /dev/null +++ b/infra/pr_diff.py @@ -0,0 +1,39 @@ +"""Получение diff и списка изменённых файлов пул-реквеста через GitHub CLI. + +Реализует порт `app.ports.DiffProvider`. Берёт данные реального PR (base..head) +командами `gh pr diff` и `gh pr view --json files`, поэтому работает и с +форк-ветками, и с любыми базовыми ветками — в отличие от локального git diff. + +Транспорт `run` инжектится через конструктор: в тестах подменяется фейком, +в проде — `infra.gh.run_gh`. +""" +from __future__ import annotations + +import json +from typing import Callable, Optional + +from domain.review import PrDiff +from infra.gh import run_gh + + +class GhDiffProvider: + """DiffProvider поверх `gh`.""" + + def __init__(self, run: Optional[Callable[..., str]] = None): + self._run = run or run_gh + + def fetch(self, pr: str) -> PrDiff: + pr = str(pr) + diff = self._run(["pr", "diff", pr]) + files_json = self._run(["pr", "view", pr, "--json", "files"]) + files = _parse_files(files_json) + return PrDiff(number=pr, diff=diff, files=files) + + +def _parse_files(files_json: str) -> list[str]: + """Достать список путей из вывода `gh pr view --json files`.""" + try: + data = json.loads(files_json) + except (ValueError, TypeError): + return [] + return [f["path"] for f in data.get("files", []) if f.get("path")] diff --git a/infra/rag_retrieval.py b/infra/rag_retrieval.py index 9081951..c44e9bd 100644 --- a/infra/rag_retrieval.py +++ b/infra/rag_retrieval.py @@ -100,3 +100,28 @@ def retrieve(self, query: str, top_k: int = 5) -> list[RetrievedChunk]: chunk_id=m.get("chunk_id", ""), )) return hits + + +class CompositeRetrievalEngine: + """Объединяет несколько движков в один порт `RetrievalEngine`. + + Нужен AI-ревью: контекст берётся сразу из двух индексов — документации и + кода. Каждый под-движок опрашивается по одному запросу, результаты + сливаются и переупорядочиваются по косинусной близости, наверх выходит + top_k. Не готовые под-движки (нет индекса/зависимостей) молча пропускаются, + поэтому один отсутствующий индекс не ломает ревью. + """ + + def __init__(self, engines: list): + self._engines = list(engines) + + def is_ready(self) -> bool: + return any(e.is_ready() for e in self._engines) + + def retrieve(self, query: str, top_k: int = 5) -> list[RetrievedChunk]: + merged: list[RetrievedChunk] = [] + for engine in self._engines: + if engine.is_ready(): + merged.extend(engine.retrieve(query, top_k=top_k)) + merged.sort(key=lambda c: c.score, reverse=True) + return merged[:top_k] diff --git a/infra/review_publisher.py b/infra/review_publisher.py new file mode 100644 index 0000000..44655ac --- /dev/null +++ b/infra/review_publisher.py @@ -0,0 +1,21 @@ +"""Публикация текста ревью комментарием в пул-реквест через GitHub CLI. + +Реализует порт `app.ports.ReviewPublisher`. Тело ревью передаётся в +`gh pr comment --body-file -` через stdin, чтобы не создавать временных файлов +и не спотыкаться на спецсимволах Markdown в аргументах командной строки. +""" +from __future__ import annotations + +from typing import Callable, Optional + +from infra.gh import run_gh + + +class GhReviewPublisher: + """ReviewPublisher поверх `gh pr comment`.""" + + def __init__(self, run: Optional[Callable[..., str]] = None): + self._run = run or run_gh + + def publish(self, pr: str, body: str) -> None: + self._run(["pr", "comment", str(pr), "--body-file", "-"], stdin=body) diff --git a/review_pr.py b/review_pr.py new file mode 100644 index 0000000..8e29194 --- /dev/null +++ b/review_pr.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""AI-ревью пул-реквеста — composition root для CI (GitHub Action). + +Запуск: + python3 review_pr.py <номер PR> [--no-comment] + +Собирает RAG по двум индексам (документация + код), LLM-клиент и gh-провайдеры, +гоняет use case `review_pull_request`, печатает ревью и по умолчанию публикует +его комментарием в PR. `--no-comment` — только напечатать, ничего не постить. + +Провайдер LLM берётся из LLM_PROVIDER (окружение раннера или .env рядом со +скриптом), ключи — из окружения, как и в основном CLI. `gh` должен быть +авторизован (в CI — через GH_TOKEN). +""" +from __future__ import annotations + +import os +import sys + +from app.pr_review import review_pull_request +from cli.config import ( + DEFAULT_EMBED_MODEL, + DEFAULT_OLLAMA_URL, + DEFAULT_PARAMS, + code_index_path, + default_model_for, + load_env, + load_rag_config, + resolve_provider, +) +from cli.main import _build_client +from infra.pr_diff import GhDiffProvider +from infra.rag_retrieval import CompositeRetrievalEngine, FaissOllamaRetrievalEngine +from infra.review_publisher import GhReviewPublisher + + +def _format_comment(result) -> str: + """Оформить ревью как Markdown-комментарий к PR.""" + lines = ["## 🤖 AI-ревью", "", result.text.strip()] + 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) + lines += ["", "---", "_Контекст (RAG): " + " · ".join(locs) + "_"] + lines += ["", "_Сгенерировано jarvis-cli · review_pr.py_"] + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + positional = [a for a in argv if not a.startswith("-")] + flags = {a for a in argv if a.startswith("-")} + if not positional: + print("Использование: python3 review_pr.py <номер PR> [--no-comment]", + file=sys.stderr) + return 2 + pr = positional[0] + + # .env лежит рядом с этим скриптом (в CI обычно отсутствует — тогда ключи + # приходят из окружения раннера). + load_env(os.path.join(os.path.dirname(os.path.realpath(__file__)), ".env")) + + provider = resolve_provider(os.environ.get("LLM_PROVIDER", "")) + params = dict(DEFAULT_PARAMS) + params["model"] = default_model_for(provider) + client = _build_client(provider) + + rag_config = load_rag_config() + docs_engine = FaissOllamaRetrievalEngine( + index_path=rag_config.index_path, strategy=rag_config.strategy, + embed_model=DEFAULT_EMBED_MODEL, ollama_url=DEFAULT_OLLAMA_URL) + code_engine = FaissOllamaRetrievalEngine( + index_path=code_index_path(), strategy=rag_config.strategy, + embed_model=DEFAULT_EMBED_MODEL, ollama_url=DEFAULT_OLLAMA_URL) + engine = CompositeRetrievalEngine([docs_engine, code_engine]) + + diff_provider = GhDiffProvider() + + print(f"Достаю diff PR #{pr} через gh…", file=sys.stderr) + pr_diff = diff_provider.fetch(pr) + print(f"Изменено файлов: {len(pr_diff.files)}. Генерирую ревью " + f"(provider={provider}, RAG={'on' if engine.is_ready() else 'off'})…", + file=sys.stderr) + + result = review_pull_request(pr_diff.diff, pr_diff.files, engine, client, + params, top_k=rag_config.top_k) + + body = _format_comment(result) + print(body) + + if "--no-comment" not in flags: + GhReviewPublisher().publish(pr, body) + print(f"\nРевью опубликовано комментарием в PR #{pr}.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/app/test_pr_review.py b/tests/app/test_pr_review.py new file mode 100644 index 0000000..f9370e2 --- /dev/null +++ b/tests/app/test_pr_review.py @@ -0,0 +1,112 @@ +"""Юнит-тесты use case AI-ревью PR — фейковые порты RetrievalEngine и LLMClient. + +Без FAISS, gh и сети: проверяем сборку контекста из diff, вызов LLM и обработку +пустого diff. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.pr_review import review_pull_request +from domain.retrieval import RetrievedChunk + +_DIFF = """diff --git a/app/foo.py b/app/foo.py +index 111..222 100644 +--- a/app/foo.py ++++ b/app/foo.py +@@ -1,3 +1,5 @@ ++def divide(a, b): ++ return a / b +""" + + +@dataclass +class _FakeEngine: + chunks: list = field(default_factory=list) + ready: bool = True + last_query: str = "" + last_top_k: int = 0 + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_query = query + self.last_top_k = top_k + return list(self.chunks) + + +@dataclass +class _FakeClient: + reply: str = "## 🐞 Потенциальные баги\n- деление на ноль в app/foo.py" + last_system: str = "" + last_messages: list = field(default_factory=list) + calls: int = 0 + + def chat(self, messages, params, system_prompt=None): + self.calls += 1 + self.last_system = system_prompt or "" + self.last_messages = messages + return self.reply + + +def _chunks(): + return [RetrievedChunk(text="Слой app оркестрирует порты.", + source="docs/architecture.md", section="Слои", + chunk_id="a#1", score=0.9)] + + +def test_review_uses_diff_and_retrieval(): + eng = _FakeEngine(chunks=_chunks()) + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], eng, client, {}, top_k=4) + + assert res.text == client.reply + assert res.files == ["app/foo.py"] + assert res.sources and res.sources[0].source == "docs/architecture.md" + assert res.used_context + assert eng.last_top_k == 4 + # добавленный код и путь файла попали в поисковый запрос + assert "divide" in eng.last_query and "app/foo.py" in eng.last_query + # diff и контекст попали в сообщение к LLM + ctx = client.last_messages[0]["content"] + assert "divide" in ctx and "Слой app оркестрирует" in ctx + + +def test_system_prompt_requires_three_sections(): + client = _FakeClient() + review_pull_request(_DIFF, ["app/foo.py"], _FakeEngine(chunks=_chunks()), + client, {}) + for section in ("Потенциальные баги", "Архитектурные проблемы", "Рекомендации"): + assert section in client.last_system + + +def test_empty_diff_returns_hint_without_llm_call(): + client = _FakeClient() + res = review_pull_request("", [], _FakeEngine(chunks=_chunks()), client, {}) + assert not res.used_context + assert client.calls == 0 + assert client.last_messages == [] + + +def test_engine_not_ready_skips_retrieval_but_still_reviews(): + eng = _FakeEngine(chunks=_chunks(), ready=False) + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], eng, client, {}) + assert eng.last_query == "" # ретрив пропущен + assert client.calls == 1 # но ревью по diff сделано + assert res.sources == [] + + +def test_no_engine_still_reviews(): + client = _FakeClient() + res = review_pull_request(_DIFF, ["app/foo.py"], None, client, {}) + assert client.calls == 1 and res.text == client.reply + + +def test_long_diff_is_clipped(): + client = _FakeClient() + big = "diff --git a/x b/x\n" + "\n".join(f"+line {i}" for i in range(20000)) + review_pull_request(big, ["x"], None, client, {}) + ctx = client.last_messages[0]["content"] + assert "diff обрезан по размеру" in ctx diff --git a/tests/app/test_project_help.py b/tests/app/test_project_help.py new file mode 100644 index 0000000..f52dc1b --- /dev/null +++ b/tests/app/test_project_help.py @@ -0,0 +1,109 @@ +"""Юнит-тесты use case /help — ответ о проекте по документации + git-ветка. + +Все порты (RetrievalEngine, GitContextProvider, LLMClient) подменены фейками — +без FAISS, MCP и сети. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.project_help import answer_project_question +from domain.retrieval import RetrievedChunk + + +@dataclass +class _FakeEngine: + chunks: list = field(default_factory=list) + ready: bool = True + last_query: str = "" + last_top_k: int = 0 + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_query = query + self.last_top_k = top_k + return list(self.chunks) + + +@dataclass +class _FakeGit: + branch = "main" + + def current_branch(self): + return self.branch + + +class _BoomGit: + def current_branch(self): + raise RuntimeError("mcp down") + + +@dataclass +class _FakeClient: + reply: str = "Проект разбит на слои cli/app/domain/infra [1]." + last_system: str = "" + last_messages: list = field(default_factory=list) + + def chat(self, messages, params, system_prompt=None): + self.last_system = system_prompt or "" + self.last_messages = messages + return self.reply + + +def _chunks(): + return [ + RetrievedChunk(text="cli/ app/ domain/ infra/ — четыре слоя.", + source="docs/architecture.md", section="Слои", chunk_id="a#1"), + ] + + +def test_answer_uses_retrieval_and_git_branch(): + eng = _FakeEngine(chunks=_chunks()) + client = _FakeClient() + res = answer_project_question("какие слои?", eng, _FakeGit(), client, {}, top_k=3) + + assert res.reply == client.reply + assert res.branch == "main" + assert res.sources and res.sources[0].source == "docs/architecture.md" + assert eng.last_top_k == 3 + # git-ветка и текст чанка попали в контекст запроса к LLM + ctx = client.last_messages[0]["content"] + assert "main" in ctx and "четыре слоя" in ctx + + +def test_git_failure_is_tolerated(): + res = answer_project_question("вопрос", _FakeEngine(chunks=_chunks()), + _BoomGit(), _FakeClient(), {}) + assert res.branch is None + assert res.reply # ответ всё равно сформирован по докам + + +def test_no_git_provider_still_answers(): + res = answer_project_question("вопрос", _FakeEngine(chunks=_chunks()), + None, _FakeClient(), {}) + assert res.branch is None and res.used_context + + +def test_empty_index_and_no_branch_returns_hint_without_llm_call(): + client = _FakeClient(reply="НЕ ДОЛЖНО ВЫЗЫВАТЬСЯ") + eng = _FakeEngine(chunks=[], ready=True) + + class _NoBranch: + def current_branch(self): + return None + + res = answer_project_question("вопрос", eng, _NoBranch(), client, {}) + assert not res.used_context + assert res.reply != client.reply # LLM не звался — вернулась подсказка + assert client.last_messages == [] + + +def test_engine_not_ready_skips_retrieval_but_git_still_used(): + eng = _FakeEngine(chunks=_chunks(), ready=False) + client = _FakeClient() + res = answer_project_question("вопрос", eng, _FakeGit(), client, {}) + # ретрив пропущен (движок не готов), но ветка есть → отвечаем по git-контексту + assert eng.last_query == "" and res.branch == "main" + assert "main" in client.last_messages[0]["content"] diff --git a/tests/cli/test_help_commands.py b/tests/cli/test_help_commands.py new file mode 100644 index 0000000..58b8e33 --- /dev/null +++ b/tests/cli/test_help_commands.py @@ -0,0 +1,62 @@ +"""Тесты обработчика /help: развилка «статичная справка» vs «вопрос о проекте».""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from cli.help_commands import handle_help +from domain.retrieval import RetrievedChunk + + +@dataclass +class _FakeEngine: + ready: bool = True + chunks: list = field(default_factory=list) + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + return list(self.chunks) + + +@dataclass +class _FakeGit: + def current_branch(self): + return "main" + + +@dataclass +class _FakeClient: + reply: str = "Ответ по докам [1]." + + def chat(self, messages, params, system_prompt=None): + return self.reply + + +def _chunks(): + return [RetrievedChunk(text="слои cli/app/domain/infra", source="docs/architecture.md", + section="Слои", chunk_id="a#1")] + + +def test_no_question_prints_static_help(capsys): + handle_help("/help", _FakeEngine(), _FakeGit(), _FakeClient(), {}) + out = capsys.readouterr().out + # признак статичной справки print_help() + assert "/wm" in out and "/rag" in out + assert "Справка по проекту" not in out + + +def test_question_answers_from_docs_with_branch_and_sources(capsys): + handle_help("/help какие слои у проекта?", _FakeEngine(chunks=_chunks()), + _FakeGit(), _FakeClient(reply="Проект слоистый."), {}) + out = capsys.readouterr().out + assert "Справка по проекту:" in out and "Проект слоистый." in out + assert "main" in out # git-ветка через MCP + assert "docs/architecture.md" in out # источник + + +def test_index_not_ready_warns(capsys): + handle_help("/help вопрос", _FakeEngine(ready=False), _FakeGit(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "не готов" in out + assert "Справка по проекту" not in out diff --git a/tests/cli/test_review_commands.py b/tests/cli/test_review_commands.py new file mode 100644 index 0000000..2100a37 --- /dev/null +++ b/tests/cli/test_review_commands.py @@ -0,0 +1,81 @@ +"""Тесты обработчика /review: парсинг номера PR и вывод ревью.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from cli.review_commands import handle_review +from domain.retrieval import RetrievedChunk +from domain.review import PrDiff + + +@dataclass +class _FakeEngine: + ready: bool = True + chunks: list = field(default_factory=list) + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + return list(self.chunks) + + +@dataclass +class _FakeDiffProvider: + diff: str = "diff --git a/app/x.py b/app/x.py\n+code" + files: list = field(default_factory=lambda: ["app/x.py"]) + fetched: list = field(default_factory=list) + + def fetch(self, pr): + self.fetched.append(pr) + return PrDiff(number=str(pr), diff=self.diff, files=list(self.files)) + + +@dataclass +class _BoomDiffProvider: + def fetch(self, pr): + raise RuntimeError("gh not authed") + + +@dataclass +class _FakeClient: + reply: str = "## 🐞 Потенциальные баги\n- баг в app/x.py" + + def chat(self, messages, params, system_prompt=None): + return self.reply + + +def _chunks(): + return [RetrievedChunk(text="слой app", source="docs/architecture.md", + section="Слои", chunk_id="a#1", score=0.8)] + + +def test_no_arg_prints_usage(capsys): + handle_review("/review", _FakeEngine(), _FakeDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "номер PR" in out + assert "AI-ревью" not in out + + +def test_non_numeric_arg_warns(capsys): + handle_review("/review abc", _FakeEngine(), _FakeDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "должен быть числом" in out + + +def test_valid_pr_prints_review_files_and_sources(capsys): + provider = _FakeDiffProvider() + handle_review("/review 12", _FakeEngine(chunks=_chunks()), provider, + _FakeClient(reply="## 🐞 Потенциальные баги\n- баг"), {}) + out = capsys.readouterr().out + assert provider.fetched == ["12"] + assert "AI-ревью PR #12" in out + assert "Потенциальные баги" in out + assert "app/x.py" in out # изменённые файлы + assert "docs/architecture.md" in out # источник RAG + + +def test_fetch_error_is_reported(capsys): + handle_review("/review 5", _FakeEngine(), _BoomDiffProvider(), _FakeClient(), {}) + out = capsys.readouterr().out + assert "Не удалось получить PR #5" in out diff --git a/tests/infra/test_composite_retrieval.py b/tests/infra/test_composite_retrieval.py new file mode 100644 index 0000000..71a4083 --- /dev/null +++ b/tests/infra/test_composite_retrieval.py @@ -0,0 +1,50 @@ +"""Тесты CompositeRetrievalEngine: слияние результатов двух индексов (доки+код).""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from domain.retrieval import RetrievedChunk +from infra.rag_retrieval import CompositeRetrievalEngine + + +@dataclass +class _FakeEngine: + ready: bool + hits: list = field(default_factory=list) + last_top_k: int = 0 + + def is_ready(self): + return self.ready + + def retrieve(self, query, top_k=5): + self.last_top_k = top_k + return list(self.hits) + + +def _chunk(src, score): + return RetrievedChunk(text=src, source=src, score=score) + + +def test_merges_and_sorts_by_score(): + docs = _FakeEngine(True, [_chunk("docs.md", 0.5), _chunk("docs2.md", 0.9)]) + code = _FakeEngine(True, [_chunk("code.py", 0.7)]) + comp = CompositeRetrievalEngine([docs, code]) + + hits = comp.retrieve("q", top_k=2) + assert [h.source for h in hits] == ["docs2.md", "code.py"] # 0.9, 0.7 (0.5 отсечён top_k) + + +def test_skips_not_ready_engines(): + docs = _FakeEngine(False, [_chunk("docs.md", 0.9)]) + code = _FakeEngine(True, [_chunk("code.py", 0.4)]) + comp = CompositeRetrievalEngine([docs, code]) + + hits = comp.retrieve("q", top_k=5) + assert [h.source for h in hits] == ["code.py"] + assert docs.last_top_k == 0 # не опрашивался + + +def test_is_ready_if_any_subengine_ready(): + assert CompositeRetrievalEngine([_FakeEngine(False), _FakeEngine(True)]).is_ready() + assert not CompositeRetrievalEngine([_FakeEngine(False), _FakeEngine(False)]).is_ready() + assert not CompositeRetrievalEngine([]).is_ready() diff --git a/tests/infra/test_mcp_git.py b/tests/infra/test_mcp_git.py new file mode 100644 index 0000000..bb43991 --- /dev/null +++ b/tests/infra/test_mcp_git.py @@ -0,0 +1,102 @@ +"""Тесты McpGitContextProvider: git-ветка через фейковый MCP-реестр.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from domain.mcp import McpTool, ToolResult +from infra.mcp_git import McpGitContextProvider, parse_branch + + +def test_parse_branch_on_branch_form(): + assert parse_branch("On branch feature/help\nnothing to commit") == "feature/help" + + +def test_parse_branch_star_form(): + assert parse_branch(" main\n* develop\n release/1.0") == "develop" + + +def test_parse_branch_none(): + assert parse_branch("no branch info here") is None + assert parse_branch("") is None + + +@dataclass +class _FakeClient: + server_id: str + tools: list + result: ToolResult + calls: list = field(default_factory=list) + + def list_tools(self): + return list(self.tools) + + def call_tool(self, name, args): + self.calls.append((name, args)) + return self.result + + +@dataclass +class _FakeRegistry: + _clients: dict # server_id -> _FakeClient + + def clients(self): + return list(self._clients.values()) + + def get(self, server_id): + return self._clients.get(server_id) + + def all_tools(self): + out = [] + for c in self._clients.values(): + out.extend(c.tools) + return out + + def shutdown(self): + pass + + +def _tool(server_id, name): + return McpTool(server_id=server_id, name=name, description="", input_schema={}) + + +def test_prefers_git_status_and_passes_repo_path(): + client = _FakeClient( + server_id="git", + tools=[_tool("git", "git_status"), _tool("git", "git_log")], + result=ToolResult(text="On branch main\nnothing to commit"), + ) + reg = _FakeRegistry({"git": client}) + provider = McpGitContextProvider(reg, "/repo") + + assert provider.current_branch() == "main" + assert client.calls == [("git_status", {"repo_path": "/repo"})] + + +def test_falls_back_to_git_branch_tool(): + client = _FakeClient( + server_id="git", + tools=[_tool("git", "git_branch")], + result=ToolResult(text=" main\n* develop"), + ) + reg = _FakeRegistry({"git": client}) + assert McpGitContextProvider(reg, "/repo").current_branch() == "develop" + + +def test_no_git_tool_returns_none(): + client = _FakeClient(server_id="fs", tools=[_tool("fs", "read_file")], + result=ToolResult(text="x")) + reg = _FakeRegistry({"fs": client}) + assert McpGitContextProvider(reg, "/repo").current_branch() is None + + +def test_tool_error_returns_none(): + client = _FakeClient( + server_id="git", tools=[_tool("git", "git_status")], + result=ToolResult(text="fatal: not a repo", is_error=True), + ) + reg = _FakeRegistry({"git": client}) + assert McpGitContextProvider(reg, "/repo").current_branch() is None + + +def test_empty_registry_returns_none(): + assert McpGitContextProvider(_FakeRegistry({}), "/repo").current_branch() is None diff --git a/tests/infra/test_pr_diff.py b/tests/infra/test_pr_diff.py new file mode 100644 index 0000000..9c12277 --- /dev/null +++ b/tests/infra/test_pr_diff.py @@ -0,0 +1,47 @@ +"""Тесты GhDiffProvider: diff и файлы PR через фейковый `gh`-раннер.""" +from __future__ import annotations + +from infra.pr_diff import GhDiffProvider + + +class _FakeRun: + """Фейк infra.gh.run_gh: возвращает заранее заданный вывод по под-команде.""" + + def __init__(self, diff="", files_json="{}"): + self._diff = diff + self._files_json = files_json + self.calls: list[list[str]] = [] + + def __call__(self, args, stdin=None): + self.calls.append(args) + if args[:2] == ["pr", "diff"]: + return self._diff + if args[:2] == ["pr", "view"]: + return self._files_json + raise AssertionError(f"неожиданный вызов gh: {args}") + + +def test_fetch_returns_diff_and_files(): + run = _FakeRun( + diff="diff --git a/x b/x\n+hello", + files_json='{"files":[{"path":"a.py"},{"path":"b.py"}]}', + ) + pr = GhDiffProvider(run=run).fetch("12") + + assert pr.number == "12" + assert pr.diff == "diff --git a/x b/x\n+hello" + assert pr.files == ["a.py", "b.py"] + assert run.calls == [["pr", "diff", "12"], ["pr", "view", "12", "--json", "files"]] + + +def test_fetch_tolerates_empty_files_json(): + run = _FakeRun(diff="", files_json="") + pr = GhDiffProvider(run=run).fetch("7") + assert pr.files == [] and pr.is_empty() + + +def test_fetch_coerces_int_pr_to_str(): + run = _FakeRun(diff="d", files_json='{"files":[]}') + pr = GhDiffProvider(run=run).fetch(5) + assert pr.number == "5" + assert run.calls[0] == ["pr", "diff", "5"] diff --git a/tests/infra/test_review_publisher.py b/tests/infra/test_review_publisher.py new file mode 100644 index 0000000..3efe230 --- /dev/null +++ b/tests/infra/test_review_publisher.py @@ -0,0 +1,28 @@ +"""Тест GhReviewPublisher: комментарий к PR через фейковый `gh`-раннер.""" +from __future__ import annotations + +from infra.review_publisher import GhReviewPublisher + + +class _FakeRun: + def __init__(self): + self.calls: list[tuple] = [] + + def __call__(self, args, stdin=None): + self.calls.append((args, stdin)) + return "" + + +def test_publish_posts_body_via_stdin(): + run = _FakeRun() + GhReviewPublisher(run=run).publish("12", "## Ревью\nтело") + + args, stdin = run.calls[0] + assert args == ["pr", "comment", "12", "--body-file", "-"] + assert stdin == "## Ревью\nтело" + + +def test_publish_coerces_pr_to_str(): + run = _FakeRun() + GhReviewPublisher(run=run).publish(9, "x") + assert run.calls[0][0][2] == "9"