276 lines
8.9 KiB
Python
276 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI Code Review for Filwords PL Backend
|
|
|
|
Читает Python-файлы проекта, отправляет в DeepSeek V4.1 Flash (Together AI),
|
|
получает структурированное ТЗ и создаёт Issue в Gitea.
|
|
|
|
Запускается через Gitea Actions (workflow_dispatch — вручную из UI).
|
|
|
|
(20.09.2026) Первая версия — конвейер без tool-calling, предсказуемо по токенам.
|
|
(20.09.2026, вечер) Фикс: tool_choice=none, безопасное извлечение content.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
|
|
# ============ Настройки ============
|
|
|
|
TOGETHER_API_KEY = os.environ.get("TOGETHER_API_KEY")
|
|
GITEA_BOT_TOKEN = os.environ.get("BOT_TOKEN")
|
|
GITEA_INSTANCE = os.environ.get("GITEA_INSTANCE", "https://git.filwordspl.com")
|
|
GITEA_REPO = os.environ.get("GITEA_REPO", "Drobysh/filwordspl-backend")
|
|
|
|
MODEL = "deepseek-ai/DeepSeek-V4.1-Flash"
|
|
MAX_OUTPUT_TOKENS = 4000
|
|
|
|
# Что игнорировать при сборе кода
|
|
IGNORE_DIRS = {
|
|
"venv", ".venv", "__pycache__", ".git", ".gitea",
|
|
"tests", "archive", "uploads", "tts_cache",
|
|
"node_modules", "dist",
|
|
}
|
|
IGNORE_FILES = {"ai_review.py", "conftest.py"}
|
|
|
|
# Расширения, которые читаем
|
|
INCLUDE_EXTENSIONS = {".py"}
|
|
|
|
# Максимальный размер одного файла (символов) — большие контент-файлы пропускаем
|
|
MAX_FILE_SIZE = 100_000
|
|
|
|
|
|
# ============ Проверка окружения ============
|
|
|
|
def check_env() -> None:
|
|
missing = []
|
|
if not TOGETHER_API_KEY:
|
|
missing.append("TOGETHER_API_KEY")
|
|
if not GITEA_BOT_TOKEN:
|
|
missing.append("BOT_TOKEN")
|
|
if missing:
|
|
print(f"❌ Отсутствуют переменные окружения: {', '.join(missing)}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# ============ Сбор кода ============
|
|
|
|
def collect_code(repo_root: Path) -> str:
|
|
"""Собирает все .py файлы в один текст с заголовками."""
|
|
chunks: list[str] = []
|
|
total_files = 0
|
|
total_size = 0
|
|
|
|
for path in sorted(repo_root.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
if path.suffix not in INCLUDE_EXTENSIONS:
|
|
continue
|
|
|
|
# Пропускаем игнорируемые папки
|
|
rel = path.relative_to(repo_root)
|
|
if any(part in IGNORE_DIRS for part in rel.parts):
|
|
continue
|
|
if path.name in IGNORE_FILES:
|
|
continue
|
|
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
print(f"⚠️ Не прочитал {rel}: {e}", file=sys.stderr)
|
|
continue
|
|
|
|
# Пропускаем слишком большие файлы
|
|
if len(content) > MAX_FILE_SIZE:
|
|
print(f"⚠️ Пропущен большой файл: {rel} ({len(content)} символов)", file=sys.stderr)
|
|
continue
|
|
|
|
chunks.append(f"### FILE: {rel}\n```python\n{content}\n```\n")
|
|
total_files += 1
|
|
total_size += len(content)
|
|
|
|
print(f"📂 Собрано файлов: {total_files}, символов: {total_size:,}")
|
|
return "\n".join(chunks)
|
|
|
|
|
|
# ============ Промпт для модели ============
|
|
|
|
SYSTEM_PROMPT = """Ты — опытный техлид и ревьюер Python-кода (FastAPI, asyncpg, PostgreSQL).
|
|
|
|
Твоя задача — проанализировать код и выдать СТРУКТУРИРОВАННОЕ ТЗ для разработчика.
|
|
|
|
ПРАВИЛА:
|
|
1. НЕ переписывай код. Только описывай, что не так и что сделать.
|
|
2. Каждый пункт — КОНКРЕТНЫЙ: файл → строка/функция → проблема → что сделать.
|
|
3. Без воды. Без общих слов. Без объяснений «что такое XSS» — только факт и фикс.
|
|
4. Приоритеты: 🔴 критично / 🟡 важно / 🟢 желательно.
|
|
5. Если проблем мало — лучше меньше пунктов, но точных.
|
|
6. Максимум 20 пунктов — только самое важное.
|
|
|
|
ФОРМАТ ОТВЕТА (markdown):
|
|
|
|
## 🔴 Критично
|
|
|
|
### 1. [короткое название]
|
|
- **Файл:** `path/to/file.py:123`
|
|
- **Проблема:** что не так
|
|
- **Что сделать:** конкретное действие
|
|
- **Критерий:** как проверить, что исправлено
|
|
|
|
## 🟡 Важно
|
|
(то же самое)
|
|
|
|
## 🟢 Желательно
|
|
(то же самое)
|
|
|
|
Если проблем нет — напиши: "✅ Критичных проблем не найдено".
|
|
"""
|
|
|
|
|
|
USER_PROMPT_TEMPLATE = """Проанализируй код проекта Filwords PL (backend).
|
|
|
|
Это FastAPI + PostgreSQL + asyncpg. Есть:
|
|
- routers/ — эндпоинты
|
|
- services/ — бизнес-логика
|
|
- database.py — схема БД
|
|
- auth.py — HMAC-SHA256 initData
|
|
|
|
Ищи:
|
|
1. Баги и логические ошибки
|
|
2. Проблемы безопасности (утечки, инъекции, неправильная валидация)
|
|
3. N+1 запросы к БД
|
|
4. Отсутствие обработки ошибок
|
|
5. Дублирование кода
|
|
6. Мёртвый код
|
|
7. Слабые места архитектуры
|
|
|
|
Вот код:
|
|
|
|
{code}
|
|
"""
|
|
|
|
|
|
# ============ Запрос к Together AI ============
|
|
|
|
def call_together(code: str) -> str:
|
|
"""Отправляет код в DeepSeek V4.1 Flash, возвращает текст ответа."""
|
|
print("🤖 Отправляю код в DeepSeek V4.1 Flash...")
|
|
|
|
user_prompt = USER_PROMPT_TEMPLATE.format(code=code)
|
|
|
|
response = httpx.post(
|
|
"https://api.together.xyz/v1/chat/completions",
|
|
headers={
|
|
"Authorization": f"Bearer {TOGETHER_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
"max_tokens": MAX_OUTPUT_TOKENS,
|
|
"temperature": 0.3,
|
|
"tool_choice": "none",
|
|
},
|
|
timeout=300.0,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
print(f"❌ Together AI вернул {response.status_code}", file=sys.stderr)
|
|
print(f"Ответ: {response.text[:1000]}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
data = response.json()
|
|
|
|
try:
|
|
result = data["choices"][0]["message"]["content"]
|
|
if result is None:
|
|
raise KeyError("content is None")
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
print(f"❌ Неожиданный формат ответа: {e}", file=sys.stderr)
|
|
print(f"Полный ответ: {data}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
usage = data.get("usage", {})
|
|
print(
|
|
f"✅ Ответ получен. Токены: вход={usage.get('prompt_tokens', 0)}, "
|
|
f"выход={usage.get('completion_tokens', 0)}"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# ============ Создание Issue в Gitea ============
|
|
|
|
def create_issue(title: str, body: str) -> str:
|
|
"""Создаёт Issue в Gitea, возвращает URL."""
|
|
print("📝 Создаю Issue в Gitea...")
|
|
|
|
response = httpx.post(
|
|
f"{GITEA_INSTANCE}/api/v1/repos/{GITEA_REPO}/issues",
|
|
headers={
|
|
"Authorization": f"token {GITEA_BOT_TOKEN}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"title": title,
|
|
"body": body,
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
|
|
if response.status_code not in (200, 201):
|
|
print(f"❌ Gitea вернул {response.status_code}: {response.text[:500]}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
data = response.json()
|
|
issue_url = data.get("html_url", "")
|
|
print(f"✅ Issue создан: {issue_url}")
|
|
return issue_url
|
|
|
|
|
|
# ============ Main ============
|
|
|
|
def main() -> None:
|
|
check_env()
|
|
|
|
repo_root = Path(".").resolve()
|
|
print(f"📁 Репозиторий: {repo_root}")
|
|
|
|
code = collect_code(repo_root)
|
|
if not code:
|
|
print("❌ Не найдено файлов для анализа", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
analysis = call_together(code)
|
|
|
|
date_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
|
title = f"🤖 AI Review: список задач ({date_str})"
|
|
|
|
body = f"""# 🤖 AI Code Review
|
|
|
|
**Дата:** {date_str}
|
|
**Модель:** `{MODEL}`
|
|
**Репозиторий:** `{GITEA_REPO}`
|
|
|
|
---
|
|
|
|
{analysis}
|
|
|
|
---
|
|
|
|
_Этот Issue создан автоматически. Исправления делаются вручную._
|
|
"""
|
|
|
|
create_issue(title, body)
|
|
print("🎉 Готово!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |