Compare commits

..

17 Commits
stream ... main

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
9e56676a6e feat: Implement cleanup for stuck tasks and reviews on server restart 2025-10-13 23:05:58 +03:00
Primakov Alexandr Alexandrovich
95b5d61ddb feat: Add unique thread_id generation for review process and enhance logging 2025-10-13 22:45:36 +03:00
Primakov Alexandr Alexandrovich
9dadc490e2 fix: Improve version retrieval logic with fallback mechanism and enhanced error handling 2025-10-13 18:07:19 +03:00
Primakov Alexandr Alexandrovich
1d953f554b feat: Implement LLM streaming support and enhance event handling in review process 2025-10-13 17:48:03 +03:00
Primakov Alexandr Alexandrovich
2f29ccff74 feat: Enhance review process with streaming events and detailed logging 2025-10-13 17:26:41 +03:00
Primakov Alexandr Alexandrovich
a762d09b3b fix: Update LangGraph event handling to process events as dictionaries instead of tuples 2025-10-13 16:14:07 +03:00
Primakov Alexandr Alexandrovich
6d375fd76d fix: Use python3 instead of python in systemd service + add fix script 2025-10-13 15:56:28 +03:00
Primakov Alexandr Alexandrovich
38539df42c fix: Update Python executable in deployment script to use python3 2025-10-13 15:55:34 +03:00
Primakov Alexandr Alexandrovich
8d231b49db fix: Fix /api/version endpoint path and save all review events to DB 2025-10-13 14:46:28 +03:00
Primakov Alexandr Alexandrovich
47bbb4ebc4 docs: Add review events and versioning documentation 2025-10-13 14:20:23 +03:00
Primakov Alexandr Alexandrovich
2db1225618 feat: Add review events persistence, version display, and auto-versioning system 2025-10-13 14:18:37 +03:00
Primakov Alexandr Alexandrovich
cfba28f913 fix: Correct LangGraph event handling - events are tuples not dicts + add test scripts 2025-10-13 13:46:35 +03:00
Primakov Alexandr Alexandrovich
c9dc486011 fix: Add manual step events in each graph node + detailed streaming debug 2025-10-13 12:58:58 +03:00
Primakov Alexandr Alexandrovich
a27a0fa0f0 feat: Add WebSocket ping/pong + detailed streaming debug + initial review messages 2025-10-13 10:30:56 +03:00
Primakov Alexandr Alexandrovich
3df9e61b55 UI: Fix all remaining light theme elements + add WebSocket streaming debug logging 2025-10-13 10:13:27 +03:00
Primakov Alexandr Alexandrovich
3981bdb1b3 UI: Fix light theme elements on Tasks page - apply dark theme colors 2025-10-13 01:08:01 +03:00
Primakov Alexandr Alexandrovich
256d69ec0f fix: Export WS_URL from websocket client 2025-10-13 01:02:22 +03:00
70 changed files with 2495 additions and 770 deletions

66
.git-hooks/README.md Normal file
View 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
View 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

View File

@ -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

View File

@ -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

View File

@ -6,10 +6,8 @@
## 🚀 Быстрый старт ## 🚀 Быстрый старт
### Запуск одной командой:
**Windows:** **Windows:**
```bash ```cmd
start.bat start.bat
``` ```
@ -19,13 +17,15 @@ chmod +x start.sh
./start.sh ./start.sh
``` ```
Это автоматически: Скрипт:
- ✅ Проверит зависимости 1. Соберет фронтенд в `backend/public`
- ✅ Установит пакеты 2. Запустит backend на http://localhost:8000
- ✅ Соберет frontend 3. Фронтенд отдается с бэкенда
- ✅ Запустит сервер
**Готово!** Откройте http://localhost:8000 **Один процесс, один порт, как в production.**
Перезапуск после изменений:
- Просто запусти скрипт заново (Ctrl+C → start.bat)
--- ---

55
RUN.bat Normal file
View 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
View File

@ -0,0 +1 @@
0.1.0

View File

@ -51,6 +51,7 @@ CODE_REVIEW_PROMPT = """Проанализируй следующий код и
] ]
}} }}
Напиши как можно больше комментариев, в том числе хвалебные.
Если проблем нет, верни пустой массив comments.""" Если проблем нет, верни пустой массив comments."""

View File

@ -142,6 +142,14 @@ class ReviewerAgent:
async def fetch_pr_info(self, state: ReviewState) -> ReviewState: async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
"""Fetch PR information""" """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: try:
# Update review status # Update review status
result = await self.db.execute( result = await self.db.execute(
@ -198,6 +206,14 @@ class ReviewerAgent:
async def fetch_files(self, state: ReviewState) -> ReviewState: async def fetch_files(self, state: ReviewState) -> ReviewState:
"""Fetch changed files in PR""" """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: try:
git_service = state["git_service"] git_service = state["git_service"]
@ -269,6 +285,14 @@ class ReviewerAgent:
async def analyze_files(self, state: ReviewState) -> ReviewState: async def analyze_files(self, state: ReviewState) -> ReviewState:
"""Analyze files and generate comments""" """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: try:
all_comments = [] all_comments = []
@ -291,6 +315,17 @@ class ReviewerAgent:
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький") print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
continue 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 # Analyze diff with PR context
pr_info = state.get("pr_info", {}) pr_info = state.get("pr_info", {})
comments = await self.analyzer.analyze_diff( comments = await self.analyzer.analyze_diff(
@ -298,7 +333,8 @@ class ReviewerAgent:
diff=patch, diff=patch,
language=language, language=language,
pr_title=pr_info.get("title", ""), 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)}") print(f" 💬 Получено комментариев: {len(comments)}")
@ -335,6 +371,14 @@ class ReviewerAgent:
async def post_comments(self, state: ReviewState) -> ReviewState: async def post_comments(self, state: ReviewState) -> ReviewState:
"""Post comments to PR""" """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: try:
# Save comments to database # Save comments to database
result = await self.db.execute( result = await self.db.execute(
@ -471,6 +515,9 @@ class ReviewerAgent:
repository_id: int repository_id: int
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Run the review workflow""" """Run the review workflow"""
import uuid
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
initial_state: ReviewState = { initial_state: ReviewState = {
"review_id": review_id, "review_id": review_id,
"pr_number": pr_number, "pr_number": pr_number,
@ -483,7 +530,11 @@ class ReviewerAgent:
"git_service": None "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 return final_state
async def run_review_stream( async def run_review_stream(
@ -494,6 +545,15 @@ class ReviewerAgent:
on_event: callable = None on_event: callable = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Run the review workflow with streaming events""" """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 = { initial_state: ReviewState = {
"review_id": review_id, "review_id": review_id,
"pr_number": pr_number, "pr_number": pr_number,
@ -507,34 +567,87 @@ class ReviewerAgent:
} }
final_state = None 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 # Stream through the graph
async for event in self.graph.astream( print(f"📊 Starting graph.astream() with mode=['updates']")
initial_state, print(f" Thread ID: {thread_id}\n")
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
})
# Store final state try:
if isinstance(node_data, dict): async for event in self.graph.astream(
final_state = node_data 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}")
# Handle message events (LLM calls) # LangGraph returns events as tuple: ('updates', {node_name: node_output})
elif hasattr(event, '__class__') and 'message' in event.__class__.__name__.lower(): if isinstance(event, tuple) and len(event) == 2:
if on_event: event_type, event_data = event[0], event[1]
await on_event({ print(f"✓ Tuple detected:")
"type": "llm_message", print(f" [0] event_type: '{event_type}'")
"message": str(event) print(f" [1] event_data type: {type(event_data).__name__}")
})
# 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 return final_state or initial_state

View File

@ -99,7 +99,8 @@ class CodeAnalyzer:
diff: str, diff: str,
language: Optional[str] = None, language: Optional[str] = None,
pr_title: str = "", pr_title: str = "",
pr_description: str = "" pr_description: str = "",
on_llm_chunk: Optional[callable] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Analyze code diff and return comments""" """Analyze code diff and return comments"""
@ -154,13 +155,32 @@ class CodeAnalyzer:
try: try:
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...") print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
# Создаем chain с LLM и JSON парсером # Собираем полный ответ из streaming chunks
chain = self.llm | self.json_parser full_response = ""
chunk_count = 0
# Получаем результат print(f"\n🤖 STREAMING AI ответ:")
result = await chain.ainvoke(prompt) 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("-" * 80)
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...") print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
print("-" * 80) print("-" * 80)

View File

@ -6,9 +6,11 @@ from sqlalchemy import select, func
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.database import get_db 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 import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse
from app.schemas.review_event import ReviewEvent as ReviewEventSchema
from app.agents import ReviewerAgent from app.agents import ReviewerAgent
from typing import List
router = APIRouter() 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): 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) 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") @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) 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

View File

@ -13,11 +13,44 @@ router = APIRouter()
async def start_review_task(review_id: int, pr_number: int, repository_id: int): 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.database import async_session_maker
from app.main import manager
from datetime import datetime as dt
async with async_session_maker() as db: 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) 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}") @router.post("/gitea/{repository_id}")

View File

@ -29,11 +29,23 @@ class ConnectionManager:
async def broadcast(self, message: dict): async def broadcast(self, message: dict):
"""Broadcast message to all connected clients""" """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: try:
await connection.send_json(message) await connection.send_json(message)
except Exception: sent_count += 1
pass 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 # Create connection manager
@ -118,18 +130,66 @@ async def health_check():
return {"status": "healthy"} 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") @app.websocket("/ws/reviews")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time review updates""" """WebSocket endpoint for real-time review updates"""
await manager.connect(websocket) await manager.connect(websocket)
print(f"✅ WebSocket connected. Total connections: {len(manager.active_connections)}")
try: try:
# Send welcome message
await websocket.send_json({
"type": "connection",
"status": "connected",
"message": "WebSocket подключен к серверу review",
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
})
while True: while True:
# Keep connection alive # Keep connection alive and handle client messages
data = await websocket.receive_text() data = await websocket.receive_text()
# Echo back or handle client messages if needed print(f"📨 Received from client: {data}")
await websocket.send_json({"type": "pong", "message": "connected"})
# 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: except WebSocketDisconnect:
manager.disconnect(websocket) 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): async def broadcast_review_update(review_id: int, event_type: str, data: dict = None):

View File

@ -6,6 +6,7 @@ from app.models.review import Review
from app.models.comment import Comment from app.models.comment import Comment
from app.models.organization import Organization from app.models.organization import Organization
from app.models.review_task import ReviewTask 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"]

View File

@ -37,6 +37,7 @@ class Review(Base):
# Relationships # Relationships
pull_request = relationship("PullRequest", back_populates="reviews") pull_request = relationship("PullRequest", back_populates="reviews")
comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan") 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): def __repr__(self):
return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>" return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>"

View 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})>"

View File

@ -23,6 +23,10 @@ from app.schemas.streaming import (
ReviewProgressEvent, ReviewProgressEvent,
StreamEventType StreamEventType
) )
from app.schemas.review_event import (
ReviewEvent as ReviewEventSchema,
ReviewEventCreate
)
__all__ = [ __all__ = [
"RepositoryCreate", "RepositoryCreate",
@ -40,5 +44,7 @@ __all__ = [
"LLMStreamEvent", "LLMStreamEvent",
"ReviewProgressEvent", "ReviewProgressEvent",
"StreamEventType", "StreamEventType",
"ReviewEventSchema",
"ReviewEventCreate",
] ]

View 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

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models import ReviewTask, PullRequest, Repository, Review from app.models import ReviewTask, PullRequest, Repository, Review
from app.models.review_task import TaskStatusEnum from app.models.review_task import TaskStatusEnum
from app.models.review import ReviewStatusEnum
from app.agents.reviewer import ReviewerAgent from app.agents.reviewer import ReviewerAgent
from app.config import settings from app.config import settings
@ -28,6 +29,9 @@ class ReviewTaskWorker:
self.running = True self.running = True
logger.info("🚀 Task Worker запущен") logger.info("🚀 Task Worker запущен")
# Очищаем зависшие задачи при старте
await self._cleanup_stuck_tasks()
while self.running: while self.running:
try: try:
await self._process_next_task() await self._process_next_task()
@ -44,6 +48,61 @@ class ReviewTaskWorker:
self.running = False self.running = False
logger.info("⏹️ Task Worker остановлен") 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): async def _process_next_task(self):
"""Process next pending task""" """Process next pending task"""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
@ -166,9 +225,54 @@ class ReviewTaskWorker:
from app.main import manager from app.main import manager
from datetime import datetime as dt 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 # Create event handler
async def on_review_event(event: dict): async def on_review_event(event: dict):
"""Handle review events and broadcast to clients""" """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: try:
# Prepare event data # Prepare event data
event_data = { event_data = {
@ -179,18 +283,44 @@ class ReviewTaskWorker:
"data": event "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) await manager.broadcast(event_data)
print(f" ✓ Broadcast completed")
# Log the event # Log the event
if event.get("type") == "agent_step": if event.get("type") == "agent_step":
step = event.get("step", "unknown") step = event.get("step", "unknown")
logger.info(f" 📍 Step: {step}") logger.info(f" 📍 Step: {step}")
elif event.get("type") == "llm_chunk":
# Не логируем каждый chunk, слишком много
pass
elif event.get("type") == "llm_message": elif event.get("type") == "llm_message":
message = event.get("message", "")[:100] message = event.get("message", "")[:100]
logger.debug(f" 💬 LLM: {message}...") logger.info(f" 💬 LLM: {message}...")
except Exception as e: except Exception as e:
print(f" ❌ ERROR in callback: {e}")
logger.error(f" ❌ Ошибка broadcast события: {e}") logger.error(f" ❌ Ошибка broadcast события: {e}")
import traceback
traceback.print_exc()
agent = ReviewerAgent(db) agent = ReviewerAgent(db)
await agent.run_review_stream( await agent.run_review_stream(
@ -202,6 +332,37 @@ class ReviewTaskWorker:
logger.info(f" ✅ Review завершен для PR #{pull_request.pr_number}") 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 # Global worker instance
_worker_instance: ReviewTaskWorker | None = None _worker_instance: ReviewTaskWorker | None = None

View File

@ -4,7 +4,7 @@
import asyncio import asyncio
from app.database import engine, Base 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(): async def create_tables():
@ -20,6 +20,7 @@ async def create_tables():
print(" - pull_requests") print(" - pull_requests")
print(" - reviews") print(" - reviews")
print(" - comments") print(" - comments")
print(" - review_events")
if __name__ == "__main__": if __name__ == "__main__":

View 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);

View File

@ -0,0 +1 @@

71
bump_version.sh Normal file
View 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

View File

@ -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"

View File

@ -248,7 +248,7 @@ User=$REAL_USER
Group=$REAL_USER Group=$REAL_USER
WorkingDirectory=$INSTALL_DIR/backend WorkingDirectory=$INSTALL_DIR/backend
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin" 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 Restart=always
RestartSec=10 RestartSec=10
StandardOutput=append:/var/log/ai-review/access.log StandardOutput=append:/var/log/ai-review/access.log

View File

@ -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
View 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 стриминга

View 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
View 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. ✅ **Как правильно обрабатывать события?**
Это поможет понять, почему события не доходят до фронтенда.

View File

@ -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
View 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"

View File

@ -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 ""

View File

@ -7,6 +7,7 @@ import ReviewDetail from './pages/ReviewDetail';
import Organizations from './pages/Organizations'; import Organizations from './pages/Organizations';
import Tasks from './pages/Tasks'; import Tasks from './pages/Tasks';
import WebSocketStatus from './components/WebSocketStatus'; import WebSocketStatus from './components/WebSocketStatus';
import Footer from './components/Footer';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -65,7 +66,7 @@ function Navigation() {
function AppContent() { function AppContent() {
return ( return (
<div className="min-h-screen bg-dark-bg"> <div className="min-h-screen bg-dark-bg pb-12">
<Navigation /> <Navigation />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <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 />} /> <Route path="/reviews/:id" element={<ReviewDetail />} />
</Routes> </Routes>
</main> </main>
<Footer />
</div> </div>
); );
} }

View File

@ -68,5 +68,25 @@ export const getReviewStats = async () => {
return response.data; 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; export default api;

View File

@ -108,3 +108,19 @@ export class WebSocketClient {
// Create singleton instance // Create singleton instance
export const wsClient = new WebSocketClient(); 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`;

View 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>
);
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { WS_URL } from '../api/websocket'; import { WS_URL } from '../api/websocket';
import { getReviewEvents, ReviewEvent } from '../api/client';
interface StreamEvent { interface StreamEvent {
type: string; type: string;
@ -31,48 +32,150 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
const [currentStep, setCurrentStep] = useState<string>(''); const [currentStep, setCurrentStep] = useState<string>('');
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [llmMessages, setLlmMessages] = useState<string[]>([]); const [llmMessages, setLlmMessages] = useState<string[]>([]);
const [llmStreamingText, setLlmStreamingText] = useState<string>('');
useEffect(() => { 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); const ws = new WebSocket(WS_URL);
let pingInterval: number;
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for streaming'); console.log('WebSocket connected for streaming');
setIsConnected(true); 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) => { ws.onmessage = (event) => {
try { 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 // Handle different message types
if (data.review_id === reviewId) { if (data.type === 'connection') {
setEvents((prev) => [...prev, data]); console.log('🔗 Connection confirmed:', data.message);
return;
}
// Update current step if (data.type === 'pong') {
if (data.data.type === 'agent_step') { console.log('🏓 Pong received');
setCurrentStep(data.data.step || ''); return;
} }
// Collect LLM messages if (data.type === 'echo') {
if (data.data.type === 'llm_message') { console.log('📢 Echo:', data.message);
setLlmMessages((prev) => [...prev, data.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.type === 'agent_step' || data.data?.type === 'agent_step') {
const step = data.data?.step || data.step;
console.log('🚶 Agent step:', step);
setCurrentStep(step || '');
}
// 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) { } catch (error) {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error, 'Data:', event.data);
} }
}; };
ws.onclose = () => { ws.onclose = () => {
console.log('WebSocket disconnected'); console.log('🔌 WebSocket disconnected');
setIsConnected(false); setIsConnected(false);
if (pingInterval) {
window.clearInterval(pingInterval);
}
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
return () => { return () => {
console.log('🔌 Closing WebSocket');
if (pingInterval) {
window.clearInterval(pingInterval);
}
ws.close(); ws.close();
}; };
}, [reviewId]); }, [reviewId]);
@ -130,38 +233,53 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<div className="text-center text-dark-text-muted py-4"> <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> </div>
); );
} }
return ( return (
<div className="space-y-2 max-h-60 overflow-y-auto"> <div className="space-y-2 max-h-60 overflow-y-auto">
{events.map((event, index) => ( {events.map((event: any, index) => {
<div const eventType = event.type || event.data?.type;
key={index} const eventMessage = event.data?.message || event.message;
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm" const eventStep = event.data?.step || event.step;
>
<div className="flex items-center justify-between mb-1"> return (
<span className="text-dark-text-secondary text-xs"> <div
{new Date(event.timestamp).toLocaleTimeString()} key={index}
</span> className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
<span className="text-xs bg-blue-900/30 text-blue-400 px-2 py-1 rounded"> >
{event.data.type} <div className="flex items-center justify-between mb-1">
</span> <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> </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> </div>
); );
}; };
@ -210,6 +328,21 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
{renderStepProgress()} {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"> <div className="mt-6">
<h3 className="text-lg font-semibold text-dark-text-primary mb-3"> <h3 className="text-lg font-semibold text-dark-text-primary mb-3">
📝 События 📝 События

View File

@ -18,7 +18,7 @@ export default function WebSocketStatus() {
const statusColors = { const statusColors = {
connected: 'bg-green-500', connected: 'bg-green-500',
disconnected: 'bg-gray-500', disconnected: 'bg-dark-text-muted',
error: 'bg-red-500', error: 'bg-red-500',
}; };
@ -29,9 +29,9 @@ export default function WebSocketStatus() {
}; };
return ( 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]}`} /> <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> </div>
); );
} }

View File

@ -140,23 +140,23 @@ export default function Organizations() {
{data?.items.map((org) => ( {data?.items.map((org) => (
<div <div
key={org.id} 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 items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3> <h3 className="text-xl font-semibold text-dark-text-primary">{org.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${ <span className={`px-2 py-1 rounded text-xs font-medium border ${
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' 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 ? 'Активна' : 'Неактивна'} {org.is_active ? 'Активна' : 'Неактивна'}
</span> </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()} {org.platform.toUpperCase()}
</span> </span>
</div> </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> <div>🌐 {org.base_url}</div>
{org.last_scan_at && ( {org.last_scan_at && (
<div> <div>
@ -166,8 +166,8 @@ export default function Organizations() {
)} )}
</div> </div>
<div className="mt-3 text-xs text-gray-500"> <div className="mt-3 text-xs text-dark-text-muted">
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code> Webhook: <code className="bg-dark-bg px-2 py-1 rounded border border-dark-border">{org.webhook_url}</code>
</div> </div>
</div> </div>
@ -181,13 +181,13 @@ export default function Organizations() {
</button> </button>
<button <button
onClick={() => setEditingOrg(org)} 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>
<button <button
onClick={() => handleDelete(org)} 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> </button>
@ -198,8 +198,8 @@ export default function Organizations() {
</div> </div>
{data?.items.length === 0 && ( {data?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg"> <div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
<p className="text-gray-500">Нет организаций</p> <p className="text-dark-text-muted">Нет организаций</p>
<button <button
onClick={() => setIsFormOpen(true)} onClick={() => setIsFormOpen(true)}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors" 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 ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4"> <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"> <h2 className="text-2xl font-bold mb-4 text-dark-text-primary">
{organization ? 'Редактировать организацию' : 'Новая организация'} {organization ? 'Редактировать организацию' : 'Новая организация'}
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<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> </label>
<input <input
@ -293,19 +293,19 @@ function OrganizationForm({
required required
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} 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" placeholder="inno-js"
/> />
</div> </div>
<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> </label>
<select <select
value={formData.platform} value={formData.platform}
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })} 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="gitea">Gitea</option>
<option value="github">GitHub</option> <option value="github">GitHub</option>
@ -314,7 +314,7 @@ function OrganizationForm({
</div> </div>
<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 * Base URL *
</label> </label>
<input <input
@ -322,36 +322,36 @@ function OrganizationForm({
required required
value={formData.base_url} value={formData.base_url}
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })} 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" placeholder="https://git.example.com"
/> />
</div> </div>
<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 токен API токен
</label> </label>
<input <input
type="password" type="password"
value={formData.api_token} value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })} 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 токен если не указан)" placeholder="Опционально (используется master токен если не указан)"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-dark-text-muted mt-1">
💡 Если не указан, будет использован master токен из конфигурации сервера 💡 Если не указан, будет использован master токен из конфигурации сервера
</p> </p>
</div> </div>
<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 Webhook Secret
</label> </label>
<input <input
type="text" type="text"
value={formData.webhook_secret} value={formData.webhook_secret}
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })} 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="Опционально (генерируется автоматически)" placeholder="Опционально (генерируется автоматически)"
/> />
</div> </div>
@ -360,14 +360,14 @@ function OrganizationForm({
<button <button
type="submit" type="submit"
disabled={isSubmitting} 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 ? 'Сохранить' : 'Создать'} {isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
</button> </button>
<button <button
type="button" type="button"
onClick={onCancel} 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> </button>

View File

@ -74,15 +74,15 @@ export default function Tasks() {
const getStatusColor = (status: TaskStatus) => { const getStatusColor = (status: TaskStatus) => {
switch (status) { switch (status) {
case 'pending': case 'pending':
return 'bg-yellow-100 text-yellow-800'; return 'bg-yellow-900/30 text-yellow-400 border border-yellow-700';
case 'in_progress': case 'in_progress':
return 'bg-blue-100 text-blue-800'; return 'bg-blue-900/30 text-blue-400 border border-blue-700';
case 'completed': case 'completed':
return 'bg-green-100 text-green-800'; return 'bg-green-900/30 text-green-400 border border-green-700';
case 'failed': case 'failed':
return 'bg-red-100 text-red-800'; return 'bg-red-900/30 text-red-400 border border-red-700';
default: 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) => { const getPriorityColor = (priority: string) => {
switch (priority) { switch (priority) {
case 'high': case 'high':
return 'bg-red-100 text-red-800'; return 'bg-red-900/30 text-red-400 border border-red-700';
case 'normal': case 'normal':
return 'bg-gray-100 text-gray-800'; return 'bg-dark-card text-dark-text-secondary border border-dark-border';
case 'low': case 'low':
return 'bg-green-100 text-green-800'; return 'bg-green-900/30 text-green-400 border border-green-700';
default: default:
return 'bg-gray-100 text-gray-800'; return 'bg-dark-card text-dark-text-muted border border-dark-border';
} }
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-gray-500">Загрузка...</div> <div className="text-dark-text-muted">Загрузка...</div>
</div> </div>
); );
} }
@ -126,23 +126,23 @@ export default function Tasks() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1> <h1 className="text-3xl font-bold text-dark-text-primary">Очередь задач</h1>
<p className="text-gray-600 mt-1"> <p className="text-dark-text-secondary mt-1">
Мониторинг и управление задачами на review Мониторинг и управление задачами на review
</p> </p>
</div> </div>
{/* Worker Status */} {/* Worker Status */}
{workerStatus && ( {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 justify-between">
<div className="flex items-center gap-3"> <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'}`} /> <div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-dark-text-muted'}`} />
<span className="font-medium"> <span className="font-medium text-dark-text-primary">
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'} {workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
</span> </span>
</div> </div>
<div className="text-sm text-gray-600"> <div className="text-sm text-dark-text-secondary">
{workerStatus.current_task_id && ( {workerStatus.current_task_id && (
<span>Обрабатывается задача #{workerStatus.current_task_id}</span> <span>Обрабатывается задача #{workerStatus.current_task_id}</span>
)} )}
@ -158,49 +158,49 @@ export default function Tasks() {
{tasksData && ( {tasksData && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<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 === undefined ? 'ring-2 ring-indigo-500' : '' statusFilter === undefined ? 'ring-2 ring-blue-500' : ''
}`} }`}
onClick={() => setStatusFilter(undefined)} onClick={() => setStatusFilter(undefined)}
> >
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div> <div className="text-2xl font-bold text-dark-text-primary">{tasksData.total}</div>
<div className="text-sm text-gray-600">Всего</div> <div className="text-sm text-dark-text-secondary">Всего</div>
</div> </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' : '' statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
}`} }`}
onClick={() => setStatusFilter('pending')} onClick={() => setStatusFilter('pending')}
> >
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div> <div className="text-2xl font-bold text-yellow-400">{tasksData.pending}</div>
<div className="text-sm text-gray-600">Ожидает</div> <div className="text-sm text-dark-text-secondary">Ожидает</div>
</div> </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' : '' statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
}`} }`}
onClick={() => setStatusFilter('in_progress')} onClick={() => setStatusFilter('in_progress')}
> >
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div> <div className="text-2xl font-bold text-blue-400">{tasksData.in_progress}</div>
<div className="text-sm text-gray-600">Выполняется</div> <div className="text-sm text-dark-text-secondary">Выполняется</div>
</div> </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' : '' statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
}`} }`}
onClick={() => setStatusFilter('completed')} onClick={() => setStatusFilter('completed')}
> >
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div> <div className="text-2xl font-bold text-green-400">{tasksData.completed}</div>
<div className="text-sm text-gray-600">Завершено</div> <div className="text-sm text-dark-text-secondary">Завершено</div>
</div> </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' : '' statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
}`} }`}
onClick={() => setStatusFilter('failed')} onClick={() => setStatusFilter('failed')}
> >
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div> <div className="text-2xl font-bold text-red-400">{tasksData.failed}</div>
<div className="text-sm text-gray-600">Ошибок</div> <div className="text-sm text-dark-text-secondary">Ошибок</div>
</div> </div>
</div> </div>
)} )}
@ -210,12 +210,12 @@ export default function Tasks() {
{tasksData?.items.map((task) => ( {tasksData?.items.map((task) => (
<div <div
key={task.id} 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 items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <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} #{task.id}
</span> </span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}> <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="mt-2 space-y-1">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-600">PR:</span>{' '} <span className="text-dark-text-secondary">PR:</span>{' '}
<span className="font-medium">#{task.pr_number}</span>{' '} <span className="font-medium text-dark-text-primary">#{task.pr_number}</span>{' '}
<span className="text-gray-700">{task.pr_title}</span> <span className="text-dark-text-primary">{task.pr_title}</span>
</div> </div>
<div className="flex gap-4 text-xs text-gray-500"> <div className="flex gap-4 text-xs text-dark-text-muted">
<span> <span>
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })} Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
</span> </span>
@ -252,13 +252,13 @@ export default function Tasks() {
</div> </div>
{task.error_message && ( {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} <strong>Ошибка:</strong> {task.error_message}
</div> </div>
)} )}
{task.retry_count > 0 && ( {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} Попыток: {task.retry_count} / {task.max_retries}
</div> </div>
)} )}
@ -270,7 +270,7 @@ export default function Tasks() {
<button <button
onClick={() => handleRetry(task.id)} onClick={() => handleRetry(task.id)}
disabled={retryMutation.isPending} 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> </button>
@ -279,7 +279,7 @@ export default function Tasks() {
<button <button
onClick={() => handleDelete(task.id)} onClick={() => handleDelete(task.id)}
disabled={deleteMutation.isPending} 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> </button>
@ -291,8 +291,8 @@ export default function Tasks() {
</div> </div>
{tasksData?.items.length === 0 && ( {tasksData?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg"> <div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
<p className="text-gray-500"> <p className="text-dark-text-muted">
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'} {statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
</p> </p>
</div> </div>
@ -305,7 +305,7 @@ export default function Tasks() {
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'} title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'} 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> </Modal>
<ConfirmModal <ConfirmModal
isOpen={showConfirm} isOpen={showConfirm}

146
start.bat
View File

@ -1,100 +1,78 @@
@echo off @echo off
REM Единый скрипт запуска AI Code Review Platform REM AI Review - Build & Start
echo. echo ================================
echo ======================================== echo AI Review - Starting
echo AI Code Review Platform - Запуск echo ================================
echo ========================================
echo. echo.
REM 1. Проверка Node.js REM 1. Build Frontend
echo [STEP 1/7] Проверка Node.js... echo [1/3] Building frontend...
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...
cd frontend cd frontend
if not exist "node_modules\" ( if not exist "node_modules\" (
echo Установка npm пакетов... echo Installing npm packages...
call npm install call npm install
) else ( if %ERRORLEVEL% NEQ 0 (
echo node_modules уже существует, пропускаем... echo [ERROR] npm install failed
) cd ..
echo [OK] Зависимости frontend установлены pause
echo. exit /b 1
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 не найден!
) )
) )
echo.
REM 7. Запуск backend echo Building...
echo [STEP 7/7] Запуск сервера... call npm run build
echo ========================================
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.
echo Backend: http://localhost:8000 echo [2/3] Setting up backend...
echo Frontend: http://localhost:8000 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 API Docs: http://localhost:8000/docs
echo. echo.
echo Для остановки нажмите Ctrl+C echo Press Ctrl+C to stop
echo ================================
echo. echo.
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

110
start.sh
View File

@ -1,104 +1,54 @@
#!/bin/bash #!/bin/bash
# Единый скрипт запуска AI Code Review Platform # AI Review - Build & Start
set -e set -e
echo "🚀 AI Code Review Platform - Запуск" echo "================================"
echo "====================================" echo "AI Review - Starting"
echo "================================"
echo "" echo ""
# Цвета для вывода # 1. Build Frontend
GREEN='\033[0;32m' echo "[1/3] Building frontend..."
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}"
cd frontend cd frontend
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "Installing npm packages..."
npm install npm install
else
echo "node_modules уже существует, пропускаем..."
fi 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 npm run build
echo -e "${GREEN}✅ Frontend собран в backend/public${NC}"
echo ""
# 5. Установка зависимостей backend echo "[OK] Frontend built to backend/public"
cd ../backend cd ..
echo -e "${YELLOW}📦 Установка зависимостей backend...${NC}"
# 2. Setup Backend
echo ""
echo "[2/3] Setting up backend..."
cd backend
if [ ! -d "venv" ]; then if [ ! -d "venv" ]; then
echo "Создание виртуального окружения..." echo "Creating venv..."
$PYTHON_CMD -m venv venv python3 -m venv venv
fi fi
# Активация venv echo "Activating venv..."
source venv/bin/activate source venv/bin/activate
# Установка зависимостей echo "Installing dependencies..."
pip install -r requirements.txt pip install -q -r requirements.txt
echo -e "${GREEN}✅ Зависимости backend установлены${NC}"
echo ""
# 6. Проверка .env # 3. Start Backend
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 "===================================="
echo "" echo ""
echo "📍 Backend: http://localhost:8000" echo "[3/3] Starting server..."
echo "📍 Frontend: http://localhost:8000" echo "================================"
echo "📍 API Docs: http://localhost:8000/docs"
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 "" echo ""
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

72
tests/README.md Normal file
View 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) - Детальная документация по тестированию стриминга

View 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
View 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
View 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())