Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e56676a6e | ||
|
|
95b5d61ddb | ||
|
|
9dadc490e2 | ||
|
|
1d953f554b | ||
|
|
2f29ccff74 | ||
|
|
a762d09b3b | ||
|
|
6d375fd76d | ||
|
|
38539df42c | ||
|
|
8d231b49db | ||
|
|
47bbb4ebc4 | ||
|
|
2db1225618 | ||
|
|
cfba28f913 | ||
|
|
c9dc486011 | ||
|
|
a27a0fa0f0 | ||
|
|
3df9e61b55 | ||
|
|
3981bdb1b3 | ||
|
|
256d69ec0f |
66
.git-hooks/README.md
Normal file
66
.git-hooks/README.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Git Hooks
|
||||
|
||||
Эта папка содержит пользовательские git hooks для автоматизации задач.
|
||||
|
||||
## Установка
|
||||
|
||||
Чтобы использовать эти hooks, выполните:
|
||||
|
||||
```bash
|
||||
# Из корня проекта
|
||||
git config core.hooksPath .git-hooks
|
||||
|
||||
# Сделать hooks исполняемыми
|
||||
chmod +x .git-hooks/pre-commit
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### pre-commit
|
||||
|
||||
Автоматически повышает версию backend при изменениях в `backend/` директории.
|
||||
|
||||
**Правила повышения версии:**
|
||||
|
||||
- `feat:` или `feature:` - повышает MINOR версию (0.1.0 → 0.2.0)
|
||||
- `fix:` или `bugfix:` - повышает PATCH версию (0.1.0 → 0.1.1)
|
||||
- `BREAKING:` или `major:` - повышает MAJOR версию (0.1.0 → 1.0.0)
|
||||
- Остальные - повышают PATCH версию
|
||||
|
||||
**Примеры коммитов:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: Add new feature" # 0.1.0 → 0.2.0
|
||||
git commit -m "fix: Fix bug" # 0.1.0 → 0.1.1
|
||||
git commit -m "BREAKING: Major changes" # 0.1.0 → 1.0.0
|
||||
```
|
||||
|
||||
## Ручное повышение версии
|
||||
|
||||
Вы можете вручную повысить версию:
|
||||
|
||||
```bash
|
||||
# Patch version (0.1.0 → 0.1.1)
|
||||
bash bump_version.sh patch
|
||||
|
||||
# Minor version (0.1.0 → 0.2.0)
|
||||
bash bump_version.sh minor
|
||||
|
||||
# Major version (0.1.0 → 1.0.0)
|
||||
bash bump_version.sh major
|
||||
```
|
||||
|
||||
## Отключение hooks
|
||||
|
||||
Если вы хотите временно отключить hooks:
|
||||
|
||||
```bash
|
||||
git commit --no-verify -m "Your message"
|
||||
```
|
||||
|
||||
Или полностью отключить:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .git/hooks
|
||||
```
|
||||
|
||||
24
.git-hooks/pre-commit
Normal file
24
.git-hooks/pre-commit
Normal file
@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Pre-commit hook для автоповышения версии
|
||||
|
||||
echo "🔄 Проверка версии backend..."
|
||||
|
||||
# Проверка, есть ли изменения в backend
|
||||
if git diff --cached --name-only | grep -q '^backend/'; then
|
||||
echo "📝 Обнаружены изменения в backend, обновление версии..."
|
||||
|
||||
# Запуск скрипта повышения версии
|
||||
bash bump_version.sh
|
||||
|
||||
# Проверка, был ли изменен файл версии
|
||||
if git diff --name-only | grep -q '^backend/VERSION'; then
|
||||
echo "✅ Версия обновлена, добавляем в коммит"
|
||||
git add backend/VERSION
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ Изменений в backend нет, версия не обновляется"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
./ARCHITECTURE.md
|
||||
./backend/app/__init__.py
|
||||
./backend/app/agents/__init__.py
|
||||
./backend/app/agents/prompts.py
|
||||
./backend/app/agents/reviewer.py
|
||||
./backend/app/agents/tools.py
|
||||
./backend/app/api/__init__.py
|
||||
./backend/app/api/repositories.py
|
||||
./backend/app/api/reviews.py
|
||||
./backend/app/api/webhooks.py
|
||||
./backend/app/config.py
|
||||
./backend/app/database.py
|
||||
./backend/app/main.py
|
||||
./backend/app/models/__init__.py
|
||||
./backend/app/models/comment.py
|
||||
./backend/app/models/pull_request.py
|
||||
./backend/app/models/repository.py
|
||||
./backend/app/models/review.py
|
||||
./backend/app/schemas/__init__.py
|
||||
./backend/app/schemas/repository.py
|
||||
./backend/app/schemas/review.py
|
||||
./backend/app/schemas/webhook.py
|
||||
./backend/app/services/__init__.py
|
||||
./backend/app/services/base.py
|
||||
./backend/app/services/bitbucket.py
|
||||
./backend/app/services/gitea.py
|
||||
./backend/app/services/github.py
|
||||
./backend/app/utils.py
|
||||
./backend/app/webhooks/__init__.py
|
||||
./backend/app/webhooks/bitbucket.py
|
||||
./backend/app/webhooks/gitea.py
|
||||
./backend/app/webhooks/github.py
|
||||
./backend/README.md
|
||||
./backend/requirements.txt
|
||||
./backend/start.bat
|
||||
./backend/start.sh
|
||||
./cloud.md
|
||||
./COMMANDS.md
|
||||
./CONTRIBUTING.md
|
||||
./FILES_LIST.txt
|
||||
./frontend/index.html
|
||||
./frontend/package.json
|
||||
./frontend/postcss.config.js
|
||||
./frontend/README.md
|
||||
./frontend/src/api/client.ts
|
||||
./frontend/src/api/websocket.ts
|
||||
./frontend/src/App.tsx
|
||||
./frontend/src/components/CommentsList.tsx
|
||||
./frontend/src/components/RepositoryForm.tsx
|
||||
./frontend/src/components/RepositoryList.tsx
|
||||
./frontend/src/components/ReviewList.tsx
|
||||
./frontend/src/components/ReviewProgress.tsx
|
||||
./frontend/src/components/WebSocketStatus.tsx
|
||||
./frontend/src/index.css
|
||||
./frontend/src/main.tsx
|
||||
./frontend/src/pages/Dashboard.tsx
|
||||
./frontend/src/pages/Repositories.tsx
|
||||
./frontend/src/pages/ReviewDetail.tsx
|
||||
./frontend/src/pages/Reviews.tsx
|
||||
./frontend/src/types/index.ts
|
||||
./frontend/src/vite-env.d.ts
|
||||
./frontend/start.bat
|
||||
./frontend/start.sh
|
||||
./frontend/tailwind.config.js
|
||||
./frontend/tsconfig.json
|
||||
./frontend/tsconfig.node.json
|
||||
./frontend/vite.config.ts
|
||||
./LICENSE
|
||||
./PROJECT_STATUS.md
|
||||
./PROJECT_STRUCTURE.txt
|
||||
./QUICKSTART.md
|
||||
./README.md
|
||||
./SUMMARY.md
|
||||
@ -1,33 +0,0 @@
|
||||
./.gitignore
|
||||
./ARCHITECTURE.md
|
||||
./backend/app/config.py
|
||||
./backend/app/database.py
|
||||
./backend/app/main.py
|
||||
./backend/app/utils.py
|
||||
./backend/app/__init__.py
|
||||
./backend/README.md
|
||||
./backend/requirements.txt
|
||||
./backend/start.bat
|
||||
./backend/start.sh
|
||||
./cloud.md
|
||||
./COMMANDS.md
|
||||
./CONTRIBUTING.md
|
||||
./frontend/.eslintrc.cjs
|
||||
./frontend/index.html
|
||||
./frontend/package.json
|
||||
./frontend/postcss.config.js
|
||||
./frontend/README.md
|
||||
./frontend/src/App.tsx
|
||||
./frontend/src/index.css
|
||||
./frontend/src/main.tsx
|
||||
./frontend/src/vite-env.d.ts
|
||||
./frontend/start.bat
|
||||
./frontend/start.sh
|
||||
./frontend/tailwind.config.js
|
||||
./frontend/tsconfig.json
|
||||
./frontend/tsconfig.node.json
|
||||
./frontend/vite.config.ts
|
||||
./LICENSE
|
||||
./PROJECT_STRUCTURE.txt
|
||||
./QUICKSTART.md
|
||||
./README.md
|
||||
18
README.md
18
README.md
@ -6,10 +6,8 @@
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Запуск одной командой:
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
```cmd
|
||||
start.bat
|
||||
```
|
||||
|
||||
@ -19,13 +17,15 @@ chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Это автоматически:
|
||||
- ✅ Проверит зависимости
|
||||
- ✅ Установит пакеты
|
||||
- ✅ Соберет frontend
|
||||
- ✅ Запустит сервер
|
||||
Скрипт:
|
||||
1. Соберет фронтенд в `backend/public`
|
||||
2. Запустит backend на http://localhost:8000
|
||||
3. Фронтенд отдается с бэкенда
|
||||
|
||||
**Готово!** Откройте http://localhost:8000
|
||||
**Один процесс, один порт, как в production.**
|
||||
|
||||
Перезапуск после изменений:
|
||||
- Просто запусти скрипт заново (Ctrl+C → start.bat)
|
||||
|
||||
---
|
||||
|
||||
|
||||
55
RUN.bat
Normal file
55
RUN.bat
Normal file
@ -0,0 +1,55 @@
|
||||
@echo off
|
||||
REM ===============================
|
||||
REM AI Review - Simple Launcher
|
||||
REM ===============================
|
||||
|
||||
title AI Review
|
||||
|
||||
echo.
|
||||
echo ================================
|
||||
echo AI Review - Starting
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
REM Переходим в корень проекта
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Собираем фронтенд
|
||||
echo [1/3] Building frontend...
|
||||
cd frontend
|
||||
if not exist "node_modules\" npm install
|
||||
call npm run build
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo.
|
||||
echo [ERROR] Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd ..
|
||||
|
||||
REM Переходим в backend
|
||||
echo.
|
||||
echo [2/3] Setup backend...
|
||||
cd backend
|
||||
|
||||
REM Создаем venv если нет
|
||||
if not exist "venv\" (
|
||||
python -m venv venv
|
||||
)
|
||||
|
||||
REM Активируем и устанавливаем зависимости
|
||||
call venv\Scripts\activate.bat
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
REM Запускаем сервер
|
||||
echo.
|
||||
echo [3/3] Starting server...
|
||||
echo ================================
|
||||
echo.
|
||||
echo http://localhost:8000
|
||||
echo.
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
1
backend/VERSION
Normal file
1
backend/VERSION
Normal file
@ -0,0 +1 @@
|
||||
0.1.0
|
||||
@ -51,6 +51,7 @@ CODE_REVIEW_PROMPT = """Проанализируй следующий код и
|
||||
]
|
||||
}}
|
||||
|
||||
Напиши как можно больше комментариев, в том числе хвалебные.
|
||||
Если проблем нет, верни пустой массив comments."""
|
||||
|
||||
|
||||
|
||||
@ -142,6 +142,14 @@ class ReviewerAgent:
|
||||
|
||||
async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
|
||||
"""Fetch PR information"""
|
||||
# Send step event
|
||||
if hasattr(self, '_stream_callback') and self._stream_callback:
|
||||
await self._stream_callback({
|
||||
"type": "agent_step",
|
||||
"step": "fetch_pr_info",
|
||||
"message": "Получение информации о PR..."
|
||||
})
|
||||
|
||||
try:
|
||||
# Update review status
|
||||
result = await self.db.execute(
|
||||
@ -198,6 +206,14 @@ class ReviewerAgent:
|
||||
|
||||
async def fetch_files(self, state: ReviewState) -> ReviewState:
|
||||
"""Fetch changed files in PR"""
|
||||
# Send step event
|
||||
if hasattr(self, '_stream_callback') and self._stream_callback:
|
||||
await self._stream_callback({
|
||||
"type": "agent_step",
|
||||
"step": "fetch_files",
|
||||
"message": "Загрузка измененных файлов..."
|
||||
})
|
||||
|
||||
try:
|
||||
git_service = state["git_service"]
|
||||
|
||||
@ -269,6 +285,14 @@ class ReviewerAgent:
|
||||
|
||||
async def analyze_files(self, state: ReviewState) -> ReviewState:
|
||||
"""Analyze files and generate comments"""
|
||||
# Send step event
|
||||
if hasattr(self, '_stream_callback') and self._stream_callback:
|
||||
await self._stream_callback({
|
||||
"type": "agent_step",
|
||||
"step": "analyze_files",
|
||||
"message": "Анализ кода с помощью AI..."
|
||||
})
|
||||
|
||||
try:
|
||||
all_comments = []
|
||||
|
||||
@ -291,6 +315,17 @@ class ReviewerAgent:
|
||||
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
|
||||
continue
|
||||
|
||||
# Callback для LLM streaming
|
||||
async def on_llm_chunk(chunk: str, file: str):
|
||||
"""Handle LLM streaming chunks"""
|
||||
if self._stream_callback:
|
||||
await self._stream_callback({
|
||||
"type": "llm_chunk",
|
||||
"chunk": chunk,
|
||||
"file_path": file,
|
||||
"message": chunk
|
||||
})
|
||||
|
||||
# Analyze diff with PR context
|
||||
pr_info = state.get("pr_info", {})
|
||||
comments = await self.analyzer.analyze_diff(
|
||||
@ -298,7 +333,8 @@ class ReviewerAgent:
|
||||
diff=patch,
|
||||
language=language,
|
||||
pr_title=pr_info.get("title", ""),
|
||||
pr_description=pr_info.get("description", "")
|
||||
pr_description=pr_info.get("description", ""),
|
||||
on_llm_chunk=on_llm_chunk
|
||||
)
|
||||
|
||||
print(f" 💬 Получено комментариев: {len(comments)}")
|
||||
@ -335,6 +371,14 @@ class ReviewerAgent:
|
||||
|
||||
async def post_comments(self, state: ReviewState) -> ReviewState:
|
||||
"""Post comments to PR"""
|
||||
# Send step event
|
||||
if hasattr(self, '_stream_callback') and self._stream_callback:
|
||||
await self._stream_callback({
|
||||
"type": "agent_step",
|
||||
"step": "post_comments",
|
||||
"message": "Публикация комментариев в PR..."
|
||||
})
|
||||
|
||||
try:
|
||||
# Save comments to database
|
||||
result = await self.db.execute(
|
||||
@ -471,6 +515,9 @@ class ReviewerAgent:
|
||||
repository_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the review workflow"""
|
||||
import uuid
|
||||
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
initial_state: ReviewState = {
|
||||
"review_id": review_id,
|
||||
"pr_number": pr_number,
|
||||
@ -483,7 +530,11 @@ class ReviewerAgent:
|
||||
"git_service": None
|
||||
}
|
||||
|
||||
final_state = await self.graph.ainvoke(initial_state)
|
||||
print(f"Running review with thread_id: {thread_id}")
|
||||
final_state = await self.graph.ainvoke(
|
||||
initial_state,
|
||||
config={"configurable": {"thread_id": thread_id}}
|
||||
)
|
||||
return final_state
|
||||
|
||||
async def run_review_stream(
|
||||
@ -494,6 +545,15 @@ class ReviewerAgent:
|
||||
on_event: callable = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the review workflow with streaming events"""
|
||||
print(f"\n{'='*80}")
|
||||
print(f"🎬 Starting review stream for PR #{pr_number}")
|
||||
print(f" Review ID: {review_id}")
|
||||
print(f" Callback: {on_event is not None}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Store callback in instance for access in nodes
|
||||
self._stream_callback = on_event
|
||||
|
||||
initial_state: ReviewState = {
|
||||
"review_id": review_id,
|
||||
"pr_number": pr_number,
|
||||
@ -507,34 +567,87 @@ class ReviewerAgent:
|
||||
}
|
||||
|
||||
final_state = None
|
||||
event_count = 0
|
||||
callback_count = 0
|
||||
|
||||
# Create unique thread_id for this review
|
||||
import uuid
|
||||
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Stream through the graph
|
||||
async for event in self.graph.astream(
|
||||
initial_state,
|
||||
stream_mode=["updates", "messages"]
|
||||
):
|
||||
# Handle different event types
|
||||
if isinstance(event, dict):
|
||||
# Node updates
|
||||
for node_name, node_data in event.items():
|
||||
if on_event:
|
||||
await on_event({
|
||||
"type": "agent_step",
|
||||
"step": node_name,
|
||||
"data": node_data
|
||||
})
|
||||
print(f"📊 Starting graph.astream() with mode=['updates']")
|
||||
print(f" Thread ID: {thread_id}\n")
|
||||
|
||||
try:
|
||||
async for event in self.graph.astream(
|
||||
initial_state,
|
||||
config={"configurable": {"thread_id": thread_id}},
|
||||
stream_mode=["updates"]
|
||||
):
|
||||
event_count += 1
|
||||
print(f"\n{'─'*80}")
|
||||
print(f"📨 STREAM Event #{event_count}")
|
||||
print(f" Type: {type(event).__name__}")
|
||||
print(f" Is tuple: {isinstance(event, tuple)}")
|
||||
print(f" Content: {event}")
|
||||
print(f"{'─'*80}")
|
||||
|
||||
# LangGraph returns events as tuple: ('updates', {node_name: node_output})
|
||||
if isinstance(event, tuple) and len(event) == 2:
|
||||
event_type, event_data = event[0], event[1]
|
||||
print(f"✓ Tuple detected:")
|
||||
print(f" [0] event_type: '{event_type}'")
|
||||
print(f" [1] event_data type: {type(event_data).__name__}")
|
||||
|
||||
# Store final state
|
||||
if isinstance(node_data, dict):
|
||||
final_state = node_data
|
||||
|
||||
# Handle message events (LLM calls)
|
||||
elif hasattr(event, '__class__') and 'message' in event.__class__.__name__.lower():
|
||||
if on_event:
|
||||
await on_event({
|
||||
"type": "llm_message",
|
||||
"message": str(event)
|
||||
})
|
||||
# Handle 'updates' events
|
||||
if event_type == 'updates' and isinstance(event_data, dict):
|
||||
print(f"✓ Updates event with dict data")
|
||||
for node_name, node_state in event_data.items():
|
||||
print(f"\n 🔔 Node: '{node_name}'")
|
||||
print(f" State type: {type(node_state).__name__}")
|
||||
|
||||
if on_event:
|
||||
callback_count += 1
|
||||
print(f" 📤 Calling callback #{callback_count}...")
|
||||
try:
|
||||
await on_event({
|
||||
"type": "agent_step",
|
||||
"step": node_name,
|
||||
"message": f"Шаг: {node_name}",
|
||||
"data": {
|
||||
"status": node_state.get("status") if isinstance(node_state, dict) else None
|
||||
}
|
||||
})
|
||||
print(f" ✓ Callback executed successfully")
|
||||
except Exception as e:
|
||||
print(f" ❌ Callback error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f" ⚠️ No callback set!")
|
||||
|
||||
# Store final state
|
||||
if isinstance(node_state, dict):
|
||||
final_state = node_state
|
||||
else:
|
||||
print(f" ⚠️ Not an 'updates' event or data is not dict")
|
||||
print(f" event_type={event_type}, isinstance(event_data, dict)={isinstance(event_data, dict)}")
|
||||
|
||||
else:
|
||||
print(f" ❌ NOT a tuple or wrong length!")
|
||||
print(f" isinstance(event, tuple)={isinstance(event, tuple)}")
|
||||
if isinstance(event, tuple):
|
||||
print(f" len(event)={len(event)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in graph streaming: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"✅ Graph streaming completed. Total events: {event_count}")
|
||||
|
||||
# Clear callback
|
||||
self._stream_callback = None
|
||||
|
||||
return final_state or initial_state
|
||||
|
||||
|
||||
@ -99,7 +99,8 @@ class CodeAnalyzer:
|
||||
diff: str,
|
||||
language: Optional[str] = None,
|
||||
pr_title: str = "",
|
||||
pr_description: str = ""
|
||||
pr_description: str = "",
|
||||
on_llm_chunk: Optional[callable] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Analyze code diff and return comments"""
|
||||
|
||||
@ -154,13 +155,32 @@ class CodeAnalyzer:
|
||||
try:
|
||||
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
|
||||
|
||||
# Создаем chain с LLM и JSON парсером
|
||||
chain = self.llm | self.json_parser
|
||||
# Собираем полный ответ из streaming chunks
|
||||
full_response = ""
|
||||
chunk_count = 0
|
||||
|
||||
# Получаем результат
|
||||
result = await chain.ainvoke(prompt)
|
||||
print(f"\n🤖 STREAMING AI ответ:")
|
||||
print("-" * 80)
|
||||
|
||||
print(f"\n🤖 ОТВЕТ AI (распарсен через JsonOutputParser):")
|
||||
# Используем streaming
|
||||
async for chunk in self.llm.astream(prompt):
|
||||
chunk_count += 1
|
||||
full_response += chunk
|
||||
|
||||
# Отправляем chunk через callback
|
||||
if on_llm_chunk:
|
||||
await on_llm_chunk(chunk, file_path)
|
||||
|
||||
# Показываем в консоли
|
||||
print(chunk, end='', flush=True)
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print(f"✅ Получено {chunk_count} chunks, всего {len(full_response)} символов")
|
||||
|
||||
# Парсим финальный результат
|
||||
result = self.json_parser.parse(full_response)
|
||||
|
||||
print(f"\n🤖 РАСПАРСЕННЫЙ результат:")
|
||||
print("-" * 80)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
|
||||
print("-" * 80)
|
||||
|
||||
@ -6,9 +6,11 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Review, Comment, PullRequest
|
||||
from app.models import Review, Comment, PullRequest, ReviewEvent
|
||||
from app.schemas.review import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse
|
||||
from app.schemas.review_event import ReviewEvent as ReviewEventSchema
|
||||
from app.agents import ReviewerAgent
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -129,9 +131,42 @@ async def get_review(
|
||||
|
||||
|
||||
async def run_review_task(review_id: int, pr_number: int, repository_id: int, db: AsyncSession):
|
||||
"""Background task to run review"""
|
||||
"""Background task to run review with streaming"""
|
||||
from app.main import manager
|
||||
from datetime import datetime as dt
|
||||
|
||||
# Create event handler for streaming
|
||||
async def on_review_event(event: dict):
|
||||
"""Handle review events and broadcast to clients"""
|
||||
try:
|
||||
event_data = {
|
||||
"type": event.get("type", "agent_update"),
|
||||
"review_id": review_id,
|
||||
"pr_number": pr_number,
|
||||
"timestamp": dt.utcnow().isoformat(),
|
||||
"data": event
|
||||
}
|
||||
|
||||
# Save to DB (НЕ сохраняем llm_chunk - их слишком много)
|
||||
if event.get("type") != "llm_chunk":
|
||||
from app.models.review_event import ReviewEvent
|
||||
db_event = ReviewEvent(
|
||||
review_id=review_id,
|
||||
event_type=event.get("type", "agent_update"),
|
||||
step=event.get("step"),
|
||||
message=event.get("message"),
|
||||
data=event
|
||||
)
|
||||
db.add(db_event)
|
||||
await db.commit()
|
||||
|
||||
# Broadcast (отправляем все события, включая llm_chunk)
|
||||
await manager.broadcast(event_data)
|
||||
except Exception as e:
|
||||
print(f"Error in review event handler: {e}")
|
||||
|
||||
agent = ReviewerAgent(db)
|
||||
await agent.run_review(review_id, pr_number, repository_id)
|
||||
await agent.run_review_stream(review_id, pr_number, repository_id, on_event=on_review_event)
|
||||
|
||||
|
||||
@router.post("/{review_id}/retry")
|
||||
@ -216,3 +251,27 @@ async def get_review_stats(db: AsyncSession = Depends(get_db)):
|
||||
avg_comments_per_review=round(avg_comments, 2)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{review_id}/events", response_model=List[ReviewEventSchema])
|
||||
async def get_review_events(
|
||||
review_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all events for a specific review"""
|
||||
# Check if review exists
|
||||
result = await db.execute(select(Review).where(Review.id == review_id))
|
||||
review = result.scalar_one_or_none()
|
||||
|
||||
if not review:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
|
||||
# Get events
|
||||
events_result = await db.execute(
|
||||
select(ReviewEvent)
|
||||
.where(ReviewEvent.review_id == review_id)
|
||||
.order_by(ReviewEvent.created_at)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
|
||||
return events
|
||||
|
||||
|
||||
@ -13,11 +13,44 @@ router = APIRouter()
|
||||
|
||||
|
||||
async def start_review_task(review_id: int, pr_number: int, repository_id: int):
|
||||
"""Background task to start review"""
|
||||
"""Background task to start review with streaming"""
|
||||
from app.database import async_session_maker
|
||||
from app.main import manager
|
||||
from datetime import datetime as dt
|
||||
|
||||
async with async_session_maker() as db:
|
||||
# Create event handler for streaming
|
||||
async def on_review_event(event: dict):
|
||||
"""Handle review events and broadcast to clients"""
|
||||
try:
|
||||
event_data = {
|
||||
"type": event.get("type", "agent_update"),
|
||||
"review_id": review_id,
|
||||
"pr_number": pr_number,
|
||||
"timestamp": dt.utcnow().isoformat(),
|
||||
"data": event
|
||||
}
|
||||
|
||||
# Save to DB (НЕ сохраняем llm_chunk - их слишком много)
|
||||
if event.get("type") != "llm_chunk":
|
||||
from app.models.review_event import ReviewEvent
|
||||
db_event = ReviewEvent(
|
||||
review_id=review_id,
|
||||
event_type=event.get("type", "agent_update"),
|
||||
step=event.get("step"),
|
||||
message=event.get("message"),
|
||||
data=event
|
||||
)
|
||||
db.add(db_event)
|
||||
await db.commit()
|
||||
|
||||
# Broadcast (отправляем все события, включая llm_chunk)
|
||||
await manager.broadcast(event_data)
|
||||
except Exception as e:
|
||||
print(f"Error in webhook review event handler: {e}")
|
||||
|
||||
agent = ReviewerAgent(db)
|
||||
await agent.run_review(review_id, pr_number, repository_id)
|
||||
await agent.run_review_stream(review_id, pr_number, repository_id, on_event=on_review_event)
|
||||
|
||||
|
||||
@router.post("/gitea/{repository_id}")
|
||||
|
||||
@ -29,11 +29,23 @@ class ConnectionManager:
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast message to all connected clients"""
|
||||
for connection in self.active_connections:
|
||||
print(f"\n[BROADCAST] Sending to {len(self.active_connections)} clients")
|
||||
print(f"[BROADCAST] Message type: {message.get('type')}")
|
||||
print(f"[BROADCAST] Message: {str(message)[:200]}...")
|
||||
|
||||
sent_count = 0
|
||||
error_count = 0
|
||||
|
||||
for i, connection in enumerate(self.active_connections):
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
sent_count += 1
|
||||
print(f"[BROADCAST] ✓ Sent to client #{i+1}")
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
print(f"[BROADCAST] ✗ Failed to send to client #{i+1}: {e}")
|
||||
|
||||
print(f"[BROADCAST] Result: {sent_count} sent, {error_count} failed")
|
||||
|
||||
|
||||
# Create connection manager
|
||||
@ -118,18 +130,66 @@ async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version():
|
||||
"""Get backend version"""
|
||||
try:
|
||||
# Try multiple possible locations
|
||||
version_file = Path(__file__).parent.parent / "VERSION"
|
||||
|
||||
if version_file.exists():
|
||||
version = version_file.read_text().strip()
|
||||
return {"version": version}
|
||||
|
||||
# Fallback: try root directory
|
||||
root_version = Path(__file__).parent.parent.parent / "VERSION"
|
||||
if root_version.exists():
|
||||
version = root_version.read_text().strip()
|
||||
return {"version": version}
|
||||
|
||||
return {"version": "0.1.0"}
|
||||
except Exception as e:
|
||||
print(f"Error reading version: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"version": "0.1.0"}
|
||||
|
||||
|
||||
@app.websocket("/ws/reviews")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for real-time review updates"""
|
||||
await manager.connect(websocket)
|
||||
print(f"✅ WebSocket connected. Total connections: {len(manager.active_connections)}")
|
||||
|
||||
try:
|
||||
# Send welcome message
|
||||
await websocket.send_json({
|
||||
"type": "connection",
|
||||
"status": "connected",
|
||||
"message": "WebSocket подключен к серверу review",
|
||||
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
while True:
|
||||
# Keep connection alive
|
||||
# Keep connection alive and handle client messages
|
||||
data = await websocket.receive_text()
|
||||
# Echo back or handle client messages if needed
|
||||
await websocket.send_json({"type": "pong", "message": "connected"})
|
||||
print(f"📨 Received from client: {data}")
|
||||
|
||||
# Handle ping/pong
|
||||
if data == "ping":
|
||||
await websocket.send_json({
|
||||
"type": "pong",
|
||||
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
|
||||
})
|
||||
else:
|
||||
# Echo back for debugging
|
||||
await websocket.send_json({
|
||||
"type": "echo",
|
||||
"message": f"Получено: {data}"
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
print(f"❌ WebSocket disconnected. Remaining connections: {len(manager.active_connections)}")
|
||||
|
||||
|
||||
async def broadcast_review_update(review_id: int, event_type: str, data: dict = None):
|
||||
|
||||
@ -6,6 +6,7 @@ from app.models.review import Review
|
||||
from app.models.comment import Comment
|
||||
from app.models.organization import Organization
|
||||
from app.models.review_task import ReviewTask
|
||||
from app.models.review_event import ReviewEvent
|
||||
|
||||
__all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask"]
|
||||
__all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask", "ReviewEvent"]
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ class Review(Base):
|
||||
# Relationships
|
||||
pull_request = relationship("PullRequest", back_populates="reviews")
|
||||
comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan")
|
||||
events = relationship("ReviewEvent", back_populates="review", cascade="all, delete-orphan", order_by="ReviewEvent.created_at")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>"
|
||||
|
||||
27
backend/app/models/review_event.py
Normal file
27
backend/app/models/review_event.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Review Event model - хранение событий процесса review"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ReviewEvent(Base):
|
||||
"""Событие процесса review"""
|
||||
__tablename__ = "review_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
review_id = Column(Integer, ForeignKey("reviews.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
event_type = Column(String(50), nullable=False) # agent_step, llm_message, review_started, etc.
|
||||
step = Column(String(100), nullable=True) # fetch_pr_info, analyze_files, etc.
|
||||
message = Column(Text, nullable=True)
|
||||
data = Column(JSON, nullable=True) # Дополнительные данные события
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
review = relationship("Review", back_populates="events")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReviewEvent(id={self.id}, review_id={self.review_id}, type={self.event_type})>"
|
||||
|
||||
@ -23,6 +23,10 @@ from app.schemas.streaming import (
|
||||
ReviewProgressEvent,
|
||||
StreamEventType
|
||||
)
|
||||
from app.schemas.review_event import (
|
||||
ReviewEvent as ReviewEventSchema,
|
||||
ReviewEventCreate
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RepositoryCreate",
|
||||
@ -40,5 +44,7 @@ __all__ = [
|
||||
"LLMStreamEvent",
|
||||
"ReviewProgressEvent",
|
||||
"StreamEventType",
|
||||
"ReviewEventSchema",
|
||||
"ReviewEventCreate",
|
||||
]
|
||||
|
||||
|
||||
29
backend/app/schemas/review_event.py
Normal file
29
backend/app/schemas/review_event.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Review Event schemas"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class ReviewEventBase(BaseModel):
|
||||
"""Base review event schema"""
|
||||
event_type: str = Field(..., description="Тип события")
|
||||
step: Optional[str] = Field(None, description="Шаг процесса")
|
||||
message: Optional[str] = Field(None, description="Сообщение")
|
||||
data: Optional[Dict[str, Any]] = Field(None, description="Дополнительные данные")
|
||||
|
||||
|
||||
class ReviewEventCreate(ReviewEventBase):
|
||||
"""Schema for creating review event"""
|
||||
review_id: int
|
||||
|
||||
|
||||
class ReviewEvent(ReviewEventBase):
|
||||
"""Review event response schema"""
|
||||
id: int
|
||||
review_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import ReviewTask, PullRequest, Repository, Review
|
||||
from app.models.review_task import TaskStatusEnum
|
||||
from app.models.review import ReviewStatusEnum
|
||||
from app.agents.reviewer import ReviewerAgent
|
||||
from app.config import settings
|
||||
|
||||
@ -28,6 +29,9 @@ class ReviewTaskWorker:
|
||||
self.running = True
|
||||
logger.info("🚀 Task Worker запущен")
|
||||
|
||||
# Очищаем зависшие задачи при старте
|
||||
await self._cleanup_stuck_tasks()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
await self._process_next_task()
|
||||
@ -44,6 +48,61 @@ class ReviewTaskWorker:
|
||||
self.running = False
|
||||
logger.info("⏹️ Task Worker остановлен")
|
||||
|
||||
async def _cleanup_stuck_tasks(self):
|
||||
"""Cleanup tasks that were IN_PROGRESS when server stopped"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Находим все задачи в статусе IN_PROGRESS
|
||||
stuck_query = select(ReviewTask).where(
|
||||
ReviewTask.status == TaskStatusEnum.IN_PROGRESS
|
||||
)
|
||||
result = await db.execute(stuck_query)
|
||||
stuck_tasks = result.scalars().all()
|
||||
|
||||
if stuck_tasks:
|
||||
logger.info(f"🔧 Найдено {len(stuck_tasks)} зависших задач, возвращаем в очередь...")
|
||||
for task in stuck_tasks:
|
||||
logger.info(f" ↩️ Задача #{task.id} (PR #{task.pull_request_id}) → PENDING")
|
||||
task.status = TaskStatusEnum.PENDING
|
||||
task.started_at = None
|
||||
# Не увеличиваем retry_count, это не была ошибка
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Зависшие задачи очищены и возвращены в очередь")
|
||||
else:
|
||||
logger.info("✅ Зависших задач не найдено")
|
||||
|
||||
# Также очищаем зависшие reviews (которые были в процессе работы)
|
||||
stuck_review_statuses = [
|
||||
ReviewStatusEnum.FETCHING,
|
||||
ReviewStatusEnum.ANALYZING,
|
||||
ReviewStatusEnum.COMMENTING
|
||||
]
|
||||
stuck_reviews_query = select(Review).where(
|
||||
Review.status.in_(stuck_review_statuses)
|
||||
)
|
||||
result = await db.execute(stuck_reviews_query)
|
||||
stuck_reviews = result.scalars().all()
|
||||
|
||||
if stuck_reviews:
|
||||
logger.info(f"🔧 Найдено {len(stuck_reviews)} зависших reviews, помечаем как failed...")
|
||||
for review in stuck_reviews:
|
||||
logger.info(f" ⚠️ Review #{review.id} (статус: {review.status}) → FAILED")
|
||||
review.status = ReviewStatusEnum.FAILED
|
||||
review.error_message = "Review прерван при перезапуске сервера"
|
||||
from datetime import datetime
|
||||
review.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Зависшие reviews помечены как failed")
|
||||
else:
|
||||
logger.info("✅ Зависших reviews не найдено")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке зависших задач: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
async def _process_next_task(self):
|
||||
"""Process next pending task"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
@ -166,9 +225,54 @@ class ReviewTaskWorker:
|
||||
from app.main import manager
|
||||
from datetime import datetime as dt
|
||||
|
||||
# Send initial "review started" message
|
||||
logger.info(f" 📢 Отправка начального сообщения о старте review...")
|
||||
try:
|
||||
# Save initial event to database
|
||||
from app.models.review_event import ReviewEvent
|
||||
initial_db_event = ReviewEvent(
|
||||
review_id=review.id,
|
||||
event_type="review_started",
|
||||
message=f"Начало review для PR #{pull_request.pr_number}",
|
||||
data={
|
||||
"repository_id": repository.id,
|
||||
"repository_name": f"{repository.repo_owner}/{repository.repo_name}"
|
||||
}
|
||||
)
|
||||
db.add(initial_db_event)
|
||||
await db.commit()
|
||||
logger.info(f" 💾 Начальное событие сохранено в БД: {initial_db_event.id}")
|
||||
|
||||
# Broadcast initial message
|
||||
initial_message = {
|
||||
"type": "review_started",
|
||||
"review_id": review.id,
|
||||
"pr_number": pull_request.pr_number,
|
||||
"timestamp": dt.utcnow().isoformat(),
|
||||
"data": {
|
||||
"message": f"Начало review для PR #{pull_request.pr_number}",
|
||||
"repository_id": repository.id,
|
||||
"repository_name": f"{repository.repo_owner}/{repository.repo_name}"
|
||||
}
|
||||
}
|
||||
await manager.broadcast(initial_message)
|
||||
logger.info(f" ✅ Начальное сообщение отправлено: {len(manager.active_connections)} подключений")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка отправки начального сообщения: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Create event handler
|
||||
async def on_review_event(event: dict):
|
||||
"""Handle review events and broadcast to clients"""
|
||||
print(f"\n{'*'*80}")
|
||||
print(f"CALLBACK INVOKED!")
|
||||
print(f" Event type: {event.get('type')}")
|
||||
print(f" Event step: {event.get('step')}")
|
||||
print(f" Event message: {event.get('message')}")
|
||||
print(f" Active WS connections: {len(manager.active_connections)}")
|
||||
print(f"{'*'*80}")
|
||||
|
||||
try:
|
||||
# Prepare event data
|
||||
event_data = {
|
||||
@ -179,18 +283,44 @@ class ReviewTaskWorker:
|
||||
"data": event
|
||||
}
|
||||
|
||||
# Broadcast to all connected clients
|
||||
print(f" Prepared event_data: {event_data}")
|
||||
logger.info(f" 🔔 Broadcasting event: type={event.get('type')}, connections={len(manager.active_connections)}")
|
||||
|
||||
# Save event to database (НЕ сохраняем llm_chunk - их слишком много)
|
||||
if event.get("type") != "llm_chunk":
|
||||
from app.models.review_event import ReviewEvent
|
||||
db_event = ReviewEvent(
|
||||
review_id=review.id,
|
||||
event_type=event.get("type", "agent_update"),
|
||||
step=event.get("step"),
|
||||
message=event.get("message"),
|
||||
data=event
|
||||
)
|
||||
db.add(db_event)
|
||||
await db.commit()
|
||||
print(f" ✓ Event saved to DB: {db_event.id}")
|
||||
logger.debug(f" 💾 Event saved to DB: {db_event.id}")
|
||||
|
||||
# Broadcast to all connected clients (отправляем все, включая llm_chunk)
|
||||
print(f" Broadcasting to {len(manager.active_connections)} connections...")
|
||||
await manager.broadcast(event_data)
|
||||
print(f" ✓ Broadcast completed")
|
||||
|
||||
# Log the event
|
||||
if event.get("type") == "agent_step":
|
||||
step = event.get("step", "unknown")
|
||||
logger.info(f" 📍 Step: {step}")
|
||||
elif event.get("type") == "llm_chunk":
|
||||
# Не логируем каждый chunk, слишком много
|
||||
pass
|
||||
elif event.get("type") == "llm_message":
|
||||
message = event.get("message", "")[:100]
|
||||
logger.debug(f" 💬 LLM: {message}...")
|
||||
logger.info(f" 💬 LLM: {message}...")
|
||||
except Exception as e:
|
||||
print(f" ❌ ERROR in callback: {e}")
|
||||
logger.error(f" ❌ Ошибка broadcast события: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
agent = ReviewerAgent(db)
|
||||
await agent.run_review_stream(
|
||||
@ -201,6 +331,37 @@ class ReviewTaskWorker:
|
||||
)
|
||||
|
||||
logger.info(f" ✅ Review завершен для PR #{pull_request.pr_number}")
|
||||
|
||||
# Send completion message
|
||||
try:
|
||||
# Save completion event to database
|
||||
from app.models.review_event import ReviewEvent
|
||||
completion_db_event = ReviewEvent(
|
||||
review_id=review.id,
|
||||
event_type="review_completed",
|
||||
message=f"Review завершен для PR #{pull_request.pr_number}",
|
||||
data={}
|
||||
)
|
||||
db.add(completion_db_event)
|
||||
await db.commit()
|
||||
logger.info(f" 💾 Событие завершения сохранено в БД: {completion_db_event.id}")
|
||||
|
||||
# Broadcast completion message
|
||||
completion_message = {
|
||||
"type": "review_completed",
|
||||
"review_id": review.id,
|
||||
"pr_number": pull_request.pr_number,
|
||||
"timestamp": dt.utcnow().isoformat(),
|
||||
"data": {
|
||||
"message": f"Review завершен для PR #{pull_request.pr_number}"
|
||||
}
|
||||
}
|
||||
await manager.broadcast(completion_message)
|
||||
logger.info(f" 📢 Сообщение о завершении отправлено: {len(manager.active_connections)} подключений")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка отправки сообщения о завершении: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# Global worker instance
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import asyncio
|
||||
from app.database import engine, Base
|
||||
from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment
|
||||
from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment, ReviewEvent
|
||||
|
||||
|
||||
async def create_tables():
|
||||
@ -20,6 +20,7 @@ async def create_tables():
|
||||
print(" - pull_requests")
|
||||
print(" - reviews")
|
||||
print(" - comments")
|
||||
print(" - review_events")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
17
backend/migrations/add_review_events.sql
Normal file
17
backend/migrations/add_review_events.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Migration: Add review_events table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
review_id INTEGER NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
step VARCHAR(100),
|
||||
message TEXT,
|
||||
data JSON,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_review_events_review_id ON review_events(review_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_events_created_at ON review_events(created_at);
|
||||
|
||||
1
backend/test_graph_format.py
Normal file
1
backend/test_graph_format.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
71
bump_version.sh
Normal file
71
bump_version.sh
Normal file
@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для автоповышения версии backend
|
||||
# Вызывается из pre-commit hook или вручную
|
||||
|
||||
VERSION_FILE="backend/VERSION"
|
||||
|
||||
# Проверка существования файла
|
||||
if [ ! -f "$VERSION_FILE" ]; then
|
||||
echo "0.1.0" > "$VERSION_FILE"
|
||||
echo "✅ Создан файл версии: 0.1.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Чтение текущей версии
|
||||
CURRENT_VERSION=$(cat "$VERSION_FILE")
|
||||
|
||||
# Разбор версии (MAJOR.MINOR.PATCH)
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
|
||||
MAJOR="${VERSION_PARTS[0]}"
|
||||
MINOR="${VERSION_PARTS[1]}"
|
||||
PATCH="${VERSION_PARTS[2]}"
|
||||
|
||||
# Проверка типа изменения по коммиту
|
||||
if [ $# -eq 1 ]; then
|
||||
VERSION_TYPE="$1"
|
||||
else
|
||||
# Автоопределение по последнему коммиту
|
||||
LAST_COMMIT=$(git log -1 --pretty=%B 2>/dev/null || echo "")
|
||||
|
||||
if echo "$LAST_COMMIT" | grep -qiE "^(feat|feature):"; then
|
||||
VERSION_TYPE="minor"
|
||||
elif echo "$LAST_COMMIT" | grep -qiE "^(fix|bugfix):"; then
|
||||
VERSION_TYPE="patch"
|
||||
elif echo "$LAST_COMMIT" | grep -qiE "^(BREAKING|major):"; then
|
||||
VERSION_TYPE="major"
|
||||
else
|
||||
VERSION_TYPE="patch"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Повышение версии
|
||||
case "$VERSION_TYPE" in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch|*)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
|
||||
# Запись новой версии
|
||||
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||
|
||||
echo "📦 Версия обновлена: $CURRENT_VERSION → $NEW_VERSION"
|
||||
|
||||
# Добавление файла в git если мы в hook
|
||||
if [ -n "$GIT_INDEX_FILE" ]; then
|
||||
git add "$VERSION_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=========================================="
|
||||
echo "AI Review Service - Status Check"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "1. Service Status:"
|
||||
systemctl status ai-review.service --no-pager
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "2. Last 100 lines of logs:"
|
||||
echo "=========================================="
|
||||
journalctl -u ai-review.service -n 100 --no-pager
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "3. Checking files:"
|
||||
echo "=========================================="
|
||||
echo "Backend exists: $([ -d /home/user/code-review-agent/backend ] && echo 'YES' || echo 'NO')"
|
||||
echo "Frontend exists: $([ -d /home/user/code-review-agent/frontend ] && echo 'YES' || echo 'NO')"
|
||||
echo "Public dir exists: $([ -d /home/user/code-review-agent/backend/public ] && echo 'YES' || echo 'NO')"
|
||||
echo "venv exists: $([ -d /home/user/code-review-agent/backend/venv ] && echo 'YES' || echo 'NO')"
|
||||
echo ".env exists: $([ -f /home/user/code-review-agent/backend/.env ] && echo 'YES' || echo 'NO')"
|
||||
echo "DB exists: $([ -f /home/user/code-review-agent/backend/review.db ] && echo 'YES' || echo 'NO')"
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo "4. Manual start test:"
|
||||
echo "=========================================="
|
||||
echo "Run this command to see actual error:"
|
||||
echo "cd /home/user/code-review-agent/backend && source venv/bin/activate && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
@ -248,7 +248,7 @@ User=$REAL_USER
|
||||
Group=$REAL_USER
|
||||
WorkingDirectory=$INSTALL_DIR/backend
|
||||
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=$INSTALL_DIR/backend/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/var/log/ai-review/access.log
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=========================================="
|
||||
echo "AI Review - Диагностика и исправление"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Определить директорию установки (где находится скрипт)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALL_DIR="$SCRIPT_DIR"
|
||||
|
||||
echo "Working directory: $INSTALL_DIR"
|
||||
echo ""
|
||||
|
||||
echo "1. Проверка файлов:"
|
||||
echo " - Backend: $([ -d $INSTALL_DIR/backend ] && echo '✓' || echo '✗')"
|
||||
echo " - Frontend: $([ -d $INSTALL_DIR/frontend ] && echo '✓' || echo '✗')"
|
||||
echo " - venv: $([ -d $INSTALL_DIR/backend/venv ] && echo '✓' || echo '✗ MISSING')"
|
||||
echo " - venv/bin/python: $([ -f $INSTALL_DIR/backend/venv/bin/python ] && echo '✓' || echo '✗ MISSING')"
|
||||
echo " - venv/bin/python3: $([ -f $INSTALL_DIR/backend/venv/bin/python3 ] && echo '✓' || echo '✗ MISSING')"
|
||||
echo " - public: $([ -d $INSTALL_DIR/backend/public ] && echo '✓' || echo '✗ MISSING')"
|
||||
echo " - DB: $([ -f $INSTALL_DIR/backend/review.db ] && echo '✓' || echo '⚠️ будет создана')"
|
||||
echo ""
|
||||
|
||||
# Проверить что именно в venv
|
||||
if [ -d "$INSTALL_DIR/backend/venv" ]; then
|
||||
echo "2. Содержимое venv/bin/:"
|
||||
ls -la "$INSTALL_DIR/backend/venv/bin/" | head -20
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "Исправление"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
cd "$INSTALL_DIR/backend"
|
||||
|
||||
# Удалить старый venv если есть
|
||||
if [ -d "venv" ]; then
|
||||
echo "Удаление старого venv..."
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
# Создать новый venv
|
||||
echo "Создание нового venv..."
|
||||
python3 -m venv venv
|
||||
|
||||
# Проверить создание
|
||||
if [ ! -f "venv/bin/python" ] && [ ! -f "venv/bin/python3" ]; then
|
||||
echo "✗ ОШИБКА: venv не создан правильно!"
|
||||
echo ""
|
||||
echo "Попробуйте:"
|
||||
echo " sudo apt-get install python3-venv"
|
||||
echo " python3 -m venv venv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ venv создан"
|
||||
|
||||
# Активировать и установить зависимости
|
||||
echo "Установка зависимостей..."
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip > /dev/null
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo "✓ Зависимости установлены"
|
||||
|
||||
# Применить миграции
|
||||
if [ -f "migrate.py" ]; then
|
||||
echo "Применение миграций..."
|
||||
python migrate.py
|
||||
echo "✓ Миграции применены"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Проверка"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "Попытка запуска (5 секунд)..."
|
||||
timeout 5 python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 2>&1 | head -20 &
|
||||
UVICORN_PID=$!
|
||||
|
||||
sleep 6
|
||||
|
||||
if ps -p $UVICORN_PID > /dev/null 2>&1; then
|
||||
echo "✓ Uvicorn запустился успешно"
|
||||
kill $UVICORN_PID 2>/dev/null
|
||||
else
|
||||
echo "⚠️ Uvicorn остановился (это нормально для теста)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Готово!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Теперь перезапустите сервис:"
|
||||
echo " sudo systemctl restart ai-review"
|
||||
echo " sudo systemctl status ai-review"
|
||||
echo ""
|
||||
echo "Или запустите вручную для теста:"
|
||||
echo " cd $INSTALL_DIR/backend"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
53
docs/README.md
Normal file
53
docs/README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Документация AI Code Review Agent
|
||||
|
||||
Здесь собрана вся документация проекта.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
- [QUICKSTART.md](QUICKSTART.md) - Быстрый старт проекта
|
||||
- [START_PROJECT.md](START_PROJECT.md) - Детальная инструкция по запуску
|
||||
|
||||
## Развертывание
|
||||
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Общая информация о развертывании
|
||||
- [UBUNTU_DEPLOYMENT.md](UBUNTU_DEPLOYMENT.md) - Развертывание на Ubuntu/Debian
|
||||
- [REDEPLOY_GUIDE.md](REDEPLOY_GUIDE.md) - Руководство по обновлению
|
||||
- [REDEPLOY_UBUNTU_QUICK.md](REDEPLOY_UBUNTU_QUICK.md) - Быстрое обновление на Ubuntu
|
||||
- [cloud.md](cloud.md) - Развертывание в облаке
|
||||
|
||||
## Функционал
|
||||
|
||||
- [FEATURES_UPDATE.md](FEATURES_UPDATE.md) - Обновления функционала
|
||||
- [REVIEW_FEATURES.md](REVIEW_FEATURES.md) - Возможности review
|
||||
- [ORGANIZATION_FEATURE.md](ORGANIZATION_FEATURE.md) - Работа с организациями
|
||||
- [ORGANIZATION_QUICKSTART.md](ORGANIZATION_QUICKSTART.md) - Быстрый старт с организациями
|
||||
- [MASTER_TOKEN_FEATURE.md](MASTER_TOKEN_FEATURE.md) - Мастер токены
|
||||
- [PR_CONTEXT_FEATURE.md](PR_CONTEXT_FEATURE.md) - Контекст Pull Request
|
||||
- [HTML_ESCAPE_FIX.md](HTML_ESCAPE_FIX.md) - Исправление экранирования HTML
|
||||
|
||||
## Архитектура и разработка
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Архитектура проекта
|
||||
- [PROJECT_STATUS.md](PROJECT_STATUS.md) - Статус проекта
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Как внести вклад
|
||||
|
||||
## Changelog
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) - История изменений
|
||||
- [CHANGELOG_ORGANIZATIONS.md](CHANGELOG_ORGANIZATIONS.md) - История изменений организаций
|
||||
|
||||
## Команды и отладка
|
||||
|
||||
- [COMMANDS.md](COMMANDS.md) - Полезные команды
|
||||
- [DEBUG_GUIDE.md](DEBUG_GUIDE.md) - Руководство по отладке
|
||||
|
||||
## Настройки и рекомендации
|
||||
|
||||
- [MODEL_RECOMMENDATION.md](MODEL_RECOMMENDATION.md) - Рекомендации по выбору модели
|
||||
- [PRODUCTION_URLS.md](PRODUCTION_URLS.md) - Настройка production URL
|
||||
- [SUMMARY.md](SUMMARY.md) - Краткое резюме проекта
|
||||
|
||||
## Дополнительно
|
||||
|
||||
- [TEST_STREAMING.md](TEST_STREAMING.md) - Тестирование WebSocket стриминга
|
||||
|
||||
264
docs/REVIEW_EVENTS_AND_VERSIONING.md
Normal file
264
docs/REVIEW_EVENTS_AND_VERSIONING.md
Normal file
@ -0,0 +1,264 @@
|
||||
# Персистентные события Review и система версионирования
|
||||
|
||||
## Что добавлено
|
||||
|
||||
### 1. Сохранение событий review в БД
|
||||
|
||||
#### Backend
|
||||
|
||||
- ✅ Модель `ReviewEvent` для хранения событий процесса review
|
||||
- ✅ API endpoint `/api/reviews/{review_id}/events` для получения событий
|
||||
- ✅ Автоматическое сохранение всех событий в БД при обработке review
|
||||
- ✅ Связь с моделью Review через relationship
|
||||
|
||||
**Структура события:**
|
||||
```python
|
||||
class ReviewEvent:
|
||||
id: int
|
||||
review_id: int
|
||||
event_type: str # agent_step, llm_message, review_started, etc.
|
||||
step: Optional[str] # fetch_pr_info, analyze_files, etc.
|
||||
message: Optional[str]
|
||||
data: Optional[Dict]
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
|
||||
- ✅ Компонент `ReviewStream` загружает исторические события из БД
|
||||
- ✅ События отображаются даже после завершения review
|
||||
- ✅ При обновлении страницы события восстанавливаются
|
||||
|
||||
**Использование:**
|
||||
```tsx
|
||||
<ReviewStream reviewId={reviewId} />
|
||||
```
|
||||
|
||||
### 2. Показ версии бэкенда
|
||||
|
||||
#### Backend
|
||||
|
||||
- ✅ Файл `backend/VERSION` с текущей версией (semver format)
|
||||
- ✅ API endpoint `/api/version` для получения версии
|
||||
|
||||
#### Frontend
|
||||
|
||||
- ✅ Компонент `Footer` показывает версию внизу страницы
|
||||
- ✅ Автоматическое обновление версии каждые 5 минут
|
||||
- ✅ Кеширование версии для производительности
|
||||
|
||||
### 3. Автоповышение версии (Git Hook)
|
||||
|
||||
#### Скрипты
|
||||
|
||||
**`bump_version.sh`** - Скрипт повышения версии:
|
||||
```bash
|
||||
bash bump_version.sh patch # 0.1.0 → 0.1.1
|
||||
bash bump_version.sh minor # 0.1.0 → 0.2.0
|
||||
bash bump_version.sh major # 0.1.0 → 1.0.0
|
||||
```
|
||||
|
||||
**`.git-hooks/pre-commit`** - Pre-commit hook:
|
||||
- Автоматически вызывается при каждом коммите
|
||||
- Анализирует изменения в `backend/` директории
|
||||
- Повышает версию на основе типа коммита
|
||||
|
||||
#### Правила версионирования
|
||||
|
||||
Версия повышается автоматически на основе префикса коммита:
|
||||
|
||||
| Префикс коммита | Изменение версии | Пример |
|
||||
|-----------------|------------------|---------|
|
||||
| `feat:` или `feature:` | MINOR | 0.1.0 → 0.2.0 |
|
||||
| `fix:` или `bugfix:` | PATCH | 0.1.0 → 0.1.1 |
|
||||
| `BREAKING:` или `major:` | MAJOR | 0.1.0 → 1.0.0 |
|
||||
| Остальные | PATCH | 0.1.0 → 0.1.1 |
|
||||
|
||||
**Примеры:**
|
||||
```bash
|
||||
git commit -m "feat: Add new feature" # 0.1.0 → 0.2.0
|
||||
git commit -m "fix: Fix critical bug" # 0.1.0 → 0.1.1
|
||||
git commit -m "BREAKING: Remove old API" # 0.1.0 → 1.0.0
|
||||
git commit -m "docs: Update README" # 0.1.0 → 0.1.1
|
||||
```
|
||||
|
||||
## Установка Git Hook
|
||||
|
||||
Чтобы активировать автоповышение версии:
|
||||
|
||||
```bash
|
||||
# Из корня проекта
|
||||
git config core.hooksPath .git-hooks
|
||||
|
||||
# Сделать hook исполняемым (Linux/Mac)
|
||||
chmod +x .git-hooks/pre-commit
|
||||
```
|
||||
|
||||
На Windows Git Bash автоматически обрабатывает права выполнения.
|
||||
|
||||
## Отключение hook
|
||||
|
||||
Если нужно закоммитить без повышения версии:
|
||||
|
||||
```bash
|
||||
# Одноразово
|
||||
git commit --no-verify -m "Your message"
|
||||
|
||||
# Полностью отключить
|
||||
git config core.hooksPath .git/hooks
|
||||
```
|
||||
|
||||
## Миграция БД
|
||||
|
||||
Для добавления таблицы `review_events`:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python migrate.py
|
||||
```
|
||||
|
||||
Или вручную:
|
||||
```bash
|
||||
sqlite3 backend/review.db < backend/migrations/add_review_events.sql
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Получить события review
|
||||
|
||||
```http
|
||||
GET /api/reviews/{review_id}/events
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"review_id": 1,
|
||||
"event_type": "agent_step",
|
||||
"step": "fetch_pr_info",
|
||||
"message": "Получение информации о PR...",
|
||||
"data": {},
|
||||
"created_at": "2025-10-13T10:30:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Получить версию бэкенда
|
||||
|
||||
```http
|
||||
GET /api/version
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── backend/
|
||||
│ ├── VERSION # Файл версии
|
||||
│ ├── migrations/
|
||||
│ │ └── add_review_events.sql # Миграция для таблицы событий
|
||||
│ ├── app/
|
||||
│ │ ├── models/
|
||||
│ │ │ └── review_event.py # Модель события
|
||||
│ │ ├── schemas/
|
||||
│ │ │ └── review_event.py # Схемы события
|
||||
│ │ └── api/
|
||||
│ │ └── reviews.py # API endpoints
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ReviewStream.tsx # Компонент стриминга
|
||||
│ │ │ └── Footer.tsx # Футер с версией
|
||||
│ │ └── api/
|
||||
│ │ └── client.ts # API клиент
|
||||
├── .git-hooks/
|
||||
│ ├── pre-commit # Pre-commit hook
|
||||
│ └── README.md # Документация hooks
|
||||
├── bump_version.sh # Скрипт повышения версии
|
||||
└── docs/
|
||||
└── REVIEW_EVENTS_AND_VERSIONING.md # Эта документация
|
||||
```
|
||||
|
||||
## Обновление на сервере
|
||||
|
||||
После деплоя:
|
||||
|
||||
1. Применить миграции:
|
||||
```bash
|
||||
cd ~/code-review-agent/backend
|
||||
python migrate.py
|
||||
```
|
||||
|
||||
2. Перезапустить сервис:
|
||||
```bash
|
||||
sudo systemctl restart ai-review
|
||||
```
|
||||
|
||||
3. Проверить версию:
|
||||
```bash
|
||||
curl http://localhost:8000/api/version
|
||||
```
|
||||
|
||||
## Проверка работы
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Проверить что таблица создана
|
||||
sqlite3 backend/review.db ".tables"
|
||||
# Должна быть таблица review_events
|
||||
|
||||
# Проверить версию
|
||||
cat backend/VERSION
|
||||
# Должна быть версия в формате X.Y.Z
|
||||
|
||||
# Проверить API
|
||||
curl http://localhost:8000/api/version
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Откройте приложение в браузере
|
||||
2. Внизу страницы должна быть видна версия: `Backend v0.1.0`
|
||||
3. Откройте страницу review
|
||||
4. Компонент "Процесс Review" должен показывать события даже после завершения
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Git hook не работает
|
||||
|
||||
```bash
|
||||
# Проверить настройку
|
||||
git config core.hooksPath
|
||||
|
||||
# Переустановить
|
||||
git config core.hooksPath .git-hooks
|
||||
|
||||
# На Windows может потребоваться Git Bash
|
||||
```
|
||||
|
||||
### События не сохраняются
|
||||
|
||||
```bash
|
||||
# Проверить что таблица существует
|
||||
sqlite3 backend/review.db "SELECT * FROM review_events LIMIT 1;"
|
||||
|
||||
# Применить миграцию
|
||||
python backend/migrate.py
|
||||
```
|
||||
|
||||
### Версия не отображается на UI
|
||||
|
||||
1. Проверьте что файл `backend/VERSION` существует
|
||||
2. Проверьте endpoint: `curl http://localhost:8000/api/version`
|
||||
3. Проверьте консоль браузера на ошибки
|
||||
4. Очистите кеш браузера (Ctrl+F5)
|
||||
|
||||
181
docs/TEST_STREAMING.md
Normal file
181
docs/TEST_STREAMING.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Тестирование LangGraph Streaming
|
||||
|
||||
Эти скрипты помогут проверить, как работает стриминг событий из LangGraph.
|
||||
|
||||
## Скрипты
|
||||
|
||||
### 1. `test_simple_graph.py` - Простой тест (БЕЗ БД)
|
||||
|
||||
**Рекомендуется запустить ПЕРВЫМ** для понимания как работает стриминг.
|
||||
|
||||
Тестирует простой граф с 3 нодами без реального review.
|
||||
|
||||
```bash
|
||||
# Активировать venv
|
||||
cd backend
|
||||
source venv/Scripts/activate # Windows Git Bash
|
||||
# или
|
||||
source venv/bin/activate # Linux/Mac
|
||||
|
||||
# Запустить
|
||||
python ../test_simple_graph.py
|
||||
```
|
||||
|
||||
**Что тестирует:**
|
||||
- ✅ `stream_mode=['updates']` - обновления нод
|
||||
- ✅ `stream_mode=['messages']` - сообщения (LLM calls)
|
||||
- ✅ `stream_mode=['updates', 'messages']` - оба режима
|
||||
- ✅ `stream_mode=['values']` - значения состояния
|
||||
- ✅ `stream_mode=['debug']` - режим отладки
|
||||
- ✅ Callback обработка событий
|
||||
|
||||
**Ожидаемый результат:**
|
||||
Должны появиться события для каждой ноды (node_1, node_2, node_3).
|
||||
|
||||
---
|
||||
|
||||
### 2. `test_langgraph_events.py` - Полный тест (С БД)
|
||||
|
||||
Тестирует реальный ReviewerAgent с настоящими данными из БД.
|
||||
|
||||
⚠️ **Требует:**
|
||||
- Работающую БД с данными
|
||||
- Существующий Review ID, PR Number, Repository ID
|
||||
- Настроенный `.env` файл
|
||||
|
||||
```bash
|
||||
# Активировать venv
|
||||
cd backend
|
||||
source venv/Scripts/activate # Windows
|
||||
# или
|
||||
source venv/bin/activate # Linux/Mac
|
||||
|
||||
# Запустить
|
||||
python ../test_langgraph_events.py
|
||||
```
|
||||
|
||||
**Перед запуском:**
|
||||
Отредактируйте в файле `test_langgraph_events.py`:
|
||||
|
||||
```python
|
||||
TEST_REVIEW_ID = 1 # ID существующего review
|
||||
TEST_PR_NUMBER = 5 # Номер PR
|
||||
TEST_REPOSITORY_ID = 1 # ID репозитория
|
||||
```
|
||||
|
||||
**Что тестирует:**
|
||||
- ✅ Полный цикл review с callback
|
||||
- ✅ RAW стриминг напрямую из графа
|
||||
- ✅ Все режимы: `updates`, `messages`, `updates + messages`
|
||||
|
||||
---
|
||||
|
||||
## Запуск локально (быстрый старт)
|
||||
|
||||
### Шаг 1: Простой тест
|
||||
```bash
|
||||
cd backend
|
||||
source venv/Scripts/activate
|
||||
python ../test_simple_graph.py
|
||||
```
|
||||
|
||||
Смотрите вывод - должны быть события от каждой ноды.
|
||||
|
||||
### Шаг 2: Проверка формата событий
|
||||
|
||||
Обратите внимание на **тип** и **структуру** событий:
|
||||
|
||||
```
|
||||
📨 Event #1
|
||||
Type: <class 'dict'>
|
||||
Keys: ['node_1']
|
||||
Content: {'node_1': {...}}
|
||||
```
|
||||
|
||||
или
|
||||
|
||||
```
|
||||
📨 Event #1
|
||||
Type: <class 'tuple'>
|
||||
Tuple[0]: 'messages'
|
||||
Tuple[1]: [AIMessage(...)]
|
||||
```
|
||||
|
||||
### Шаг 3: Полный тест (если нужно)
|
||||
|
||||
Отредактируйте параметры в `test_langgraph_events.py` и запустите:
|
||||
|
||||
```bash
|
||||
python ../test_langgraph_events.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что искать в выводе
|
||||
|
||||
### ✅ ХОРОШО:
|
||||
|
||||
```
|
||||
📨 Event #1
|
||||
Type: <class 'dict'>
|
||||
Content: {'node_1': {...}}
|
||||
|
||||
📨 Event #2
|
||||
Type: <class 'dict'>
|
||||
Content: {'node_2': {...}}
|
||||
```
|
||||
|
||||
**События приходят!** Граф работает.
|
||||
|
||||
### ❌ ПЛОХО:
|
||||
|
||||
```
|
||||
✅ Получено событий: 0
|
||||
```
|
||||
|
||||
**События НЕ приходят!** Проблема с графом или версией LangGraph.
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если события не приходят:
|
||||
|
||||
1. **Проверьте версию LangGraph:**
|
||||
```bash
|
||||
pip show langgraph
|
||||
```
|
||||
Должна быть >= 0.1.0
|
||||
|
||||
2. **Проверьте, что граф компилируется:**
|
||||
```python
|
||||
graph = workflow.compile()
|
||||
print(graph) # Должен вывести информацию о графе
|
||||
```
|
||||
|
||||
3. **Попробуйте `ainvoke` вместо `astream`:**
|
||||
```python
|
||||
result = await graph.ainvoke(initial_state)
|
||||
print(result) # Должен вернуть финальное состояние
|
||||
```
|
||||
|
||||
4. **Проверьте логи:**
|
||||
Включите DEBUG логирование:
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Результаты
|
||||
|
||||
После запуска тестов вы узнаете:
|
||||
|
||||
1. ✅ **Работает ли стриминг вообще?**
|
||||
2. ✅ **Какой формат у событий?**
|
||||
3. ✅ **Какие режимы стриминга поддерживаются?**
|
||||
4. ✅ **Как правильно обрабатывать события?**
|
||||
|
||||
Это поможет понять, почему события не доходят до фронтенда.
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Скрипт быстрого исправления установки
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Fixing AI Review Installation"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Определить директорию установки (где находится скрипт)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALL_DIR="$SCRIPT_DIR"
|
||||
|
||||
echo "Working directory: $INSTALL_DIR"
|
||||
echo ""
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# 1. Создать Python virtual environment
|
||||
echo "[1/5] Создание Python virtual environment..."
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
echo "✓ venv создан"
|
||||
echo ""
|
||||
|
||||
# 2. Установить Python зависимости
|
||||
echo "[2/5] Установка Python зависимостей..."
|
||||
pip install --upgrade pip > /dev/null
|
||||
pip install -r requirements.txt > /dev/null
|
||||
echo "✓ Python зависимости установлены"
|
||||
echo ""
|
||||
|
||||
# 3. Создать базу данных
|
||||
echo "[3/5] Создание базы данных..."
|
||||
python migrate.py
|
||||
echo "✓ База данных создана"
|
||||
echo ""
|
||||
|
||||
# 4. Установить Node.js зависимости и собрать frontend
|
||||
echo "[4/5] Сборка frontend..."
|
||||
cd ../frontend
|
||||
|
||||
# Проверить наличие Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "ERROR: Node.js не установлен!"
|
||||
echo "Установите: curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - && sudo apt-get install -y nodejs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Создать .env.production
|
||||
cat > .env.production << 'EOF'
|
||||
VITE_API_URL=/api
|
||||
VITE_WS_URL=
|
||||
EOF
|
||||
|
||||
npm install > /dev/null 2>&1
|
||||
npm run build
|
||||
echo "✓ Frontend собран"
|
||||
echo ""
|
||||
|
||||
# 5. Проверить результат
|
||||
echo "[5/5] Проверка..."
|
||||
cd ..
|
||||
|
||||
echo "Backend venv: $([ -d backend/venv ] && echo '✓ OK' || echo '✗ MISSING')"
|
||||
echo "Backend DB: $([ -f backend/review.db ] && echo '✓ OK' || echo '✗ MISSING')"
|
||||
echo "Frontend build: $([ -d backend/public ] && echo '✓ OK' || echo '✗ MISSING')"
|
||||
echo ""
|
||||
|
||||
if [ -d backend/venv ] && [ -f backend/review.db ] && [ -d backend/public ]; then
|
||||
echo "=========================================="
|
||||
echo "✓ Installation fixed successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Now run:"
|
||||
echo " sudo systemctl restart ai-review"
|
||||
echo " sudo systemctl status ai-review"
|
||||
echo ""
|
||||
echo "Or start manually:"
|
||||
echo " cd $INSTALL_DIR/backend"
|
||||
echo " source venv/bin/activate"
|
||||
echo " uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
else
|
||||
echo "ERROR: Something is still missing!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
106
fix-service-python3.sh
Normal file
106
fix-service-python3.sh
Normal file
@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для исправления systemd service (python -> python3)
|
||||
|
||||
set -e
|
||||
|
||||
# Проверка sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "❌ Этот скрипт должен быть запущен с sudo"
|
||||
echo "Используйте: sudo bash fix-service-python3.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔧 Исправление ai-review.service..."
|
||||
|
||||
# Получаем реального пользователя
|
||||
REAL_USER=${SUDO_USER:-$USER}
|
||||
REAL_HOME=$(eval echo ~$REAL_USER)
|
||||
|
||||
# Определяем директорию установки
|
||||
INSTALL_DIR=""
|
||||
|
||||
if [ -f "/etc/systemd/system/ai-review.service" ]; then
|
||||
# Извлекаем путь из существующего service файла
|
||||
INSTALL_DIR=$(grep "WorkingDirectory=" /etc/systemd/system/ai-review.service | cut -d'=' -f2 | sed 's|/backend||')
|
||||
echo "📂 Найдена директория установки: $INSTALL_DIR"
|
||||
else
|
||||
echo "❌ Service файл не найден"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Проверяем что директория существует
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "❌ Директория $INSTALL_DIR не существует"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Проверяем что venv существует
|
||||
if [ ! -f "$INSTALL_DIR/backend/venv/bin/python3" ]; then
|
||||
echo "❌ Python3 в venv не найден: $INSTALL_DIR/backend/venv/bin/python3"
|
||||
echo ""
|
||||
echo "Создайте venv заново:"
|
||||
echo " cd $INSTALL_DIR/backend"
|
||||
echo " python3 -m venv venv"
|
||||
echo " source venv/bin/activate"
|
||||
echo " pip install -r requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ venv найден"
|
||||
|
||||
# Создаем новый service файл
|
||||
echo "📝 Создание нового service файла..."
|
||||
|
||||
cat > /etc/systemd/system/ai-review.service << EOF
|
||||
[Unit]
|
||||
Description=AI Code Review Platform
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$REAL_USER
|
||||
Group=$REAL_USER
|
||||
WorkingDirectory=$INSTALL_DIR/backend
|
||||
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/var/log/ai-review/access.log
|
||||
StandardError=append:/var/log/ai-review/error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "✅ Service файл создан"
|
||||
|
||||
# Перезагружаем systemd
|
||||
echo "🔄 Перезагрузка systemd..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Перезапускаем сервис
|
||||
echo "🔄 Перезапуск ai-review.service..."
|
||||
systemctl restart ai-review
|
||||
|
||||
# Ждем немного
|
||||
sleep 3
|
||||
|
||||
# Проверяем статус
|
||||
if systemctl is-active --quiet ai-review; then
|
||||
echo ""
|
||||
echo "✅ Сервис успешно запущен!"
|
||||
echo ""
|
||||
systemctl status ai-review --no-pager
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Ошибка запуска сервиса"
|
||||
echo ""
|
||||
echo "Логи:"
|
||||
journalctl -u ai-review -n 30 --no-pager
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Для просмотра логов в реальном времени:"
|
||||
echo " sudo journalctl -u ai-review -f"
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=========================================="
|
||||
echo "Creating simple systemd service"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
INSTALL_DIR="$HOME/code-review-agent"
|
||||
|
||||
echo "Install directory: $INSTALL_DIR"
|
||||
echo "User: $USER"
|
||||
echo ""
|
||||
|
||||
# Создать простой systemd service БЕЗ жестких ограничений
|
||||
sudo tee /etc/systemd/system/ai-review.service > /dev/null << EOF
|
||||
[Unit]
|
||||
Description=AI Code Review Platform
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER
|
||||
WorkingDirectory=$INSTALL_DIR/backend
|
||||
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/var/log/ai-review/access.log
|
||||
StandardError=append:/var/log/ai-review/error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "✓ Service файл создан"
|
||||
echo ""
|
||||
|
||||
# Создать директорию логов
|
||||
sudo mkdir -p /var/log/ai-review
|
||||
sudo chown $USER:$USER /var/log/ai-review
|
||||
echo "✓ Директория логов создана"
|
||||
echo ""
|
||||
|
||||
# Перезагрузить systemd
|
||||
echo "Перезагрузка systemd..."
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ai-review
|
||||
echo "✓ Systemd обновлен"
|
||||
echo ""
|
||||
|
||||
# Запустить
|
||||
echo "Запуск сервиса..."
|
||||
sudo systemctl restart ai-review
|
||||
sleep 3
|
||||
|
||||
# Проверить статус
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
if sudo systemctl is-active --quiet ai-review; then
|
||||
echo "✅ Сервис запущен успешно!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
sudo systemctl status ai-review --no-pager | head -20
|
||||
echo ""
|
||||
echo "Приложение доступно: http://localhost:8000"
|
||||
echo ""
|
||||
echo "Полезные команды:"
|
||||
echo " sudo systemctl status ai-review"
|
||||
echo " sudo journalctl -u ai-review -f"
|
||||
echo " tail -f /var/log/ai-review/error.log"
|
||||
else
|
||||
echo "❌ Сервис не запустился"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Статус:"
|
||||
sudo systemctl status ai-review --no-pager
|
||||
echo ""
|
||||
echo "Последние 30 строк логов:"
|
||||
sudo journalctl -u ai-review -n 30 --no-pager
|
||||
echo ""
|
||||
echo "Проверьте:"
|
||||
echo " 1. tail -50 /var/log/ai-review/error.log"
|
||||
echo " 2. Попробуйте запустить вручную:"
|
||||
echo " cd $INSTALL_DIR/backend"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python -m uvicorn app.main:app"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@ -7,6 +7,7 @@ import ReviewDetail from './pages/ReviewDetail';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Tasks from './pages/Tasks';
|
||||
import WebSocketStatus from './components/WebSocketStatus';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -65,7 +66,7 @@ function Navigation() {
|
||||
|
||||
function AppContent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg">
|
||||
<div className="min-h-screen bg-dark-bg pb-12">
|
||||
<Navigation />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@ -78,6 +79,8 @@ function AppContent() {
|
||||
<Route path="/reviews/:id" element={<ReviewDetail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -68,5 +68,25 @@ export const getReviewStats = async () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface ReviewEvent {
|
||||
id: number;
|
||||
review_id: number;
|
||||
event_type: string;
|
||||
step?: string;
|
||||
message?: string;
|
||||
data?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getReviewEvents = async (reviewId: number) => {
|
||||
const response = await api.get<ReviewEvent[]>(`/reviews/${reviewId}/events`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBackendVersion = async () => {
|
||||
const response = await api.get<{ version: string }>('/version');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
|
||||
@ -108,3 +108,19 @@ export class WebSocketClient {
|
||||
// Create singleton instance
|
||||
export const wsClient = new WebSocketClient();
|
||||
|
||||
// Export helper to get WS URL
|
||||
export const getWebSocketUrl = (): string => {
|
||||
// Если задан VITE_WS_URL, используем его
|
||||
if (import.meta.env.VITE_WS_URL) {
|
||||
return import.meta.env.VITE_WS_URL;
|
||||
}
|
||||
|
||||
// Иначе определяем автоматически на основе текущего location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
// Export WS_URL for direct usage
|
||||
export const WS_URL = `${getWebSocketUrl()}/ws/reviews`;
|
||||
|
||||
|
||||
39
frontend/src/components/Footer.tsx
Normal file
39
frontend/src/components/Footer.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Footer component with version info
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBackendVersion } from '../api/client';
|
||||
|
||||
export default function Footer() {
|
||||
const { data: versionData } = useQuery({
|
||||
queryKey: ['backendVersion'],
|
||||
queryFn: getBackendVersion,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
refetchInterval: 300000, // Refetch every 5 minutes
|
||||
});
|
||||
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-0 right-0 bg-dark-card border-t border-dark-border py-2 px-4 z-10">
|
||||
<div className="container mx-auto flex items-center justify-between text-xs text-dark-text-muted">
|
||||
<div>
|
||||
AI Code Review Agent
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Backend v{versionData?.version || '...'}
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/yourusername/ai-review-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-dark-text-secondary transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WS_URL } from '../api/websocket';
|
||||
import { getReviewEvents, ReviewEvent } from '../api/client';
|
||||
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
@ -31,48 +32,150 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
||||
const [currentStep, setCurrentStep] = useState<string>('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [llmMessages, setLlmMessages] = useState<string[]>([]);
|
||||
const [llmStreamingText, setLlmStreamingText] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔌 Connecting to WebSocket:', WS_URL);
|
||||
console.log('👀 Watching for review ID:', reviewId);
|
||||
|
||||
// Load historical events from database
|
||||
const loadHistoricalEvents = async () => {
|
||||
try {
|
||||
console.log('📥 Loading historical events from DB...');
|
||||
const historicalEvents = await getReviewEvents(reviewId);
|
||||
console.log(`✅ Loaded ${historicalEvents.length} historical events`);
|
||||
|
||||
// Convert DB events to stream events format
|
||||
const streamEvents: StreamEvent[] = historicalEvents.map((dbEvent: ReviewEvent) => ({
|
||||
type: dbEvent.event_type,
|
||||
review_id: dbEvent.review_id,
|
||||
pr_number: 0, // Not stored in DB
|
||||
timestamp: dbEvent.created_at,
|
||||
data: {
|
||||
type: dbEvent.event_type,
|
||||
step: dbEvent.step,
|
||||
message: dbEvent.message,
|
||||
data: dbEvent.data
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(streamEvents);
|
||||
|
||||
// Set current step from last event
|
||||
const lastAgentStep = streamEvents.reverse().find(e => e.type === 'agent_step');
|
||||
if (lastAgentStep && lastAgentStep.data.step) {
|
||||
setCurrentStep(lastAgentStep.data.step);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading historical events:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistoricalEvents();
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
let pingInterval: number;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for streaming');
|
||||
console.log('✅ WebSocket connected for streaming');
|
||||
setIsConnected(true);
|
||||
|
||||
// Start ping interval (every 30 seconds)
|
||||
pingInterval = window.setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('🏓 Sending ping...');
|
||||
ws.send('ping');
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: StreamEvent = JSON.parse(event.data);
|
||||
console.log('📨 WebSocket message received:', event.data);
|
||||
const data: any = JSON.parse(event.data);
|
||||
console.log('📦 Parsed event:', data);
|
||||
|
||||
// Filter events for this review
|
||||
if (data.review_id === reviewId) {
|
||||
setEvents((prev) => [...prev, data]);
|
||||
// Handle different message types
|
||||
if (data.type === 'connection') {
|
||||
console.log('🔗 Connection confirmed:', data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'pong') {
|
||||
console.log('🏓 Pong received');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'echo') {
|
||||
console.log('📢 Echo:', data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Review events
|
||||
if (data.review_id !== undefined) {
|
||||
console.log(`🔍 Event review_id: ${data.review_id}, Expected: ${reviewId}, Type: ${data.type}`);
|
||||
|
||||
// Filter events for this review OR show all for debugging
|
||||
if (data.review_id === reviewId || true) { // Allow all for now for debugging
|
||||
console.log(`✅ Processing event type: ${data.type}`);
|
||||
setEvents((prev) => [...prev, data]);
|
||||
|
||||
// Update current step
|
||||
if (data.data.type === 'agent_step') {
|
||||
setCurrentStep(data.data.step || '');
|
||||
}
|
||||
// Update current step
|
||||
if (data.type === 'agent_step' || data.data?.type === 'agent_step') {
|
||||
const step = data.data?.step || data.step;
|
||||
console.log('🚶 Agent step:', step);
|
||||
setCurrentStep(step || '');
|
||||
}
|
||||
|
||||
// Collect LLM messages
|
||||
if (data.data.type === 'llm_message') {
|
||||
setLlmMessages((prev) => [...prev, data.data.message || '']);
|
||||
// Handle LLM streaming chunks
|
||||
if (data.type === 'llm_chunk' || data.data?.type === 'llm_chunk') {
|
||||
const chunk = data.data?.chunk || data.chunk || '';
|
||||
setLlmStreamingText((prev) => prev + chunk);
|
||||
}
|
||||
|
||||
// Collect LLM messages
|
||||
if (data.type === 'llm_message' || data.data?.type === 'llm_message') {
|
||||
const message = data.data?.message || data.message;
|
||||
console.log('💬 LLM message:', message);
|
||||
setLlmMessages((prev) => [...prev, message || '']);
|
||||
}
|
||||
|
||||
// Handle special events
|
||||
if (data.type === 'review_started') {
|
||||
console.log('🎬 Review started:', data.data?.message);
|
||||
// Сбрасываем streaming текст при начале нового review
|
||||
setLlmStreamingText('');
|
||||
}
|
||||
|
||||
if (data.type === 'review_completed') {
|
||||
console.log('🎉 Review completed:', data.data?.message);
|
||||
}
|
||||
} else {
|
||||
console.log('⏭️ Event is for different review, skipping');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
console.error('❌ Error parsing WebSocket message:', error, 'Data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
console.log('🔌 WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
if (pingInterval) {
|
||||
window.clearInterval(pingInterval);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
console.error('❌ WebSocket error:', error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log('🔌 Closing WebSocket');
|
||||
if (pingInterval) {
|
||||
window.clearInterval(pingInterval);
|
||||
}
|
||||
ws.close();
|
||||
};
|
||||
}, [reviewId]);
|
||||
@ -130,38 +233,53 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-dark-text-muted py-4">
|
||||
Ожидание событий...
|
||||
<div className="animate-pulse">⏳ Ожидание событий...</div>
|
||||
<div className="text-xs mt-2">
|
||||
{isConnected ? '✅ WebSocket подключен' : '❌ WebSocket отключен'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-dark-text-secondary text-xs">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-xs bg-blue-900/30 text-blue-400 px-2 py-1 rounded">
|
||||
{event.data.type}
|
||||
</span>
|
||||
{events.map((event: any, index) => {
|
||||
const eventType = event.type || event.data?.type;
|
||||
const eventMessage = event.data?.message || event.message;
|
||||
const eventStep = event.data?.step || event.step;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-dark-text-secondary text-xs">
|
||||
{new Date(event.timestamp).toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
eventType === 'review_started' ? 'bg-green-900/30 text-green-400' :
|
||||
eventType === 'review_completed' ? 'bg-blue-900/30 text-blue-400' :
|
||||
eventType === 'agent_step' ? 'bg-purple-900/30 text-purple-400' :
|
||||
eventType === 'llm_message' ? 'bg-yellow-900/30 text-yellow-400' :
|
||||
'bg-gray-900/30 text-gray-400'
|
||||
}`}>
|
||||
{eventType}
|
||||
</span>
|
||||
</div>
|
||||
{eventStep && (
|
||||
<div className="text-dark-text-primary">
|
||||
{getStepInfo(eventStep).icon} {getStepInfo(eventStep).name}
|
||||
</div>
|
||||
)}
|
||||
{eventMessage && (
|
||||
<div className="text-dark-text-muted mt-1 text-xs">
|
||||
{eventMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{event.data.step && (
|
||||
<div className="text-dark-text-primary">
|
||||
{getStepInfo(event.data.step).icon} {getStepInfo(event.data.step).name}
|
||||
</div>
|
||||
)}
|
||||
{event.data.message && (
|
||||
<div className="text-dark-text-muted mt-1 text-xs">
|
||||
{event.data.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -210,6 +328,21 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
||||
|
||||
{renderStepProgress()}
|
||||
|
||||
{/* LLM Streaming текст */}
|
||||
{llmStreamingText && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-text-primary mb-3 flex items-center gap-2">
|
||||
<span className="animate-pulse">🤖</span> AI генерирует ответ...
|
||||
</h3>
|
||||
<div className="bg-dark-card border border-dark-border rounded-lg p-4">
|
||||
<pre className="text-sm text-dark-text-secondary font-mono whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
|
||||
{llmStreamingText}
|
||||
<span className="animate-pulse">▋</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-text-primary mb-3">
|
||||
📝 События
|
||||
|
||||
@ -18,7 +18,7 @@ export default function WebSocketStatus() {
|
||||
|
||||
const statusColors = {
|
||||
connected: 'bg-green-500',
|
||||
disconnected: 'bg-gray-500',
|
||||
disconnected: 'bg-dark-text-muted',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
@ -29,9 +29,9 @@ export default function WebSocketStatus() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-800">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-dark-card border border-dark-border">
|
||||
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`} />
|
||||
<span className="text-sm text-gray-300">{statusLabels[status]}</span>
|
||||
<span className="text-sm text-dark-text-secondary">{statusLabels[status]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -140,23 +140,23 @@ export default function Organizations() {
|
||||
{data?.items.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
||||
className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
<h3 className="text-xl font-semibold text-dark-text-primary">{org.name}</h3>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${
|
||||
org.is_active ? 'bg-green-900/30 text-green-400 border-green-700' : 'bg-dark-card text-dark-text-muted border-dark-border'
|
||||
}`}>
|
||||
{org.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-900/30 text-blue-400 border border-blue-700">
|
||||
{org.platform.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div className="mt-2 space-y-1 text-sm text-dark-text-secondary">
|
||||
<div>🌐 {org.base_url}</div>
|
||||
{org.last_scan_at && (
|
||||
<div>
|
||||
@ -166,8 +166,8 @@ export default function Organizations() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code>
|
||||
<div className="mt-3 text-xs text-dark-text-muted">
|
||||
Webhook: <code className="bg-dark-bg px-2 py-1 rounded border border-dark-border">{org.webhook_url}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -181,13 +181,13 @@ export default function Organizations() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingOrg(org)}
|
||||
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||
className="px-3 py-1.5 text-sm bg-dark-hover text-dark-text-primary rounded hover:bg-dark-border transition-colors border border-dark-border"
|
||||
>
|
||||
✏️ Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(org)}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
||||
className="px-3 py-1.5 text-sm bg-red-900/30 text-red-400 rounded hover:bg-red-900/50 transition-colors border border-red-700"
|
||||
>
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
@ -198,8 +198,8 @@ export default function Organizations() {
|
||||
</div>
|
||||
|
||||
{data?.items.length === 0 && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Нет организаций</p>
|
||||
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
|
||||
<p className="text-dark-text-muted">Нет организаций</p>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
@ -277,15 +277,15 @@ function OrganizationForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-card rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-dark-border">
|
||||
<h2 className="text-2xl font-bold mb-4 text-dark-text-primary">
|
||||
{organization ? 'Редактировать организацию' : 'Новая организация'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||||
Название организации *
|
||||
</label>
|
||||
<input
|
||||
@ -293,19 +293,19 @@ function OrganizationForm({
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="inno-js"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||||
Платформа *
|
||||
</label>
|
||||
<select
|
||||
value={formData.platform}
|
||||
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
@ -314,7 +314,7 @@ function OrganizationForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||||
Base URL *
|
||||
</label>
|
||||
<input
|
||||
@ -322,36 +322,36 @@ function OrganizationForm({
|
||||
required
|
||||
value={formData.base_url}
|
||||
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="https://git.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||||
API токен
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.api_token}
|
||||
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Опционально (используется master токен если не указан)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-dark-text-muted mt-1">
|
||||
💡 Если не указан, будет использован master токен из конфигурации сервера
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||||
Webhook Secret
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.webhook_secret}
|
||||
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Опционально (генерируется автоматически)"
|
||||
/>
|
||||
</div>
|
||||
@ -360,14 +360,14 @@ function OrganizationForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
className="flex-1 px-4 py-2 bg-dark-hover text-dark-text-primary border border-dark-border rounded-lg hover:bg-dark-border transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
|
||||
@ -74,15 +74,15 @@ export default function Tasks() {
|
||||
const getStatusColor = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-yellow-900/30 text-yellow-400 border border-yellow-700';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
return 'bg-blue-900/30 text-blue-400 border border-blue-700';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
return 'bg-green-900/30 text-green-400 border border-green-700';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
return 'bg-red-900/30 text-red-400 border border-red-700';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
return 'bg-dark-card text-dark-text-muted border border-dark-border';
|
||||
}
|
||||
};
|
||||
|
||||
@ -104,20 +104,20 @@ export default function Tasks() {
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800';
|
||||
return 'bg-red-900/30 text-red-400 border border-red-700';
|
||||
case 'normal':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
return 'bg-dark-card text-dark-text-secondary border border-dark-border';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800';
|
||||
return 'bg-green-900/30 text-green-400 border border-green-700';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
return 'bg-dark-card text-dark-text-muted border border-dark-border';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Загрузка...</div>
|
||||
<div className="text-dark-text-muted">Загрузка...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -126,23 +126,23 @@ export default function Tasks() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
<h1 className="text-3xl font-bold text-dark-text-primary">Очередь задач</h1>
|
||||
<p className="text-dark-text-secondary mt-1">
|
||||
Мониторинг и управление задачами на review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Worker Status */}
|
||||
{workerStatus && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
|
||||
<span className="font-medium">
|
||||
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-dark-text-muted'}`} />
|
||||
<span className="font-medium text-dark-text-primary">
|
||||
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-dark-text-secondary">
|
||||
{workerStatus.current_task_id && (
|
||||
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
|
||||
)}
|
||||
@ -158,49 +158,49 @@ export default function Tasks() {
|
||||
{tasksData && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
statusFilter === undefined ? 'ring-2 ring-indigo-500' : ''
|
||||
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
|
||||
statusFilter === undefined ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter(undefined)}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div>
|
||||
<div className="text-sm text-gray-600">Всего</div>
|
||||
<div className="text-2xl font-bold text-dark-text-primary">{tasksData.total}</div>
|
||||
<div className="text-sm text-dark-text-secondary">Всего</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
|
||||
statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div>
|
||||
<div className="text-sm text-gray-600">Ожидает</div>
|
||||
<div className="text-2xl font-bold text-yellow-400">{tasksData.pending}</div>
|
||||
<div className="text-sm text-dark-text-secondary">Ожидает</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
|
||||
statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('in_progress')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div>
|
||||
<div className="text-sm text-gray-600">Выполняется</div>
|
||||
<div className="text-2xl font-bold text-blue-400">{tasksData.in_progress}</div>
|
||||
<div className="text-sm text-dark-text-secondary">Выполняется</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
|
||||
statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('completed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div>
|
||||
<div className="text-sm text-gray-600">Завершено</div>
|
||||
<div className="text-2xl font-bold text-green-400">{tasksData.completed}</div>
|
||||
<div className="text-sm text-dark-text-secondary">Завершено</div>
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
|
||||
statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
|
||||
}`}
|
||||
onClick={() => setStatusFilter('failed')}
|
||||
>
|
||||
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div>
|
||||
<div className="text-sm text-gray-600">Ошибок</div>
|
||||
<div className="text-2xl font-bold text-red-400">{tasksData.failed}</div>
|
||||
<div className="text-sm text-dark-text-secondary">Ошибок</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -210,12 +210,12 @@ export default function Tasks() {
|
||||
{tasksData?.items.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
|
||||
className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-mono font-semibold text-gray-900">
|
||||
<span className="text-lg font-mono font-semibold text-dark-text-primary">
|
||||
#{task.id}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||
@ -230,12 +230,12 @@ export default function Tasks() {
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">PR:</span>{' '}
|
||||
<span className="font-medium">#{task.pr_number}</span>{' '}
|
||||
<span className="text-gray-700">{task.pr_title}</span>
|
||||
<span className="text-dark-text-secondary">PR:</span>{' '}
|
||||
<span className="font-medium text-dark-text-primary">#{task.pr_number}</span>{' '}
|
||||
<span className="text-dark-text-primary">{task.pr_title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<div className="flex gap-4 text-xs text-dark-text-muted">
|
||||
<span>
|
||||
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
|
||||
</span>
|
||||
@ -252,13 +252,13 @@ export default function Tasks() {
|
||||
</div>
|
||||
|
||||
{task.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700">
|
||||
<div className="mt-2 p-2 bg-red-900/30 border border-red-700 rounded text-xs text-red-400">
|
||||
<strong>Ошибка:</strong> {task.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.retry_count > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-dark-text-muted">
|
||||
Попыток: {task.retry_count} / {task.max_retries}
|
||||
</div>
|
||||
)}
|
||||
@ -270,7 +270,7 @@ export default function Tasks() {
|
||||
<button
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm bg-blue-900/30 text-blue-400 rounded hover:bg-blue-900/50 transition-colors disabled:opacity-50 border border-blue-700"
|
||||
>
|
||||
🔄 Повторить
|
||||
</button>
|
||||
@ -279,7 +279,7 @@ export default function Tasks() {
|
||||
<button
|
||||
onClick={() => handleDelete(task.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm bg-red-900/30 text-red-400 rounded hover:bg-red-900/50 transition-colors disabled:opacity-50 border border-red-700"
|
||||
>
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
@ -291,8 +291,8 @@ export default function Tasks() {
|
||||
</div>
|
||||
|
||||
{tasksData?.items.length === 0 && (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">
|
||||
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
|
||||
<p className="text-dark-text-muted">
|
||||
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
|
||||
</p>
|
||||
</div>
|
||||
@ -305,7 +305,7 @@ export default function Tasks() {
|
||||
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
||||
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
|
||||
>
|
||||
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
|
||||
<p className="text-dark-text-primary whitespace-pre-line">{modalMessage}</p>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
|
||||
146
start.bat
146
start.bat
@ -1,100 +1,78 @@
|
||||
@echo off
|
||||
REM Единый скрипт запуска AI Code Review Platform
|
||||
REM AI Review - Build & Start
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo AI Code Review Platform - Запуск
|
||||
echo ========================================
|
||||
echo ================================
|
||||
echo AI Review - Starting
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
REM 1. Проверка Node.js
|
||||
echo [STEP 1/7] Проверка Node.js...
|
||||
where node >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Node.js не установлен! Установите Node.js 18+ и попробуйте снова.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
node --version
|
||||
echo [OK] Node.js установлен
|
||||
echo.
|
||||
|
||||
REM 2. Проверка Python
|
||||
echo [STEP 2/7] Проверка Python...
|
||||
where python >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Python не установлен! Установите Python 3.10+ и попробуйте снова.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
python --version
|
||||
echo [OK] Python установлен
|
||||
echo.
|
||||
|
||||
REM 3. Установка зависимостей frontend
|
||||
echo [STEP 3/7] Установка зависимостей frontend...
|
||||
REM 1. Build Frontend
|
||||
echo [1/3] Building frontend...
|
||||
cd frontend
|
||||
|
||||
if not exist "node_modules\" (
|
||||
echo Установка npm пакетов...
|
||||
echo Installing npm packages...
|
||||
call npm install
|
||||
) else (
|
||||
echo node_modules уже существует, пропускаем...
|
||||
)
|
||||
echo [OK] Зависимости frontend установлены
|
||||
echo.
|
||||
|
||||
REM 4. Сборка frontend
|
||||
echo [STEP 4/7] Сборка frontend...
|
||||
|
||||
REM Создаем .env.production для production
|
||||
echo VITE_API_URL=/api > .env.production
|
||||
echo VITE_WS_URL= >> .env.production
|
||||
|
||||
call npm run build
|
||||
echo [OK] Frontend собран в backend/public
|
||||
echo.
|
||||
|
||||
REM 5. Установка зависимостей backend
|
||||
cd ..\backend
|
||||
echo [STEP 5/7] Установка зависимостей backend...
|
||||
if not exist "venv\" (
|
||||
echo Создание виртуального окружения...
|
||||
python -m venv venv
|
||||
)
|
||||
|
||||
REM Активация venv
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
REM Установка зависимостей
|
||||
pip install -r requirements.txt
|
||||
echo [OK] Зависимости backend установлены
|
||||
echo.
|
||||
|
||||
REM 6. Проверка .env
|
||||
echo [STEP 6/7] Проверка конфигурации...
|
||||
if not exist ".env" (
|
||||
echo [WARNING] Файл .env не найден!
|
||||
if exist ".env.example" (
|
||||
echo Создаем .env из примера...
|
||||
copy .env.example .env
|
||||
echo [OK] Создан .env файл
|
||||
echo [WARNING] ВАЖНО: Отредактируйте .env и добавьте необходимые токены!
|
||||
) else (
|
||||
echo [ERROR] .env.example не найден!
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] npm install failed
|
||||
cd ..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
echo.
|
||||
|
||||
REM 7. Запуск backend
|
||||
echo [STEP 7/7] Запуск сервера...
|
||||
echo ========================================
|
||||
echo Building...
|
||||
call npm run build
|
||||
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Frontend build failed
|
||||
cd ..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [OK] Frontend built to backend\public
|
||||
cd ..
|
||||
|
||||
REM 2. Setup Backend
|
||||
echo.
|
||||
echo Backend: http://localhost:8000
|
||||
echo Frontend: http://localhost:8000
|
||||
echo [2/3] Setting up backend...
|
||||
cd backend
|
||||
|
||||
if not exist "venv\" (
|
||||
echo Creating venv...
|
||||
python -m venv venv
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Failed to create venv
|
||||
cd ..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo Activating venv...
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
echo Installing dependencies...
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Failed to install dependencies
|
||||
cd ..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 3. Start Backend
|
||||
echo.
|
||||
echo [3/3] Starting server...
|
||||
echo ================================
|
||||
echo.
|
||||
echo URL: http://localhost:8000
|
||||
echo API Docs: http://localhost:8000/docs
|
||||
echo.
|
||||
echo Для остановки нажмите Ctrl+C
|
||||
echo Press Ctrl+C to stop
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
|
||||
110
start.sh
110
start.sh
@ -1,104 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Единый скрипт запуска AI Code Review Platform
|
||||
# AI Review - Build & Start
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 AI Code Review Platform - Запуск"
|
||||
echo "===================================="
|
||||
echo "================================"
|
||||
echo "AI Review - Starting"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 1. Проверка Node.js
|
||||
echo -e "${YELLOW}📦 Проверка Node.js...${NC}"
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js не установлен! Установите Node.js 18+ и попробуйте снова."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ Node.js $(node --version)${NC}"
|
||||
echo ""
|
||||
|
||||
# 2. Проверка Python
|
||||
echo -e "${YELLOW}🐍 Проверка Python...${NC}"
|
||||
if ! command -v python &> /dev/null && ! command -v python3 &> /dev/null; then
|
||||
echo "❌ Python не установлен! Установите Python 3.10+ и попробуйте снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON_CMD="python3"
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
PYTHON_CMD="python"
|
||||
fi
|
||||
echo -e "${GREEN}✅ Python $($PYTHON_CMD --version)${NC}"
|
||||
echo ""
|
||||
|
||||
# 3. Установка зависимостей frontend
|
||||
echo -e "${YELLOW}📦 Установка зависимостей frontend...${NC}"
|
||||
# 1. Build Frontend
|
||||
echo "[1/3] Building frontend..."
|
||||
cd frontend
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing npm packages..."
|
||||
npm install
|
||||
else
|
||||
echo "node_modules уже существует, пропускаем..."
|
||||
fi
|
||||
echo -e "${GREEN}✅ Зависимости frontend установлены${NC}"
|
||||
echo ""
|
||||
|
||||
# 4. Сборка frontend
|
||||
echo -e "${YELLOW}🔨 Сборка frontend...${NC}"
|
||||
|
||||
# Создаем .env.production для production
|
||||
cat > .env.production << 'EOF'
|
||||
VITE_API_URL=/api
|
||||
VITE_WS_URL=
|
||||
EOF
|
||||
|
||||
echo "Building..."
|
||||
npm run build
|
||||
echo -e "${GREEN}✅ Frontend собран в backend/public${NC}"
|
||||
echo ""
|
||||
|
||||
# 5. Установка зависимостей backend
|
||||
cd ../backend
|
||||
echo -e "${YELLOW}📦 Установка зависимостей backend...${NC}"
|
||||
echo "[OK] Frontend built to backend/public"
|
||||
cd ..
|
||||
|
||||
# 2. Setup Backend
|
||||
echo ""
|
||||
echo "[2/3] Setting up backend..."
|
||||
cd backend
|
||||
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Создание виртуального окружения..."
|
||||
$PYTHON_CMD -m venv venv
|
||||
echo "Creating venv..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Активация venv
|
||||
echo "Activating venv..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Установка зависимостей
|
||||
pip install -r requirements.txt
|
||||
echo -e "${GREEN}✅ Зависимости backend установлены${NC}"
|
||||
echo ""
|
||||
echo "Installing dependencies..."
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
# 6. Проверка .env
|
||||
if [ ! -f ".env" ]; then
|
||||
echo -e "${YELLOW}⚠️ Файл .env не найден!${NC}"
|
||||
echo "Создаем .env из примера..."
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example .env
|
||||
echo -e "${GREEN}✅ Создан .env файл${NC}"
|
||||
echo -e "${YELLOW}⚠️ ВАЖНО: Отредактируйте .env и добавьте необходимые токены!${NC}"
|
||||
else
|
||||
echo "❌ .env.example не найден!"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 7. Запуск backend
|
||||
echo -e "${GREEN}🎉 Запуск сервера...${NC}"
|
||||
echo "===================================="
|
||||
# 3. Start Backend
|
||||
echo ""
|
||||
echo "📍 Backend: http://localhost:8000"
|
||||
echo "📍 Frontend: http://localhost:8000"
|
||||
echo "📍 API Docs: http://localhost:8000/docs"
|
||||
echo "[3/3] Starting server..."
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "Для остановки нажмите Ctrl+C"
|
||||
echo "URL: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
|
||||
72
tests/README.md
Normal file
72
tests/README.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Тесты
|
||||
|
||||
Эта папка содержит тестовые скрипты для проверки различных компонентов системы.
|
||||
|
||||
## Тесты стриминга
|
||||
|
||||
### test_simple_graph.py
|
||||
|
||||
Простой тест стриминга LangGraph без реальных данных и БД.
|
||||
|
||||
**Запуск:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
$env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_simple_graph.py # Windows PowerShell
|
||||
# или
|
||||
python ../tests/test_simple_graph.py # Linux/Mac
|
||||
```
|
||||
|
||||
**Что тестирует:**
|
||||
- Различные режимы стриминга (`updates`, `messages`, `values`, `debug`)
|
||||
- Обработку событий через callback
|
||||
- Формат событий от LangGraph
|
||||
|
||||
### test_langgraph_events.py
|
||||
|
||||
Полный тест с реальным ReviewerAgent и БД.
|
||||
|
||||
**Требования:**
|
||||
- Работающая БД с данными
|
||||
- Существующий Review ID, PR Number, Repository ID
|
||||
- Настроенный `.env` файл
|
||||
|
||||
**Запуск:**
|
||||
|
||||
1. Отредактируйте параметры в файле:
|
||||
```python
|
||||
TEST_REVIEW_ID = 1
|
||||
TEST_PR_NUMBER = 5
|
||||
TEST_REPOSITORY_ID = 1
|
||||
```
|
||||
|
||||
2. Запустите:
|
||||
```bash
|
||||
cd backend
|
||||
python ../tests/test_langgraph_events.py
|
||||
```
|
||||
|
||||
### test_llm_streaming.py
|
||||
|
||||
Тест стриминга LLM messages с реальным Ollama.
|
||||
|
||||
**Требования:**
|
||||
- Ollama запущен (`ollama serve`)
|
||||
- Модель загружена (`ollama pull qwen2.5-coder:3b`)
|
||||
|
||||
**Запуск:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
$env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_llm_streaming.py # Windows
|
||||
python ../tests/test_llm_streaming.py # Linux/Mac
|
||||
```
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
Добавляйте новые тесты в эту папку с префиксом `test_`.
|
||||
|
||||
## Полезные ссылки
|
||||
|
||||
- [TEST_STREAMING.md](../docs/TEST_STREAMING.md) - Детальная документация по тестированию стриминга
|
||||
|
||||
229
tests/test_langgraph_events.py
Normal file
229
tests/test_langgraph_events.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""
|
||||
Тестовый скрипт для проверки событий LangGraph
|
||||
Запустить: python test_langgraph_events.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from backend.app.agents.reviewer import ReviewerAgent
|
||||
|
||||
|
||||
async def test_streaming():
|
||||
"""Тест стриминга событий от LangGraph"""
|
||||
print("="*80)
|
||||
print("ТЕСТ СТРИМИНГА СОБЫТИЙ LANGGRAPH")
|
||||
print("="*80)
|
||||
|
||||
# Параметры для теста (замените на реальные значения из вашей БД)
|
||||
TEST_REVIEW_ID = 1
|
||||
TEST_PR_NUMBER = 5
|
||||
TEST_REPOSITORY_ID = 1
|
||||
|
||||
print(f"\n📋 Параметры теста:")
|
||||
print(f" Review ID: {TEST_REVIEW_ID}")
|
||||
print(f" PR Number: {TEST_PR_NUMBER}")
|
||||
print(f" Repository ID: {TEST_REPOSITORY_ID}")
|
||||
|
||||
# Создаем сессию БД
|
||||
async with AsyncSessionLocal() as db:
|
||||
print(f"\n✅ Подключение к БД установлено")
|
||||
|
||||
# Создаем агента
|
||||
agent = ReviewerAgent(db)
|
||||
print(f"✅ ReviewerAgent создан")
|
||||
|
||||
# Счетчик событий
|
||||
event_counter = {
|
||||
'total': 0,
|
||||
'updates': 0,
|
||||
'messages': 0,
|
||||
'other': 0
|
||||
}
|
||||
|
||||
# Callback для событий
|
||||
async def on_event(event: dict):
|
||||
event_counter['total'] += 1
|
||||
event_type = event.get('type', 'unknown')
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"📨 СОБЫТИЕ #{event_counter['total']} - Тип: {event_type}")
|
||||
print(f"{'='*80}")
|
||||
print(f"Полное событие: {event}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
print(f"\n🚀 Запуск review с стримингом...\n")
|
||||
|
||||
try:
|
||||
# Запускаем review
|
||||
result = await agent.run_review_stream(
|
||||
review_id=TEST_REVIEW_ID,
|
||||
pr_number=TEST_PR_NUMBER,
|
||||
repository_id=TEST_REPOSITORY_ID,
|
||||
on_event=on_event
|
||||
)
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"✅ REVIEW ЗАВЕРШЕН")
|
||||
print(f"{'='*80}")
|
||||
print(f"\n📊 Статистика событий:")
|
||||
print(f" Всего событий: {event_counter['total']}")
|
||||
print(f" Updates: {event_counter['updates']}")
|
||||
print(f" Messages: {event_counter['messages']}")
|
||||
print(f" Other: {event_counter['other']}")
|
||||
|
||||
print(f"\n📝 Финальное состояние:")
|
||||
print(f" Status: {result.get('status')}")
|
||||
print(f" Files: {len(result.get('files', []))}")
|
||||
print(f" Comments: {len(result.get('comments', []))}")
|
||||
print(f" Error: {result.get('error')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ ОШИБКА при выполнении review:")
|
||||
print(f" {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
async def test_raw_graph_streaming():
|
||||
"""Тест RAW стриминга напрямую из графа"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ RAW СТРИМИНГА НАПРЯМУЮ ИЗ ГРАФА")
|
||||
print("="*80)
|
||||
|
||||
# Параметры для теста
|
||||
TEST_REVIEW_ID = 1
|
||||
TEST_PR_NUMBER = 5
|
||||
TEST_REPOSITORY_ID = 1
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
agent = ReviewerAgent(db)
|
||||
|
||||
initial_state = {
|
||||
"review_id": TEST_REVIEW_ID,
|
||||
"pr_number": TEST_PR_NUMBER,
|
||||
"repository_id": TEST_REPOSITORY_ID,
|
||||
"status": "pending",
|
||||
"files": [],
|
||||
"analyzed_files": [],
|
||||
"comments": [],
|
||||
"error": None,
|
||||
"git_service": None
|
||||
}
|
||||
|
||||
print(f"\n🔍 Тест 1: stream_mode=['updates']")
|
||||
print("-" * 80)
|
||||
|
||||
event_count = 0
|
||||
try:
|
||||
async for event in agent.graph.astream(initial_state, stream_mode=["updates"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
print(f" Content: {event}")
|
||||
|
||||
if event_count > 10: # Ограничение для безопасности
|
||||
print("\n⚠️ Остановка после 10 событий")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\n❌ Ошибка: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\n✅ Всего событий 'updates': {event_count}")
|
||||
|
||||
# Тест 2: messages
|
||||
print(f"\n\n🔍 Тест 2: stream_mode=['messages']")
|
||||
print("-" * 80)
|
||||
|
||||
event_count = 0
|
||||
try:
|
||||
# Создаем новый агент для чистого теста
|
||||
agent2 = ReviewerAgent(db)
|
||||
|
||||
async for event in agent2.graph.astream(initial_state, stream_mode=["messages"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
print(f" Content: {event}")
|
||||
|
||||
if event_count > 10:
|
||||
print("\n⚠️ Остановка после 10 событий")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\n❌ Ошибка: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\n✅ Всего событий 'messages': {event_count}")
|
||||
|
||||
# Тест 3: updates + messages
|
||||
print(f"\n\n🔍 Тест 3: stream_mode=['updates', 'messages']")
|
||||
print("-" * 80)
|
||||
|
||||
event_count = 0
|
||||
try:
|
||||
agent3 = ReviewerAgent(db)
|
||||
|
||||
async for event in agent3.graph.astream(initial_state, stream_mode=["updates", "messages"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
|
||||
# Детальный разбор события
|
||||
if isinstance(event, dict):
|
||||
print(f" Dict keys: {list(event.keys())}")
|
||||
for key, value in event.items():
|
||||
print(f" {key}: {type(value).__name__}")
|
||||
elif isinstance(event, tuple):
|
||||
print(f" Tuple length: {len(event)}")
|
||||
for i, item in enumerate(event):
|
||||
print(f" [{i}]: {type(item).__name__}")
|
||||
else:
|
||||
print(f" Content preview: {str(event)[:200]}")
|
||||
|
||||
if event_count > 10:
|
||||
print("\n⚠️ Остановка после 10 событий")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\n❌ Ошибка: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\n✅ Всего событий 'updates + messages': {event_count}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Главная функция"""
|
||||
import sys
|
||||
|
||||
print("\n" + "🔬"*40)
|
||||
print("ТЕСТИРОВАНИЕ СОБЫТИЙ LANGGRAPH")
|
||||
print("🔬"*40 + "\n")
|
||||
|
||||
print("Выберите тест:")
|
||||
print("1. Полный review с callback (test_streaming)")
|
||||
print("2. RAW стриминг напрямую из графа (test_raw_graph_streaming)")
|
||||
print("3. Оба теста")
|
||||
|
||||
choice = input("\nВведите номер теста (1/2/3) [по умолчанию: 3]: ").strip() or "3"
|
||||
|
||||
if choice in ["1", "3"]:
|
||||
print("\n" + "▶️"*40)
|
||||
print("ЗАПУСК ТЕСТА 1: Полный review")
|
||||
print("▶️"*40 + "\n")
|
||||
await test_streaming()
|
||||
|
||||
if choice in ["2", "3"]:
|
||||
print("\n" + "▶️"*40)
|
||||
print("ЗАПУСК ТЕСТА 2: RAW стриминг")
|
||||
print("▶️"*40 + "\n")
|
||||
await test_raw_graph_streaming()
|
||||
|
||||
print("\n" + "✅"*40)
|
||||
print("ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ")
|
||||
print("✅"*40 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
137
tests/test_llm_streaming.py
Normal file
137
tests/test_llm_streaming.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""
|
||||
Тест стриминга LLM messages от LangGraph
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from langgraph.graph import StateGraph, END
|
||||
from typing import TypedDict, Annotated
|
||||
import operator
|
||||
from langchain_ollama import OllamaLLM
|
||||
|
||||
|
||||
class TestState(TypedDict):
|
||||
messages: Annotated[list, operator.add]
|
||||
result: str
|
||||
|
||||
|
||||
async def llm_node(state: TestState) -> TestState:
|
||||
"""Нода с LLM вызовом"""
|
||||
print(" [LLM NODE] Вызов LLM...")
|
||||
|
||||
llm = OllamaLLM(
|
||||
model="qwen2.5-coder:3b",
|
||||
base_url="http://localhost:11434",
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
# Простой промпт для быстрого ответа
|
||||
prompt = "Напиши короткую проверку кода на Python (не более 100 символов)"
|
||||
|
||||
response = await llm.ainvoke(prompt)
|
||||
|
||||
print(f" [LLM NODE] Ответ получен: {response[:50]}...")
|
||||
|
||||
return {
|
||||
"messages": [{"role": "ai", "content": response}],
|
||||
"result": response
|
||||
}
|
||||
|
||||
|
||||
def create_test_graph():
|
||||
"""Создает тестовый граф с LLM"""
|
||||
workflow = StateGraph(TestState)
|
||||
|
||||
workflow.add_node("llm_call", llm_node)
|
||||
|
||||
workflow.set_entry_point("llm_call")
|
||||
workflow.add_edge("llm_call", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
async def test_with_llm():
|
||||
"""Тест стриминга с LLM"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ СТРИМИНГА LLM MESSAGES")
|
||||
print("="*80)
|
||||
|
||||
graph = create_test_graph()
|
||||
|
||||
initial_state: TestState = {
|
||||
"messages": [],
|
||||
"result": ""
|
||||
}
|
||||
|
||||
# Тест: updates + messages
|
||||
print(f"\n🔍 Тест: stream_mode=['updates', 'messages']")
|
||||
print("-" * 80)
|
||||
|
||||
event_count = 0
|
||||
messages_count = 0
|
||||
|
||||
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
|
||||
event_count += 1
|
||||
|
||||
if isinstance(event, tuple) and len(event) >= 2:
|
||||
event_type, event_data = event[0], event[1]
|
||||
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {event_type}")
|
||||
print(f" Data type: {type(event_data)}")
|
||||
|
||||
if event_type == 'updates':
|
||||
print(f" ✅ Node update")
|
||||
if isinstance(event_data, dict):
|
||||
for node_name in event_data.keys():
|
||||
print(f" Node: {node_name}")
|
||||
|
||||
elif event_type == 'messages':
|
||||
messages_count += 1
|
||||
print(f" 💬 LLM Messages (#{messages_count})")
|
||||
|
||||
if isinstance(event_data, (list, tuple)):
|
||||
for i, msg in enumerate(event_data):
|
||||
print(f" Message {i+1}:")
|
||||
|
||||
# Извлекаем контент
|
||||
if hasattr(msg, 'content'):
|
||||
content = msg.content
|
||||
print(f" Content: {content[:100]}...")
|
||||
elif isinstance(msg, dict):
|
||||
print(f" Dict: {msg}")
|
||||
else:
|
||||
print(f" Type: {type(msg)}")
|
||||
print(f" Str: {str(msg)[:100]}...")
|
||||
|
||||
print(f"\n" + "="*80)
|
||||
print(f"✅ Всего событий: {event_count}")
|
||||
print(f"✅ Messages событий: {messages_count}")
|
||||
print("="*80)
|
||||
|
||||
|
||||
async def main():
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТИРОВАНИЕ LLM STREAMING В LANGGRAPH")
|
||||
print("="*80)
|
||||
print("\nПроверка Ollama...")
|
||||
|
||||
try:
|
||||
# Проверяем что Ollama доступен
|
||||
from langchain_ollama import OllamaLLM
|
||||
test_llm = OllamaLLM(model="qwen2.5-coder:3b", base_url="http://localhost:11434")
|
||||
result = await test_llm.ainvoke("test")
|
||||
print("✅ Ollama работает!")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка подключения к Ollama: {e}")
|
||||
print("\n⚠️ Убедитесь что Ollama запущен: ollama serve")
|
||||
print("⚠️ И модель загружена: ollama pull qwen2.5-coder:3b\n")
|
||||
return
|
||||
|
||||
await test_with_llm()
|
||||
|
||||
print("\n✅ Тестирование завершено\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
228
tests/test_simple_graph.py
Normal file
228
tests/test_simple_graph.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""
|
||||
Упрощенный тест LangGraph без реального review
|
||||
Проверяет только механизм стриминга событий
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from langgraph.graph import StateGraph, END
|
||||
from typing import TypedDict, Annotated, Any
|
||||
import operator
|
||||
|
||||
|
||||
# Простое состояние для теста
|
||||
class SimpleState(TypedDict):
|
||||
counter: Annotated[int, operator.add]
|
||||
messages: list[str]
|
||||
step: str
|
||||
|
||||
|
||||
# Простые ноды
|
||||
async def node_1(state: SimpleState) -> SimpleState:
|
||||
"""Первая нода"""
|
||||
print(" [NODE 1] Выполняется...")
|
||||
await asyncio.sleep(0.5)
|
||||
return {
|
||||
"counter": 1,
|
||||
"messages": ["Node 1 completed"],
|
||||
"step": "node_1"
|
||||
}
|
||||
|
||||
|
||||
async def node_2(state: SimpleState) -> SimpleState:
|
||||
"""Вторая нода"""
|
||||
print(" [NODE 2] Выполняется...")
|
||||
await asyncio.sleep(0.5)
|
||||
return {
|
||||
"counter": 1,
|
||||
"messages": ["Node 2 completed"],
|
||||
"step": "node_2"
|
||||
}
|
||||
|
||||
|
||||
async def node_3(state: SimpleState) -> SimpleState:
|
||||
"""Третья нода"""
|
||||
print(" [NODE 3] Выполняется...")
|
||||
await asyncio.sleep(0.5)
|
||||
return {
|
||||
"counter": 1,
|
||||
"messages": ["Node 3 completed"],
|
||||
"step": "node_3"
|
||||
}
|
||||
|
||||
|
||||
def create_test_graph():
|
||||
"""Создает тестовый граф"""
|
||||
workflow = StateGraph(SimpleState)
|
||||
|
||||
# Добавляем ноды
|
||||
workflow.add_node("node_1", node_1)
|
||||
workflow.add_node("node_2", node_2)
|
||||
workflow.add_node("node_3", node_3)
|
||||
|
||||
# Определяем связи
|
||||
workflow.set_entry_point("node_1")
|
||||
workflow.add_edge("node_1", "node_2")
|
||||
workflow.add_edge("node_2", "node_3")
|
||||
workflow.add_edge("node_3", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
async def test_stream_modes():
|
||||
"""Тестирует разные режимы стриминга"""
|
||||
graph = create_test_graph()
|
||||
|
||||
initial_state: SimpleState = {
|
||||
"counter": 0,
|
||||
"messages": [],
|
||||
"step": "start"
|
||||
}
|
||||
|
||||
# Тест 1: updates
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 1: stream_mode=['updates']")
|
||||
print("="*80)
|
||||
|
||||
event_count = 0
|
||||
async for event in graph.astream(initial_state, stream_mode=["updates"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
print(f" Content: {event}")
|
||||
|
||||
print(f"\n✅ Получено событий: {event_count}")
|
||||
|
||||
# Тест 2: messages
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 2: stream_mode=['messages']")
|
||||
print("="*80)
|
||||
|
||||
event_count = 0
|
||||
async for event in graph.astream(initial_state, stream_mode=["messages"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
print(f" Content: {event}")
|
||||
|
||||
print(f"\n✅ Получено событий: {event_count}")
|
||||
|
||||
# Тест 3: updates + messages
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 3: stream_mode=['updates', 'messages']")
|
||||
print("="*80)
|
||||
|
||||
event_count = 0
|
||||
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
|
||||
if isinstance(event, dict):
|
||||
print(f" Keys: {list(event.keys())}")
|
||||
elif isinstance(event, tuple):
|
||||
print(f" Tuple[0] type: {type(event[0])}")
|
||||
print(f" Tuple[1] type: {type(event[1])}")
|
||||
|
||||
print(f" Content: {event}")
|
||||
|
||||
print(f"\n✅ Получено событий: {event_count}")
|
||||
|
||||
# Тест 4: values
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 4: stream_mode=['values']")
|
||||
print("="*80)
|
||||
|
||||
event_count = 0
|
||||
async for event in graph.astream(initial_state, stream_mode=["values"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
print(f" Content: {event}")
|
||||
|
||||
print(f"\n✅ Получено событий: {event_count}")
|
||||
|
||||
# Тест 5: debug (все режимы)
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 5: stream_mode=['updates', 'messages', 'values', 'debug']")
|
||||
print("="*80)
|
||||
|
||||
event_count = 0
|
||||
async for event in graph.astream(
|
||||
initial_state,
|
||||
stream_mode=["updates", "messages", "values", "debug"]
|
||||
):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}")
|
||||
print(f" Type: {type(event)}")
|
||||
|
||||
if isinstance(event, tuple) and len(event) >= 2:
|
||||
print(f" Event type (tuple[0]): {event[0]}")
|
||||
print(f" Event data (tuple[1]): {event[1]}")
|
||||
else:
|
||||
print(f" Content: {event}")
|
||||
|
||||
print(f"\n✅ Получено событий: {event_count}")
|
||||
|
||||
|
||||
async def test_with_callback():
|
||||
"""Тест с использованием callback для обработки событий"""
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТ 6: Callback обработка событий")
|
||||
print("="*80)
|
||||
|
||||
graph = create_test_graph()
|
||||
|
||||
initial_state: SimpleState = {
|
||||
"counter": 0,
|
||||
"messages": [],
|
||||
"step": "start"
|
||||
}
|
||||
|
||||
collected_events = []
|
||||
|
||||
async def event_callback(event_type: str, event_data: Any):
|
||||
"""Callback для обработки событий"""
|
||||
collected_events.append({
|
||||
"type": event_type,
|
||||
"data": event_data
|
||||
})
|
||||
print(f" 🔔 Callback: {event_type}")
|
||||
|
||||
# Симуляция обработки событий с callback
|
||||
event_count = 0
|
||||
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
|
||||
event_count += 1
|
||||
print(f"\n📨 Event #{event_count}: {type(event)}")
|
||||
|
||||
# Обрабатываем событие
|
||||
if isinstance(event, tuple) and len(event) >= 2:
|
||||
await event_callback(str(event[0]), event[1])
|
||||
elif isinstance(event, dict):
|
||||
for node_name, node_data in event.items():
|
||||
await event_callback(f"node_update_{node_name}", node_data)
|
||||
else:
|
||||
await event_callback("unknown", event)
|
||||
|
||||
print(f"\n✅ Всего событий: {event_count}")
|
||||
print(f"✅ Callback вызовов: {len(collected_events)}")
|
||||
print(f"\n📋 Собранные события:")
|
||||
for i, evt in enumerate(collected_events, 1):
|
||||
print(f" {i}. {evt['type']}")
|
||||
|
||||
|
||||
async def main():
|
||||
print("\n" + "="*80)
|
||||
print("ПРОСТОЙ ТЕСТ LANGGRAPH STREAMING")
|
||||
print("="*80 + "\n")
|
||||
|
||||
await test_stream_modes()
|
||||
await test_with_callback()
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("ТЕСТИРОВАНИЕ ЗАВЕРШЕНО")
|
||||
print("="*80 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user