Merge pull request #1 from TDJX/agent_lifecycle_intergration

Agent lifecycle
This commit is contained in:
Михаил Краевский 2025-09-05 00:32:30 +03:00 committed by GitHub
commit 8fa727333a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 5864 additions and 4660 deletions

152
CLAUDE.md Normal file
View File

@ -0,0 +1,152 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Application Startup
```bash
# Start FastAPI server
uvicorn app.main:app --reload --port 8000
# Start Celery worker (required for resume processing)
celery -A celery_worker.celery_app worker --loglevel=info
# Start LiveKit server (for voice interviews)
docker run --rm -p 7880:7880 -p 7881:7881 livekit/livekit-server --dev
```
### Database Management
```bash
# Run database migrations
alembic upgrade head
# Create new migration
alembic revision --autogenerate -m "description"
```
### Code Quality
```bash
# Format code and fix imports
ruff format .
# Lint and auto-fix issues
ruff check . --fix
# Type checking
mypy .
```
### Testing
```bash
# Run basic system tests
python simple_test.py
# Run comprehensive tests
python test_system.py
# Test agent integration
python test_agent_integration.py
# Run pytest suite
pytest
```
## Architecture Overview
### Core Components
**FastAPI Application** (`app/`):
- `main.py`: Application entry point with middleware and router configuration
- `routers/`: API endpoints organized by domain (resume, interview, vacancy, admin)
- `models/`: SQLModel database schemas with enums and relationships
- `services/`: Business logic layer handling complex operations
- `repositories/`: Data access layer using SQLModel/SQLAlchemy
**Background Processing** (`celery_worker/`):
- `celery_app.py`: Celery configuration with Redis backend
- `tasks.py`: Asynchronous tasks for resume parsing and interview analysis
- `interview_analysis_task.py`: Specialized task for processing interview results
**AI Interview System**:
- `ai_interviewer_agent.py`: LiveKit-based voice interview agent using OpenAI, Deepgram, and Cartesia
- `app/services/agent_manager.py`: Singleton manager for controlling the AI agent lifecycle
- Agent runs as a single process, handling one interview at a time (hackathon limitation)
- Inter-process communication via JSON command files
- Automatic startup/shutdown with FastAPI application lifecycle
**RAG System** (`rag/`):
- `vector_store.py`: Milvus vector database integration for resume search
- `llm/model.py`: OpenAI GPT integration for resume parsing and interview plan generation
- `service/model.py`: RAG service orchestration
### Database Schema
**Key Models**:
- `Resume`: Candidate resumes with parsing status, interview plans, and file storage
- `InterviewSession`: LiveKit rooms with AI agent process tracking
- `Vacancy`: Job postings with requirements and descriptions
- `Session`: User session management with cookie-based tracking
**Status Enums**:
- `ResumeStatus`: pending → parsing → parsed → interview_scheduled → interviewed
- `InterviewStatus`: created → active → completed/failed
### External Dependencies
**Required Services**:
- PostgreSQL: Primary database with asyncpg driver
- Redis: Celery broker and caching layer
- Milvus: Vector database for semantic search (optional, has fallbacks)
- S3-compatible storage: Resume file storage
**API Keys**:
- OpenAI: Required for resume parsing and LLM operations
- Deepgram/Cartesia/ElevenLabs: Optional voice services (has fallbacks)
- LiveKit credentials: For interview functionality
## Development Workflow
### Resume Processing Flow
1. File upload via `/api/v1/resume/upload`
2. Celery task processes file and extracts text
3. OpenAI parses resume data and generates interview plan
4. Vector embeddings stored in Milvus for search
5. Status updates tracked through enum progression
### Interview System Flow
1. AI agent starts automatically with FastAPI application
2. Validate resume readiness via `/api/v1/interview/{id}/validate`
3. Check agent availability (singleton, one interview at a time)
4. Generate LiveKit token via `/api/v1/interview/{id}/token`
5. Assign interview session to agent via command files
6. Conduct real-time voice interview through LiveKit
7. Agent monitors for end commands or natural completion
8. Session cleanup and agent returns to idle state
### Configuration Management
- Settings via `app/core/config.py` with Pydantic BaseSettings
- Environment variables loaded from `.env` file (see `.env.example`)
- Database URLs and API keys configured per environment
## Important Notes
- AI agent runs as a singleton process, handling one interview at a time
- Agent lifecycle is managed automatically with FastAPI startup/shutdown
- Interview sessions require LiveKit server to be running
- Agent communication happens via JSON files (agent_commands.json, session_metadata_*.json)
- Resume parsing is asynchronous and status should be checked via polling
- Vector search gracefully degrades if Milvus is unavailable
- Session management uses custom middleware with cookie-based tracking
## Agent Management API
```bash
# Check agent status
GET /api/v1/admin/agent/status
# Start/stop/restart agent manually
POST /api/v1/admin/agent/start
POST /api/v1/admin/agent/stop
POST /api/v1/admin/agent/restart
```

View File

@ -1,36 +1,29 @@
# -*- coding: utf-8 -*-
import asyncio
import json
import logging
import os
from typing import Dict, List, Optional
from datetime import datetime
# Принудительно устанавливаем UTF-8 для Windows
if os.name == 'nt': # Windows
if os.name == "nt": # Windows
import sys
if hasattr(sys, 'stdout') and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# Устанавливаем переменную окружения для Python
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
from livekit.agents import (
Agent,
AgentSession,
JobContext,
WorkerOptions,
cli,
NotGiven
)
from livekit.plugins import openai, deepgram, cartesia, silero
from livekit.api import LiveKitAPI, DeleteRoomRequest
from rag.settings import settings
if hasattr(sys, "stdout") and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# Устанавливаем переменную окружения для Python
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.api import DeleteRoomRequest, LiveKitAPI
from livekit.plugins import cartesia, deepgram, openai, silero
from app.core.database import get_session
from app.repositories.interview_repository import InterviewRepository
from app.repositories.resume_repository import ResumeRepository
from app.services.interview_finalization_service import InterviewFinalizationService
from rag.settings import settings
logger = logging.getLogger("ai-interviewer")
logger.setLevel(logging.INFO)
@ -39,12 +32,14 @@ logger.setLevel(logging.INFO)
async def close_room(room_name: str):
"""Закрывает LiveKit комнату полностью (отключает всех участников)"""
try:
api = LiveKitAPI(settings.livekit_url, settings.livekit_api_key, settings.livekit_api_secret)
api = LiveKitAPI(
settings.livekit_url, settings.livekit_api_key, settings.livekit_api_secret
)
# Создаем RoomService для управления комнатами
await api.room.delete_room(delete=DeleteRoomRequest(room=room_name))
logger.info(f"[ROOM_MANAGEMENT] Room {room_name} deleted successfully")
except Exception as e:
logger.error(f"[ROOM_MANAGEMENT] Failed to delete room {room_name}: {str(e)}")
raise
@ -53,7 +48,7 @@ async def close_room(room_name: str):
class InterviewAgent:
"""AI Agent для проведения собеседований с управлением диалогом"""
def __init__(self, interview_plan: Dict):
def __init__(self, interview_plan: dict):
self.interview_plan = interview_plan
self.conversation_history = []
@ -66,18 +61,25 @@ class InterviewAgent:
self.last_user_response = None
self.intro_done = False # Новый флаг — произнесено ли приветствие
self.interview_finalized = False # Флаг завершения интервью
# Трекинг времени интервью
import time
self.interview_start_time = time.time()
self.duration_minutes = interview_plan.get('interview_structure', {}).get('duration_minutes', 10)
self.sections = self.interview_plan.get('interview_structure', {}).get('sections', [])
self.total_sections = len(self.sections)
logger.info(f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {self.duration_minutes} min")
def get_current_section(self) -> Dict:
self.interview_start_time = time.time()
self.duration_minutes = interview_plan.get("interview_structure", {}).get(
"duration_minutes", 10
)
self.sections = self.interview_plan.get("interview_structure", {}).get(
"sections", []
)
self.total_sections = len(self.sections)
logger.info(
f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {self.duration_minutes} min"
)
def get_current_section(self) -> dict:
"""Получить текущую секцию интервью"""
if self.current_section < len(self.sections):
return self.sections[self.current_section]
@ -86,7 +88,7 @@ class InterviewAgent:
def get_next_question(self) -> str:
"""Получить следующий вопрос"""
section = self.get_current_section()
questions = section.get('questions', [])
questions = section.get("questions", [])
if self.current_question_in_section < len(questions):
return questions[self.current_question_in_section]
return None
@ -97,7 +99,7 @@ class InterviewAgent:
self.questions_asked_total += 1
section = self.get_current_section()
if self.current_question_in_section >= len(section.get('questions', [])):
if self.current_question_in_section >= len(section.get("questions", [])):
self.move_to_next_section()
def move_to_next_section(self):
@ -105,7 +107,9 @@ class InterviewAgent:
self.current_section += 1
self.current_question_in_section = 0
if self.current_section < len(self.sections):
logger.info(f"Переход к секции: {self.sections[self.current_section].get('name', 'Unnamed')}")
logger.info(
f"Переход к секции: {self.sections[self.current_section].get('name', 'Unnamed')}"
)
def is_interview_complete(self) -> bool:
"""Интервью завершается только по решению LLM через ключевые фразы"""
@ -113,39 +117,42 @@ class InterviewAgent:
def get_system_instructions(self) -> str:
"""Системные инструкции для AI агента с ключевыми фразами для завершения"""
candidate_info = self.interview_plan.get('candidate_info', {})
interview_structure = self.interview_plan.get('interview_structure', {})
greeting = interview_structure.get('greeting', 'Привет! Готов к интервью?')
focus_areas = self.interview_plan.get('focus_areas', [])
key_evaluation_points = self.interview_plan.get('key_evaluation_points', [])
candidate_info = self.interview_plan.get("candidate_info", {})
interview_structure = self.interview_plan.get("interview_structure", {})
greeting = interview_structure.get("greeting", "Привет! Готов к интервью?")
focus_areas = self.interview_plan.get("focus_areas", [])
key_evaluation_points = self.interview_plan.get("key_evaluation_points", [])
# Вычисляем текущее время интервью
import time
elapsed_minutes = (time.time() - self.interview_start_time) / 60
remaining_minutes = max(0, self.duration_minutes - elapsed_minutes)
time_percentage = min(100, (elapsed_minutes / self.duration_minutes) * 100)
# Формируем план интервью для агента
sections_info = "\n".join([
f"- {section.get('name', 'Секция')}: {', '.join(section.get('questions', []))}"
for section in self.sections
])
sections_info = "\n".join(
[
f"- {section.get('name', 'Секция')}: {', '.join(section.get('questions', []))}"
for section in self.sections
]
)
# Безопасно формируем строки для избежания конфликтов с кавычками
candidate_name = candidate_info.get('name', 'Кандидат')
candidate_years = candidate_info.get('total_years', 0)
candidate_skills = ', '.join(candidate_info.get('skills', []))
focus_areas_str = ', '.join(focus_areas)
evaluation_points_str = ', '.join(key_evaluation_points)
candidate_name = candidate_info.get("name", "Кандидат")
candidate_years = candidate_info.get("total_years", 0)
candidate_skills = ", ".join(candidate_info.get("skills", []))
focus_areas_str = ", ".join(focus_areas)
evaluation_points_str = ", ".join(key_evaluation_points)
# Статус времени
if time_percentage > 90:
time_status = 'СРОЧНО ЗАВЕРШАТЬ'
time_status = "СРОЧНО ЗАВЕРШАТЬ"
elif time_percentage > 75:
time_status = 'ВРЕМЯ ЗАКАНЧИВАЕТСЯ'
time_status = "ВРЕМЯ ЗАКАНЧИВАЕТСЯ"
else:
time_status = 'НОРМАЛЬНО'
time_status = "НОРМАЛЬНО"
return f"""Ты опытный HR-интервьюер, который проводит адаптивное голосовое собеседование.
ИНФОРМАЦИЯ О КАНДИДАТЕ:
@ -195,33 +202,34 @@ class InterviewAgent:
СТИЛЬ: Дружелюбный, профессиональный, заинтересованный в кандидате.
"""
def get_time_info(self) -> Dict[str, float]:
def get_time_info(self) -> dict[str, float]:
"""Получает информацию о времени интервью"""
import time
elapsed_minutes = (time.time() - self.interview_start_time) / 60
remaining_minutes = max(0.0, self.duration_minutes - elapsed_minutes)
time_percentage = min(100.0, (elapsed_minutes / self.duration_minutes) * 100)
return {
"elapsed_minutes": elapsed_minutes,
"remaining_minutes": remaining_minutes,
"remaining_minutes": remaining_minutes,
"time_percentage": time_percentage,
"duration_minutes": self.duration_minutes
"duration_minutes": self.duration_minutes,
}
async def track_interview_progress(self, user_response: str) -> Dict[str, any]:
async def track_interview_progress(self, user_response: str) -> dict[str, any]:
"""Трекает прогресс интервью для логирования"""
current_section = self.get_current_section()
time_info = self.get_time_info()
return {
"section": current_section.get('name', 'Unknown'),
"section": current_section.get("name", "Unknown"),
"questions_asked": self.questions_asked_total,
"section_progress": f"{self.current_section + 1}/{len(self.sections)}",
"user_response_length": len(user_response),
"elapsed_minutes": f"{time_info['elapsed_minutes']:.1f}",
"remaining_minutes": f"{time_info['remaining_minutes']:.1f}",
"time_percentage": f"{time_info['time_percentage']:.0f}%"
"time_percentage": f"{time_info['time_percentage']:.0f}%",
}
@ -230,52 +238,116 @@ async def entrypoint(ctx: JobContext):
logger.info("[INIT] Starting AI Interviewer Agent")
logger.info(f"[INIT] Room: {ctx.room.name}")
# План интервью - получаем из переменной окружения
room_metadata = os.environ.get("LIVEKIT_ROOM_METADATA", ctx.room.metadata or "{}")
try:
metadata = json.loads(room_metadata)
interview_plan = metadata.get("interview_plan", {})
if not interview_plan:
# План интервью - получаем из метаданных сессии
interview_plan = {}
session_id = None
# Проверяем файлы команд для получения сессии
command_file = "agent_commands.json"
metadata_file = None
# Ожидаем команды от менеджера
for _ in range(60): # Ждем до 60 секунд
if os.path.exists(command_file):
try:
with open(command_file, encoding="utf-8") as f:
command = json.load(f)
if (
command.get("action") == "start_session"
and command.get("room_name") == ctx.room.name
):
session_id = command.get("session_id")
metadata_file = command.get("metadata_file")
logger.info(
f"[INIT] Received start_session command for session {session_id}"
)
break
except Exception as e:
logger.warning(f"[INIT] Failed to parse command file: {str(e)}")
await asyncio.sleep(1)
# Загружаем метаданные сессии
if metadata_file and os.path.exists(metadata_file):
try:
with open(metadata_file, encoding="utf-8") as f:
metadata = json.load(f)
interview_plan = metadata.get("interview_plan", {})
session_id = metadata.get("session_id", session_id)
logger.info(f"[INIT] Loaded interview plan for session {session_id}")
except Exception as e:
logger.warning(f"[INIT] Failed to load metadata: {str(e)}")
interview_plan = {}
except Exception as e:
logger.warning(f"[INIT] Failed to parse metadata: {str(e)}")
interview_plan = {}
# Используем дефолтный план если план пустой или нет секций
if not interview_plan or not interview_plan.get("interview_structure", {}).get("sections"):
logger.info(f"[INIT] Using default interview plan")
if not interview_plan or not interview_plan.get("interview_structure", {}).get(
"sections"
):
logger.info("[INIT] Using default interview plan")
interview_plan = {
"interview_structure": {
"duration_minutes": 2, # ТЕСТОВЫЙ РЕЖИМ - 2 минуты
"greeting": "Привет! Это быстрое тестовое интервью на 2 минуты. Готов?",
"sections": [
{"name": "Знакомство", "duration_minutes": 1, "questions": ["Расскажи кратко о себе одним предложением"]},
{"name": "Завершение", "duration_minutes": 1, "questions": ["Спасибо! Есть вопросы ко мне?"]}
]
{
"name": "Знакомство",
"duration_minutes": 1,
"questions": ["Расскажи кратко о себе одним предложением"],
},
{
"name": "Завершение",
"duration_minutes": 1,
"questions": ["Спасибо! Есть вопросы ко мне?"],
},
],
},
"candidate_info": {
"name": "Тестовый кандидат",
"skills": ["Python", "React"],
"total_years": 3,
},
"candidate_info": {"name": "Тестовый кандидат", "skills": ["Python", "React"], "total_years": 3},
"focus_areas": ["quick_test"],
"key_evaluation_points": ["Коммуникация"]
"key_evaluation_points": ["Коммуникация"],
}
interviewer = InterviewAgent(interview_plan)
logger.info(f"[INIT] InterviewAgent created with {len(interviewer.sections)} sections")
logger.info(
f"[INIT] InterviewAgent created with {len(interviewer.sections)} sections"
)
# STT
stt = deepgram.STT(model="nova-2-general", language="ru", api_key=settings.deepgram_api_key) \
if settings.deepgram_api_key else openai.STT(model="whisper-1", language="ru", api_key=settings.openai_api_key)
stt = (
deepgram.STT(
model="nova-2-general", language="ru", api_key=settings.deepgram_api_key
)
if settings.deepgram_api_key
else openai.STT(
model="whisper-1", language="ru", api_key=settings.openai_api_key
)
)
# LLM
llm = openai.LLM(model="gpt-4o-mini", api_key=settings.openai_api_key, temperature=0.7)
llm = openai.LLM(
model="gpt-4o-mini", api_key=settings.openai_api_key, temperature=0.7
)
# TTS
tts = cartesia.TTS(model="sonic-turbo", language="ru", voice='da05e96d-ca10-4220-9042-d8acef654fa9',
api_key=settings.cartesia_api_key) if settings.cartesia_api_key else silero.TTS(language="ru", model="v4_ru")
tts = (
cartesia.TTS(
model="sonic-turbo",
language="ru",
voice="da05e96d-ca10-4220-9042-d8acef654fa9",
api_key=settings.cartesia_api_key,
)
if settings.cartesia_api_key
else silero.TTS(language="ru", model="v4_ru")
)
# Создаем обычный Agent и Session
agent = Agent(instructions=interviewer.get_system_instructions())
# Создаем AgentSession с обычным TTS
session = AgentSession(vad=silero.VAD.load(), stt=stt, llm=llm, tts=tts)
@ -287,10 +359,16 @@ async def entrypoint(ctx: JobContext):
try:
interview_repo = InterviewRepository(db)
resume_repo = ResumeRepository(db)
finalization_service = InterviewFinalizationService(interview_repo, resume_repo)
success = await finalization_service.save_dialogue_to_session(room_name, dialogue_history)
finalization_service = InterviewFinalizationService(
interview_repo, resume_repo
)
success = await finalization_service.save_dialogue_to_session(
room_name, dialogue_history
)
if not success:
logger.warning(f"[DB] Failed to save dialogue for room: {room_name}")
logger.warning(
f"[DB] Failed to save dialogue for room: {room_name}"
)
finally:
await session_generator.aclose()
except Exception as e:
@ -299,46 +377,54 @@ async def entrypoint(ctx: JobContext):
# --- Логика завершения интервью ---
async def finalize_interview(room_name: str, interviewer_instance):
"""Завершение интервью и запуск анализа"""
# Проверяем, не завершено ли уже интервью
if interviewer_instance.interview_finalized:
logger.info(f"[FINALIZE] Interview already finalized for room: {room_name}")
return
interviewer_instance.interview_finalized = True
try:
logger.info(f"[FINALIZE] Starting interview finalization for room: {room_name}")
logger.info(
f"[FINALIZE] Starting interview finalization for room: {room_name}"
)
# Собираем метрики интервью
time_info = interviewer_instance.get_time_info()
interview_metrics = {
"total_messages": interviewer_instance.questions_asked_total,
"dialogue_length": len(interviewer_instance.conversation_history),
"elapsed_minutes": time_info['elapsed_minutes'],
"planned_duration": time_info['duration_minutes'],
"time_percentage": time_info['time_percentage']
"elapsed_minutes": time_info["elapsed_minutes"],
"planned_duration": time_info["duration_minutes"],
"time_percentage": time_info["time_percentage"],
}
session_generator = get_session()
db = await anext(session_generator)
try:
interview_repo = InterviewRepository(db)
resume_repo = ResumeRepository(db)
finalization_service = InterviewFinalizationService(interview_repo, resume_repo)
finalization_service = InterviewFinalizationService(
interview_repo, resume_repo
)
# Используем сервис для завершения интервью
result = await finalization_service.finalize_interview(
room_name=room_name,
dialogue_history=interviewer_instance.conversation_history,
interview_metrics=interview_metrics
interview_metrics=interview_metrics,
)
if result:
logger.info(f"[FINALIZE] Interview successfully finalized: session_id={result['session_id']}, task_id={result['analysis_task_id']}")
logger.info(
f"[FINALIZE] Interview successfully finalized: session_id={result['session_id']}, task_id={result['analysis_task_id']}"
)
else:
logger.error(f"[FINALIZE] Failed to finalize interview for room: {room_name}")
logger.error(
f"[FINALIZE] Failed to finalize interview for room: {room_name}"
)
finally:
await session_generator.aclose()
except Exception as e:
@ -348,24 +434,58 @@ async def entrypoint(ctx: JobContext):
async def check_interview_completion_by_keywords(agent_text: str):
"""Проверяет завершение интервью по ключевым фразам"""
# Ключевые фразы для завершения интервью
ending_keywords = [
"До скорой встречи"
]
ending_keywords = ["До скорой встречи"]
text_lower = agent_text.lower()
for keyword in ending_keywords:
if keyword.lower() in text_lower:
logger.info(f"[KEYWORD_DETECTION] Found ending keyword: '{keyword}' in agent response")
logger.info(
f"[KEYWORD_DETECTION] Found ending keyword: '{keyword}' in agent response"
)
if not interviewer.interview_finalized:
# Запускаем полную цепочку завершения интервью
await complete_interview_sequence(ctx.room.name, interviewer)
return True
break
return False
# --- Мониторинг команд завершения ---
async def monitor_end_commands():
"""Мониторит команды завершения сессии"""
command_file = "agent_commands.json"
while not interviewer.interview_finalized:
try:
if os.path.exists(command_file):
with open(command_file, encoding="utf-8") as f:
command = json.load(f)
if (
command.get("action") == "end_session"
and command.get("session_id") == session_id
):
logger.info(
f"[COMMAND] Received end_session command for session {session_id}"
)
if not interviewer.interview_finalized:
await complete_interview_sequence(
ctx.room.name, interviewer
)
break
await asyncio.sleep(2) # Проверяем каждые 2 секунды
except Exception as e:
logger.error(f"[COMMAND] Error monitoring commands: {str(e)}")
await asyncio.sleep(5)
# Запускаем мониторинг команд в фоне
asyncio.create_task(monitor_end_commands())
# --- Полная цепочка завершения интервью ---
async def complete_interview_sequence(room_name: str, interviewer_instance):
"""
@ -376,15 +496,15 @@ async def entrypoint(ctx: JobContext):
"""
try:
logger.info("[SEQUENCE] Starting interview completion sequence")
# Шаг 1: Финализируем интервью в БД
logger.info("[SEQUENCE] Step 1: Finalizing interview in database")
await finalize_interview(room_name, interviewer_instance)
logger.info("[SEQUENCE] Step 1: Database finalization completed")
# Даём время на завершение всех DB операций
await asyncio.sleep(1)
# Шаг 2: Закрываем комнату LiveKit
logger.info("[SEQUENCE] Step 2: Closing LiveKit room")
try:
@ -392,48 +512,54 @@ async def entrypoint(ctx: JobContext):
logger.info(f"[SEQUENCE] Step 2: Room {room_name} closed successfully")
except Exception as e:
logger.error(f"[SEQUENCE] Step 2: Failed to close room: {str(e)}")
logger.info("[SEQUENCE] Step 2: Room closure failed, but continuing sequence")
logger.info(
"[SEQUENCE] Step 2: Room closure failed, but continuing sequence"
)
# Шаг 3: Завершаем процесс агента
logger.info("[SEQUENCE] Step 3: Terminating agent process")
await asyncio.sleep(2) # Даём время на завершение всех операций
logger.info("[SEQUENCE] Step 3: Force terminating agent process")
import os
os._exit(0) # Принудительное завершение процесса
except Exception as e:
logger.error(f"[SEQUENCE] Error in interview completion sequence: {str(e)}")
# Fallback: принудительно завершаем процесс даже при ошибках
logger.info("[SEQUENCE] Fallback: Force terminating process")
await asyncio.sleep(1)
import os
os._exit(1)
os._exit(1)
# --- Упрощенная логика обработки пользовательского ответа ---
async def handle_user_input(user_response: str):
current_section = interviewer.get_current_section()
# Сохраняем ответ пользователя
dialogue_message = {
"role": "user",
"content": str(user_response).encode('utf-8').decode('utf-8'), # Принудительное UTF-8
"content": str(user_response)
.encode("utf-8")
.decode("utf-8"), # Принудительное UTF-8
"timestamp": datetime.utcnow().isoformat(),
"section": current_section.get('name', 'Unknown')
"section": current_section.get("name", "Unknown"),
}
interviewer.conversation_history.append(dialogue_message)
await save_dialogue_to_db(ctx.room.name, interviewer.conversation_history)
# Обновляем прогресс интервью
if not interviewer.intro_done:
interviewer.intro_done = True
# Обновляем счетчик сообщений и треким время
interviewer.questions_asked_total += 1
progress_info = await interviewer.track_interview_progress(user_response)
logger.info(f"[PROGRESS] Messages: {progress_info['questions_asked']}, Time: {progress_info['elapsed_minutes']}min/{progress_info['time_percentage']}")
logger.info(
f"[PROGRESS] Messages: {progress_info['questions_asked']}, Time: {progress_info['elapsed_minutes']}min/{progress_info['time_percentage']}"
)
# Обновляем инструкции агента с текущим прогрессом
try:
updated_instructions = interviewer.get_system_instructions()
@ -443,7 +569,7 @@ async def entrypoint(ctx: JobContext):
@session.on("conversation_item_added")
def on_conversation_item(event):
role = event.item.role
role = event.item.role
text = event.item.text_content
if role == "user":
@ -451,27 +577,34 @@ async def entrypoint(ctx: JobContext):
elif role == "assistant":
# Сохраняем ответ агента в историю диалога
current_section = interviewer.get_current_section()
interviewer.conversation_history.append({
"role": "assistant",
"content": str(text).encode('utf-8').decode('utf-8'), # Принудительное UTF-8
"timestamp": datetime.utcnow().isoformat(),
"section": current_section.get('name', 'Unknown')
})
interviewer.conversation_history.append(
{
"role": "assistant",
"content": str(text)
.encode("utf-8")
.decode("utf-8"), # Принудительное UTF-8
"timestamp": datetime.utcnow().isoformat(),
"section": current_section.get("name", "Unknown"),
}
)
# Сохраняем диалог в БД
asyncio.create_task(save_dialogue_to_db(ctx.room.name, interviewer.conversation_history))
asyncio.create_task(
save_dialogue_to_db(ctx.room.name, interviewer.conversation_history)
)
# Проверяем ключевые фразы для завершения интервью
asyncio.create_task(check_interview_completion_by_keywords(text))
await session.start(agent=agent, room=ctx.room)
logger.info("[INIT] AI Interviewer started")
def main():
logging.basicConfig(level=logging.INFO)
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # фикс для Windows
asyncio.set_event_loop_policy(
asyncio.WindowsSelectorEventLoopPolicy()
) # фикс для Windows
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))

View File

@ -1,50 +1,49 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = "postgresql+asyncpg://tdjx:1309@localhost:5432/hr_ai"
# Redis Configuration (for Celery and caching)
redis_cache_url: str = "localhost"
redis_cache_port: int = 6379
redis_cache_db: int = 0
# Milvus Vector Database
milvus_uri: str = "http://localhost:19530"
milvus_collection: str = "candidate_profiles"
# S3 Storage
s3_endpoint_url: str = "https://s3.selcdn.ru"
s3_access_key_id: str
s3_secret_access_key: str
s3_bucket_name: str
s3_region: str = "ru-1"
# LLM API Keys
openai_api_key: Optional[str] = None
anthropic_api_key: Optional[str] = None
openai_api_key: str | None = None
anthropic_api_key: str | None = None
openai_model: str = "gpt-4o-mini"
openai_embeddings_model: str = "text-embedding-3-small"
# AI Agent API Keys (for voice interviewer)
deepgram_api_key: Optional[str] = None
cartesia_api_key: Optional[str] = None
elevenlabs_api_key: Optional[str] = None
resemble_api_key: Optional[str] = None
deepgram_api_key: str | None = None
cartesia_api_key: str | None = None
elevenlabs_api_key: str | None = None
resemble_api_key: str | None = None
# LiveKit Configuration
livekit_url: str = "ws://localhost:7880"
livekit_api_key: str = "devkey"
livekit_api_secret: str = "devkey_secret_32chars_minimum_length"
# App Configuration
app_env: str = "development"
debug: bool = True
class Config:
env_file = ".env"
settings = Settings()
settings = Settings()

View File

@ -1,7 +1,8 @@
from typing import AsyncGenerator, Generator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, Session
from collections.abc import AsyncGenerator, Generator
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlmodel import SQLModel
from .config import settings
@ -57,4 +58,4 @@ def get_sync_session() -> Generator[Session, None, None]:
async def create_db_and_tables():
"""Создать таблицы в БД"""
async with async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
await conn.run_sync(SQLModel.metadata.create_all)

View File

@ -1,52 +1,52 @@
import uuid
import boto3
from botocore.exceptions import ClientError
from typing import Optional
import uuid
from app.core.config import settings
class S3Service:
def __init__(self):
self.s3_client = boto3.client(
's3',
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
region_name=settings.s3_region
region_name=settings.s3_region,
)
self.bucket_name = settings.s3_bucket_name
async def upload_file(self, file_content: bytes, file_name: str, content_type: str) -> Optional[str]:
async def upload_file(
self, file_content: bytes, file_name: str, content_type: str
) -> str | None:
try:
file_key = f"{uuid.uuid4()}_{file_name}"
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=file_key,
Body=file_content,
ContentType=content_type
ContentType=content_type,
)
file_url = f"{settings.s3_endpoint_url}/{self.bucket_name}/{file_key}"
return file_url
except ClientError as e:
print(f"Error uploading file to S3: {e}")
return None
async def delete_file(self, file_url: str) -> bool:
try:
file_key = file_url.split('/')[-1]
self.s3_client.delete_object(
Bucket=self.bucket_name,
Key=file_key
)
file_key = file_url.split("/")[-1]
self.s3_client.delete_object(Bucket=self.bucket_name, Key=file_key)
return True
except ClientError as e:
print(f"Error deleting file from S3: {e}")
return False
s3_service = S3Service()
s3_service = S3Service()

View File

@ -1,40 +1,46 @@
import logging
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.repositories.session_repository import SessionRepository
from app.models.session import Session
import logging
from app.core.database import get_session
from app.models.session import Session
from app.repositories.session_repository import SessionRepository
logger = logging.getLogger(__name__)
class SessionMiddleware(BaseHTTPMiddleware):
"""Middleware для автоматического управления сессиями"""
def __init__(self, app: ASGIApp, cookie_name: str = "session_id"):
super().__init__(app)
self.cookie_name = cookie_name
async def dispatch(self, request: Request, call_next):
# Пропускаем статические файлы, служебные эндпоинты и OPTIONS запросы
if (request.url.path.startswith(("/docs", "/redoc", "/openapi.json", "/health", "/favicon.ico")) or
request.method == "OPTIONS"):
if (
request.url.path.startswith(
("/docs", "/redoc", "/openapi.json", "/health", "/favicon.ico")
)
or request.method == "OPTIONS"
):
return await call_next(request)
# Получаем session_id из cookie или заголовка
session_id = request.cookies.get(self.cookie_name) or request.headers.get("X-Session-ID")
session_id = request.cookies.get(self.cookie_name) or request.headers.get(
"X-Session-ID"
)
session_obj = None
try:
# Работаем с БД в рамках одной async сессии
async for db_session in get_session():
session_repo = SessionRepository(db_session)
# Проверяем существующую сессию
if session_id:
session_obj = await session_repo.get_by_session_id(session_id)
@ -47,10 +53,13 @@ class SessionMiddleware(BaseHTTPMiddleware):
# Создаем новую сессию, если нет действующей
if not session_obj:
user_agent = request.headers.get("User-Agent")
client_ip = getattr(request.client, 'host', None) if request.client else None
client_ip = (
getattr(request.client, "host", None)
if request.client
else None
)
session_obj = await session_repo.create_session(
user_agent=user_agent,
ip_address=client_ip
user_agent=user_agent, ip_address=client_ip
)
logger.info(f"Created new session: {session_obj.session_id}")
@ -61,13 +70,12 @@ class SessionMiddleware(BaseHTTPMiddleware):
except Exception as e:
logger.error(f"Session middleware error: {e}")
return JSONResponse(
status_code=500,
content={"error": "Session management error"}
status_code=500, content={"error": "Session management error"}
)
# Выполняем запрос
response = await call_next(request)
# Устанавливаем cookie с session_id в ответе
if session_obj and isinstance(response, Response):
response.set_cookie(
@ -76,7 +84,7 @@ class SessionMiddleware(BaseHTTPMiddleware):
max_age=30 * 24 * 60 * 60, # 30 дней
httponly=True,
secure=False, # Для dev среды
samesite="lax"
samesite="lax",
)
return response
@ -84,4 +92,4 @@ class SessionMiddleware(BaseHTTPMiddleware):
async def get_current_session(request: Request) -> Session:
"""Получить текущую сессию из контекста запроса"""
return getattr(request.state, 'session', None)
return getattr(request.state, "session", None)

View File

@ -1,37 +1,37 @@
from .vacancy import Vacancy, VacancyCreate, VacancyUpdate, VacancyRead
from .resume import Resume, ResumeCreate, ResumeUpdate, ResumeRead
from .session import Session, SessionCreate, SessionRead
from .interview import (
InterviewSession,
InterviewSessionCreate,
InterviewSessionUpdate,
InterviewSession,
InterviewSessionCreate,
InterviewSessionRead,
InterviewStatus
InterviewSessionUpdate,
InterviewStatus,
)
from .interview_report import (
InterviewReport,
InterviewReportCreate,
InterviewReportUpdate,
InterviewReportRead,
InterviewReportSummary,
RecommendationType
InterviewReportUpdate,
RecommendationType,
)
from .resume import Resume, ResumeCreate, ResumeRead, ResumeUpdate
from .session import Session, SessionCreate, SessionRead
from .vacancy import Vacancy, VacancyCreate, VacancyRead, VacancyUpdate
__all__ = [
"Vacancy",
"VacancyCreate",
"VacancyCreate",
"VacancyUpdate",
"VacancyRead",
"Resume",
"ResumeCreate",
"ResumeUpdate",
"ResumeUpdate",
"ResumeRead",
"Session",
"SessionCreate",
"SessionRead",
"InterviewSession",
"InterviewSessionCreate",
"InterviewSessionUpdate",
"InterviewSessionUpdate",
"InterviewSessionRead",
"InterviewStatus",
"InterviewReport",
@ -40,4 +40,4 @@ __all__ = [
"InterviewReportRead",
"InterviewReportSummary",
"RecommendationType",
]
]

View File

@ -1,8 +1,9 @@
from sqlmodel import SQLModel, Field, Column, Relationship
from sqlalchemy import Enum as SQLEnum, JSON
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from typing import Any, Optional
from sqlalchemy import JSON
from sqlmodel import Column, Field, Relationship, SQLModel
class InterviewStatus(str, Enum):
@ -10,7 +11,7 @@ class InterviewStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
FAILED = "failed"
def __str__(self):
return self.value
@ -19,24 +20,29 @@ class InterviewSessionBase(SQLModel):
resume_id: int = Field(foreign_key="resume.id")
room_name: str = Field(max_length=255, unique=True)
status: str = Field(default="created", max_length=50)
transcript: Optional[str] = None
ai_feedback: Optional[str] = None
dialogue_history: Optional[List[Dict[str, Any]]] = Field(default=None, sa_column=Column(JSON))
transcript: str | None = None
ai_feedback: str | None = None
dialogue_history: list[dict[str, Any]] | None = Field(
default=None, sa_column=Column(JSON)
)
# Добавляем отслеживание AI процесса
ai_agent_pid: Optional[int] = None
ai_agent_status: str = Field(default="not_started") # not_started, running, stopped, failed
ai_agent_pid: int | None = None
ai_agent_status: str = Field(
default="not_started"
) # not_started, running, stopped, failed
class InterviewSession(InterviewSessionBase, table=True):
__tablename__ = "interview_sessions"
id: Optional[int] = Field(default=None, primary_key=True)
id: int | None = Field(default=None, primary_key=True)
started_at: datetime = Field(default_factory=datetime.utcnow)
completed_at: Optional[datetime] = None
completed_at: datetime | None = None
# Связь с отчетом (один к одному)
report: Optional["InterviewReport"] = Relationship(back_populates="interview_session")
report: Optional["InterviewReport"] = Relationship(
back_populates="interview_session"
)
class InterviewSessionCreate(SQLModel):
@ -45,17 +51,17 @@ class InterviewSessionCreate(SQLModel):
class InterviewSessionUpdate(SQLModel):
status: Optional[InterviewStatus] = None
completed_at: Optional[datetime] = None
transcript: Optional[str] = None
ai_feedback: Optional[str] = None
dialogue_history: Optional[List[Dict[str, Any]]] = None
status: InterviewStatus | None = None
completed_at: datetime | None = None
transcript: str | None = None
ai_feedback: str | None = None
dialogue_history: list[dict[str, Any]] | None = None
class InterviewSessionRead(InterviewSessionBase):
id: int
started_at: datetime
completed_at: Optional[datetime] = None
completed_at: datetime | None = None
class InterviewValidationResponse(SQLModel):
@ -66,4 +72,4 @@ class InterviewValidationResponse(SQLModel):
class LiveKitTokenResponse(SQLModel):
token: str
room_name: str
server_url: str
server_url: str

View File

@ -1,176 +1,191 @@
# -*- coding: utf-8 -*-
from sqlmodel import SQLModel, Field, Column, Relationship
from sqlalchemy import JSON, String, Integer, Float, Text
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from typing import Any, Optional
from sqlalchemy import JSON, Text
from sqlmodel import Column, Field, Relationship, SQLModel
class RecommendationType(str, Enum):
STRONGLY_RECOMMEND = "strongly_recommend"
RECOMMEND = "recommend"
RECOMMEND = "recommend"
CONSIDER = "consider"
REJECT = "reject"
def __str__(self):
return self.value
class InterviewReportBase(SQLModel):
"""Базовая модель отчета по интервью"""
interview_session_id: int = Field(foreign_key="interview_sessions.id", unique=True)
# Основные критерии оценки (0-100)
technical_skills_score: int = Field(ge=0, le=100)
technical_skills_justification: Optional[str] = Field(default=None, max_length=1000)
technical_skills_concerns: Optional[str] = Field(default=None, max_length=500)
technical_skills_justification: str | None = Field(default=None, max_length=1000)
technical_skills_concerns: str | None = Field(default=None, max_length=500)
experience_relevance_score: int = Field(ge=0, le=100)
experience_relevance_justification: Optional[str] = Field(default=None, max_length=1000)
experience_relevance_concerns: Optional[str] = Field(default=None, max_length=500)
experience_relevance_justification: str | None = Field(
default=None, max_length=1000
)
experience_relevance_concerns: str | None = Field(default=None, max_length=500)
communication_score: int = Field(ge=0, le=100)
communication_justification: Optional[str] = Field(default=None, max_length=1000)
communication_concerns: Optional[str] = Field(default=None, max_length=500)
communication_justification: str | None = Field(default=None, max_length=1000)
communication_concerns: str | None = Field(default=None, max_length=500)
problem_solving_score: int = Field(ge=0, le=100)
problem_solving_justification: Optional[str] = Field(default=None, max_length=1000)
problem_solving_concerns: Optional[str] = Field(default=None, max_length=500)
problem_solving_justification: str | None = Field(default=None, max_length=1000)
problem_solving_concerns: str | None = Field(default=None, max_length=500)
cultural_fit_score: int = Field(ge=0, le=100)
cultural_fit_justification: Optional[str] = Field(default=None, max_length=1000)
cultural_fit_concerns: Optional[str] = Field(default=None, max_length=500)
cultural_fit_justification: str | None = Field(default=None, max_length=1000)
cultural_fit_concerns: str | None = Field(default=None, max_length=500)
# Агрегированные поля
overall_score: int = Field(ge=0, le=100)
recommendation: RecommendationType
# Дополнительные поля для анализа
strengths: Optional[List[str]] = Field(default=None, sa_column=Column(JSON))
weaknesses: Optional[List[str]] = Field(default=None, sa_column=Column(JSON))
red_flags: Optional[List[str]] = Field(default=None, sa_column=Column(JSON))
strengths: list[str] | None = Field(default=None, sa_column=Column(JSON))
weaknesses: list[str] | None = Field(default=None, sa_column=Column(JSON))
red_flags: list[str] | None = Field(default=None, sa_column=Column(JSON))
# Метрики интервью
questions_quality_score: Optional[float] = Field(default=None, ge=0, le=10) # Средняя оценка ответов
interview_duration_minutes: Optional[int] = Field(default=None, ge=0)
response_count: Optional[int] = Field(default=None, ge=0)
dialogue_messages_count: Optional[int] = Field(default=None, ge=0)
questions_quality_score: float | None = Field(
default=None, ge=0, le=10
) # Средняя оценка ответов
interview_duration_minutes: int | None = Field(default=None, ge=0)
response_count: int | None = Field(default=None, ge=0)
dialogue_messages_count: int | None = Field(default=None, ge=0)
# Дополнительная информация
next_steps: Optional[str] = Field(default=None, max_length=1000)
interviewer_notes: Optional[str] = Field(default=None, sa_column=Column(Text))
next_steps: str | None = Field(default=None, max_length=1000)
interviewer_notes: str | None = Field(default=None, sa_column=Column(Text))
# Детальный анализ вопросов (JSON)
questions_analysis: Optional[List[Dict[str, Any]]] = Field(default=None, sa_column=Column(JSON))
questions_analysis: list[dict[str, Any]] | None = Field(
default=None, sa_column=Column(JSON)
)
# Метаданные анализа
analysis_method: Optional[str] = Field(default="openai_gpt4", max_length=50) # openai_gpt4, fallback_heuristic
llm_model_used: Optional[str] = Field(default=None, max_length=100)
analysis_duration_seconds: Optional[int] = Field(default=None, ge=0)
analysis_method: str | None = Field(
default="openai_gpt4", max_length=50
) # openai_gpt4, fallback_heuristic
llm_model_used: str | None = Field(default=None, max_length=100)
analysis_duration_seconds: int | None = Field(default=None, ge=0)
class InterviewReport(InterviewReportBase, table=True):
"""Полный отчет по интервью с ID и временными метками"""
__tablename__ = "interview_reports"
id: Optional[int] = Field(default=None, primary_key=True)
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Связь с сессией интервью
interview_session: Optional["InterviewSession"] = Relationship(back_populates="report")
interview_session: Optional["InterviewSession"] = Relationship(
back_populates="report"
)
class InterviewReportCreate(SQLModel):
"""Модель для создания отчета"""
interview_session_id: int
technical_skills_score: int = Field(ge=0, le=100)
technical_skills_justification: Optional[str] = None
technical_skills_concerns: Optional[str] = None
technical_skills_justification: str | None = None
technical_skills_concerns: str | None = None
experience_relevance_score: int = Field(ge=0, le=100)
experience_relevance_justification: Optional[str] = None
experience_relevance_concerns: Optional[str] = None
experience_relevance_justification: str | None = None
experience_relevance_concerns: str | None = None
communication_score: int = Field(ge=0, le=100)
communication_justification: Optional[str] = None
communication_concerns: Optional[str] = None
communication_justification: str | None = None
communication_concerns: str | None = None
problem_solving_score: int = Field(ge=0, le=100)
problem_solving_justification: Optional[str] = None
problem_solving_concerns: Optional[str] = None
problem_solving_justification: str | None = None
problem_solving_concerns: str | None = None
cultural_fit_score: int = Field(ge=0, le=100)
cultural_fit_justification: Optional[str] = None
cultural_fit_concerns: Optional[str] = None
cultural_fit_justification: str | None = None
cultural_fit_concerns: str | None = None
overall_score: int = Field(ge=0, le=100)
recommendation: RecommendationType
strengths: Optional[List[str]] = None
weaknesses: Optional[List[str]] = None
red_flags: Optional[List[str]] = None
questions_quality_score: Optional[float] = None
interview_duration_minutes: Optional[int] = None
response_count: Optional[int] = None
dialogue_messages_count: Optional[int] = None
next_steps: Optional[str] = None
interviewer_notes: Optional[str] = None
questions_analysis: Optional[List[Dict[str, Any]]] = None
analysis_method: Optional[str] = "openai_gpt4"
llm_model_used: Optional[str] = None
analysis_duration_seconds: Optional[int] = None
strengths: list[str] | None = None
weaknesses: list[str] | None = None
red_flags: list[str] | None = None
questions_quality_score: float | None = None
interview_duration_minutes: int | None = None
response_count: int | None = None
dialogue_messages_count: int | None = None
next_steps: str | None = None
interviewer_notes: str | None = None
questions_analysis: list[dict[str, Any]] | None = None
analysis_method: str | None = "openai_gpt4"
llm_model_used: str | None = None
analysis_duration_seconds: int | None = None
class InterviewReportUpdate(SQLModel):
"""Модель для обновления отчета"""
technical_skills_score: Optional[int] = Field(default=None, ge=0, le=100)
technical_skills_justification: Optional[str] = None
technical_skills_concerns: Optional[str] = None
experience_relevance_score: Optional[int] = Field(default=None, ge=0, le=100)
experience_relevance_justification: Optional[str] = None
experience_relevance_concerns: Optional[str] = None
communication_score: Optional[int] = Field(default=None, ge=0, le=100)
communication_justification: Optional[str] = None
communication_concerns: Optional[str] = None
problem_solving_score: Optional[int] = Field(default=None, ge=0, le=100)
problem_solving_justification: Optional[str] = None
problem_solving_concerns: Optional[str] = None
cultural_fit_score: Optional[int] = Field(default=None, ge=0, le=100)
cultural_fit_justification: Optional[str] = None
cultural_fit_concerns: Optional[str] = None
overall_score: Optional[int] = Field(default=None, ge=0, le=100)
recommendation: Optional[RecommendationType] = None
strengths: Optional[List[str]] = None
weaknesses: Optional[List[str]] = None
red_flags: Optional[List[str]] = None
questions_quality_score: Optional[float] = None
interview_duration_minutes: Optional[int] = None
response_count: Optional[int] = None
dialogue_messages_count: Optional[int] = None
next_steps: Optional[str] = None
interviewer_notes: Optional[str] = None
questions_analysis: Optional[List[Dict[str, Any]]] = None
analysis_method: Optional[str] = None
llm_model_used: Optional[str] = None
analysis_duration_seconds: Optional[int] = None
technical_skills_score: int | None = Field(default=None, ge=0, le=100)
technical_skills_justification: str | None = None
technical_skills_concerns: str | None = None
experience_relevance_score: int | None = Field(default=None, ge=0, le=100)
experience_relevance_justification: str | None = None
experience_relevance_concerns: str | None = None
communication_score: int | None = Field(default=None, ge=0, le=100)
communication_justification: str | None = None
communication_concerns: str | None = None
problem_solving_score: int | None = Field(default=None, ge=0, le=100)
problem_solving_justification: str | None = None
problem_solving_concerns: str | None = None
cultural_fit_score: int | None = Field(default=None, ge=0, le=100)
cultural_fit_justification: str | None = None
cultural_fit_concerns: str | None = None
overall_score: int | None = Field(default=None, ge=0, le=100)
recommendation: RecommendationType | None = None
strengths: list[str] | None = None
weaknesses: list[str] | None = None
red_flags: list[str] | None = None
questions_quality_score: float | None = None
interview_duration_minutes: int | None = None
response_count: int | None = None
dialogue_messages_count: int | None = None
next_steps: str | None = None
interviewer_notes: str | None = None
questions_analysis: list[dict[str, Any]] | None = None
analysis_method: str | None = None
llm_model_used: str | None = None
analysis_duration_seconds: int | None = None
class InterviewReportRead(InterviewReportBase):
"""Модель для чтения отчета с ID и временными метками"""
id: int
created_at: datetime
updated_at: datetime
@ -178,22 +193,23 @@ class InterviewReportRead(InterviewReportBase):
class InterviewReportSummary(SQLModel):
"""Краткая сводка отчета для списков"""
id: int
interview_session_id: int
overall_score: int
recommendation: RecommendationType
created_at: datetime
# Основные баллы
technical_skills_score: int
experience_relevance_score: int
communication_score: int
problem_solving_score: int
cultural_fit_score: int
# Краткие выводы
strengths: Optional[List[str]] = None
red_flags: Optional[List[str]] = None
strengths: list[str] | None = None
red_flags: list[str] | None = None
# Индексы для эффективных запросов по скорингу
@ -204,4 +220,4 @@ CREATE INDEX idx_interview_reports_recommendation ON interview_reports (recommen
CREATE INDEX idx_interview_reports_technical_skills ON interview_reports (technical_skills_score DESC);
CREATE INDEX idx_interview_reports_communication ON interview_reports (communication_score DESC);
CREATE INDEX idx_interview_reports_session_id ON interview_reports (interview_session_id);
"""
"""

View File

@ -1,13 +1,13 @@
from sqlmodel import SQLModel, Field, Relationship, Column
from sqlalchemy import JSON
from typing import Optional
from datetime import datetime
from enum import Enum
from sqlalchemy import JSON
from sqlmodel import Column, Field, SQLModel
class ResumeStatus(str, Enum):
PENDING = "pending"
PARSING = "parsing"
PARSING = "parsing"
PARSED = "parsed"
PARSE_FAILED = "parse_failed"
UNDER_REVIEW = "under_review"
@ -22,19 +22,19 @@ class ResumeBase(SQLModel):
session_id: int = Field(foreign_key="session.id")
applicant_name: str = Field(max_length=255)
applicant_email: str = Field(max_length=255)
applicant_phone: Optional[str] = Field(max_length=50)
applicant_phone: str | None = Field(max_length=50)
resume_file_url: str
cover_letter: Optional[str] = None
cover_letter: str | None = None
status: ResumeStatus = Field(default=ResumeStatus.PENDING)
interview_report_url: Optional[str] = None
notes: Optional[str] = None
parsed_data: Optional[dict] = Field(default=None, sa_column=Column(JSON))
interview_plan: Optional[dict] = Field(default=None, sa_column=Column(JSON))
parse_error: Optional[str] = None
interview_report_url: str | None = None
notes: str | None = None
parsed_data: dict | None = Field(default=None, sa_column=Column(JSON))
interview_plan: dict | None = Field(default=None, sa_column=Column(JSON))
parse_error: str | None = None
class Resume(ResumeBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
@ -43,25 +43,25 @@ class ResumeCreate(SQLModel):
vacancy_id: int
applicant_name: str = Field(max_length=255)
applicant_email: str = Field(max_length=255)
applicant_phone: Optional[str] = Field(max_length=50)
applicant_phone: str | None = Field(max_length=50)
resume_file_url: str
cover_letter: Optional[str] = None
cover_letter: str | None = None
class ResumeUpdate(SQLModel):
applicant_name: Optional[str] = None
applicant_email: Optional[str] = None
applicant_phone: Optional[str] = None
cover_letter: Optional[str] = None
status: Optional[ResumeStatus] = None
interview_report_url: Optional[str] = None
notes: Optional[str] = None
parsed_data: Optional[dict] = None
interview_plan: Optional[dict] = None
parse_error: Optional[str] = None
applicant_name: str | None = None
applicant_email: str | None = None
applicant_phone: str | None = None
cover_letter: str | None = None
status: ResumeStatus | None = None
interview_report_url: str | None = None
notes: str | None = None
parsed_data: dict | None = None
interview_plan: dict | None = None
parse_error: str | None = None
class ResumeRead(ResumeBase):
id: int
created_at: datetime
updated_at: datetime
updated_at: datetime

View File

@ -1,30 +1,32 @@
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime, timedelta
import uuid
from datetime import datetime, timedelta
from sqlmodel import Field, SQLModel
class SessionBase(SQLModel):
session_id: str = Field(max_length=255, unique=True, index=True)
user_agent: Optional[str] = Field(max_length=512)
ip_address: Optional[str] = Field(max_length=45)
user_agent: str | None = Field(max_length=512)
ip_address: str | None = Field(max_length=45)
is_active: bool = Field(default=True)
expires_at: datetime = Field(default_factory=lambda: datetime.utcnow() + timedelta(days=30))
expires_at: datetime = Field(
default_factory=lambda: datetime.utcnow() + timedelta(days=30)
)
last_activity: datetime = Field(default_factory=datetime.utcnow)
class Session(SessionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
@classmethod
def create_new_session(cls, user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> "Session":
def create_new_session(
cls, user_agent: str | None = None, ip_address: str | None = None
) -> "Session":
"""Create a new session with a unique session_id"""
return cls(
session_id=str(uuid.uuid4()),
user_agent=user_agent,
ip_address=ip_address
session_id=str(uuid.uuid4()), user_agent=user_agent, ip_address=ip_address
)
def is_expired(self) -> bool:
@ -44,4 +46,4 @@ class SessionCreate(SessionBase):
class SessionRead(SessionBase):
id: int
created_at: datetime
updated_at: datetime
updated_at: datetime

View File

@ -1,8 +1,8 @@
from sqlmodel import SQLModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
from sqlmodel import Field, SQLModel
class EmploymentType(str, Enum):
FULL_TIME = "full"
@ -15,7 +15,7 @@ class EmploymentType(str, Enum):
class Experience(str, Enum):
NO_EXPERIENCE = "noExperience"
BETWEEN_1_AND_3 = "between1And3"
BETWEEN_3_AND_6 = "between3And6"
BETWEEN_3_AND_6 = "between3And6"
MORE_THAN_6 = "moreThan6"
@ -30,31 +30,31 @@ class Schedule(str, Enum):
class VacancyBase(SQLModel):
title: str = Field(max_length=255)
description: str
key_skills: Optional[str] = None
key_skills: str | None = None
employment_type: EmploymentType
experience: Experience
schedule: Schedule
salary_from: Optional[int] = None
salary_to: Optional[int] = None
salary_currency: Optional[str] = Field(default="RUR", max_length=3)
gross_salary: Optional[bool] = False
salary_from: int | None = None
salary_to: int | None = None
salary_currency: str | None = Field(default="RUR", max_length=3)
gross_salary: bool | None = False
company_name: str = Field(max_length=255)
company_description: Optional[str] = None
company_description: str | None = None
area_name: str = Field(max_length=255)
metro_stations: Optional[str] = None
address: Optional[str] = None
professional_roles: Optional[str] = None
contacts_name: Optional[str] = Field(max_length=255)
contacts_email: Optional[str] = Field(max_length=255)
contacts_phone: Optional[str] = Field(max_length=50)
metro_stations: str | None = None
address: str | None = None
professional_roles: str | None = None
contacts_name: str | None = Field(max_length=255)
contacts_email: str | None = Field(max_length=255)
contacts_phone: str | None = Field(max_length=50)
is_archived: bool = Field(default=False)
premium: bool = Field(default=False)
published_at: Optional[datetime] = Field(default_factory=datetime.utcnow)
url: Optional[str] = None
published_at: datetime | None = Field(default_factory=datetime.utcnow)
url: str | None = None
class Vacancy(VacancyBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
@ -64,32 +64,32 @@ class VacancyCreate(VacancyBase):
class VacancyUpdate(SQLModel):
title: Optional[str] = None
description: Optional[str] = None
key_skills: Optional[str] = None
employment_type: Optional[EmploymentType] = None
experience: Optional[Experience] = None
schedule: Optional[Schedule] = None
salary_from: Optional[int] = None
salary_to: Optional[int] = None
salary_currency: Optional[str] = None
gross_salary: Optional[bool] = None
company_name: Optional[str] = None
company_description: Optional[str] = None
area_name: Optional[str] = None
metro_stations: Optional[str] = None
address: Optional[str] = None
professional_roles: Optional[str] = None
contacts_name: Optional[str] = None
contacts_email: Optional[str] = None
contacts_phone: Optional[str] = None
is_archived: Optional[bool] = None
premium: Optional[bool] = None
published_at: Optional[datetime] = None
url: Optional[str] = None
title: str | None = None
description: str | None = None
key_skills: str | None = None
employment_type: EmploymentType | None = None
experience: Experience | None = None
schedule: Schedule | None = None
salary_from: int | None = None
salary_to: int | None = None
salary_currency: str | None = None
gross_salary: bool | None = None
company_name: str | None = None
company_description: str | None = None
area_name: str | None = None
metro_stations: str | None = None
address: str | None = None
professional_roles: str | None = None
contacts_name: str | None = None
contacts_email: str | None = None
contacts_phone: str | None = None
is_archived: bool | None = None
premium: bool | None = None
published_at: datetime | None = None
url: str | None = None
class VacancyRead(VacancyBase):
id: int
created_at: datetime
updated_at: datetime
updated_at: datetime

View File

@ -1,5 +1,5 @@
from .vacancy_repository import VacancyRepository
from .resume_repository import ResumeRepository
from .interview_repository import InterviewRepository
from .resume_repository import ResumeRepository
from .vacancy_repository import VacancyRepository
__all__ = ["VacancyRepository", "ResumeRepository", "InterviewRepository"]
__all__ = ["VacancyRepository", "ResumeRepository", "InterviewRepository"]

View File

@ -1,15 +1,21 @@
from typing import TypeVar, Generic, Optional, List, Type, Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlmodel import SQLModel
from typing import Annotated, Generic, TypeVar
from fastapi import Depends
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
from app.core.database import get_session
ModelType = TypeVar("ModelType", bound=SQLModel)
class BaseRepository(Generic[ModelType]):
def __init__(self, model: Type[ModelType], session: Annotated[AsyncSession, Depends(get_session)]):
def __init__(
self,
model: type[ModelType],
session: Annotated[AsyncSession, Depends(get_session)],
):
self.model = model
self._session = session
@ -20,29 +26,29 @@ class BaseRepository(Generic[ModelType]):
await self._session.refresh(db_obj)
return db_obj
async def get(self, id: int) -> Optional[ModelType]:
async def get(self, id: int) -> ModelType | None:
statement = select(self.model).where(self.model.id == id)
result = await self._session.execute(statement)
return result.scalar_one_or_none()
async def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]:
async def get_all(self, skip: int = 0, limit: int = 100) -> list[ModelType]:
statement = select(self.model).offset(skip).limit(limit)
result = await self._session.execute(statement)
return result.scalars().all()
async def update(self, id: int, obj_in: dict) -> Optional[ModelType]:
async def update(self, id: int, obj_in: dict) -> ModelType | None:
# Получаем объект и обновляем его напрямую
result = await self._session.execute(
select(self.model).where(self.model.id == id)
)
db_obj = result.scalar_one_or_none()
if not db_obj:
return None
for key, value in obj_in.items():
setattr(db_obj, key, value)
await self._session.commit()
await self._session.refresh(db_obj)
return db_obj
@ -51,4 +57,4 @@ class BaseRepository(Generic[ModelType]):
statement = delete(self.model).where(self.model.id == id)
result = await self._session.execute(statement)
await self._session.commit()
return result.rowcount > 0
return result.rowcount > 0

View File

@ -1,24 +1,30 @@
from typing import Optional, List, Annotated
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, desc
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.interview import InterviewSession, InterviewStatus
from app.models.interview import InterviewSession
from app.repositories.base_repository import BaseRepository
class InterviewRepository(BaseRepository[InterviewSession]):
def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]):
super().__init__(InterviewSession, session)
async def get_by_room_name(self, room_name: str) -> Optional[InterviewSession]:
async def get_by_room_name(self, room_name: str) -> InterviewSession | None:
"""Получить сессию интервью по имени комнаты"""
statement = select(InterviewSession).where(InterviewSession.room_name == room_name)
statement = select(InterviewSession).where(
InterviewSession.room_name == room_name
)
result = await self._session.execute(statement)
return result.scalar_one_or_none()
async def update_status(self, session_id: int, status: str, completed_at: Optional[datetime] = None) -> bool:
async def update_status(
self, session_id: int, status: str, completed_at: datetime | None = None
) -> bool:
"""Обновить статус сессии"""
try:
# Получаем объект и обновляем его напрямую
@ -26,21 +32,23 @@ class InterviewRepository(BaseRepository[InterviewSession]):
select(InterviewSession).where(InterviewSession.id == session_id)
)
session_obj = result.scalar_one_or_none()
if not session_obj:
return False
session_obj.status = status
if completed_at:
session_obj.completed_at = completed_at
await self._session.commit()
return True
except Exception:
await self._session.rollback()
return False
async def update_dialogue_history(self, room_name: str, dialogue_history: list) -> bool:
async def update_dialogue_history(
self, room_name: str, dialogue_history: list
) -> bool:
"""Обновить историю диалога для сессии"""
try:
# Получаем объект и обновляем его напрямую
@ -48,18 +56,20 @@ class InterviewRepository(BaseRepository[InterviewSession]):
select(InterviewSession).where(InterviewSession.room_name == room_name)
)
session_obj = result.scalar_one_or_none()
if not session_obj:
return False
session_obj.dialogue_history = dialogue_history
await self._session.commit()
return True
except Exception:
await self._session.rollback()
return False
async def update_ai_agent_status(self, session_id: int, pid: Optional[int] = None, status: str = "not_started") -> bool:
async def update_ai_agent_status(
self, session_id: int, pid: int | None = None, status: str = "not_started"
) -> bool:
"""Обновить статус AI агента"""
try:
# Получаем объект и обновляем его напрямую
@ -67,10 +77,10 @@ class InterviewRepository(BaseRepository[InterviewSession]):
select(InterviewSession).where(InterviewSession.id == session_id)
)
session_obj = result.scalar_one_or_none()
if not session_obj:
return False
session_obj.ai_agent_pid = pid
session_obj.ai_agent_status = status
await self._session.commit()
@ -78,8 +88,8 @@ class InterviewRepository(BaseRepository[InterviewSession]):
except Exception:
await self._session.rollback()
return False
async def get_sessions_with_running_agents(self) -> List[InterviewSession]:
async def get_sessions_with_running_agents(self) -> list[InterviewSession]:
"""Получить сессии с запущенными AI агентами"""
statement = select(InterviewSession).where(
InterviewSession.ai_agent_status == "running"
@ -87,7 +97,9 @@ class InterviewRepository(BaseRepository[InterviewSession]):
result = await self._session.execute(statement)
return result.scalars().all()
async def get_active_session_by_resume_id(self, resume_id: int) -> Optional[InterviewSession]:
async def get_active_session_by_resume_id(
self, resume_id: int
) -> InterviewSession | None:
"""Получить активную сессию собеседования для резюме"""
statement = (
select(InterviewSession)
@ -98,13 +110,13 @@ class InterviewRepository(BaseRepository[InterviewSession]):
result = await self._session.execute(statement)
return result.scalar_one_or_none()
async def create_interview_session(self, resume_id: int, room_name: str) -> InterviewSession:
async def create_interview_session(
self, resume_id: int, room_name: str
) -> InterviewSession:
"""Создать новую сессию интервью"""
from app.models.interview import InterviewSessionCreate
session_data = InterviewSessionCreate(
resume_id=resume_id,
room_name=room_name
)
session_data = InterviewSessionCreate(resume_id=resume_id, room_name=room_name)
return await self.create(session_data.model_dump())
async def update_session_status(self, session_id: int, status: str) -> bool:
@ -112,4 +124,4 @@ class InterviewRepository(BaseRepository[InterviewSession]):
completed_at = None
if status == "completed":
completed_at = datetime.utcnow()
return await self.update_status(session_id, status, completed_at)
return await self.update_status(session_id, status, completed_at)

View File

@ -1,9 +1,12 @@
from typing import List, Optional, Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.resume import Resume, ResumeStatus
from .base_repository import BaseRepository
@ -11,45 +14,49 @@ class ResumeRepository(BaseRepository[Resume]):
def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]):
super().__init__(Resume, session)
async def get_by_vacancy_id(self, vacancy_id: int) -> List[Resume]:
async def get_by_vacancy_id(self, vacancy_id: int) -> list[Resume]:
statement = select(Resume).where(Resume.vacancy_id == vacancy_id)
result = await self._session.execute(statement)
return result.scalars().all()
async def get_by_status(self, status: ResumeStatus) -> List[Resume]:
async def get_by_status(self, status: ResumeStatus) -> list[Resume]:
statement = select(Resume).where(Resume.status == status)
result = await self._session.execute(statement)
return result.scalars().all()
async def get_by_id(self, resume_id: int) -> Optional[Resume]:
async def get_by_id(self, resume_id: int) -> Resume | None:
"""Получить резюме по ID"""
return await self.get(resume_id)
async def create_with_session(self, resume_dict: dict, session_id: int) -> Resume:
"""Создать резюме с привязкой к сессии"""
resume_dict['session_id'] = session_id
resume_dict["session_id"] = session_id
return await self.create(resume_dict)
async def get_by_session_id(self, session_id: int) -> List[Resume]:
async def get_by_session_id(self, session_id: int) -> list[Resume]:
"""Получить резюме по session_id"""
statement = select(Resume).where(Resume.session_id == session_id)
result = await self._session.execute(statement)
return result.scalars().all()
async def get_by_vacancy_and_session(self, vacancy_id: int, session_id: int) -> List[Resume]:
async def get_by_vacancy_and_session(
self, vacancy_id: int, session_id: int
) -> list[Resume]:
"""Получить резюме по vacancy_id и session_id"""
statement = select(Resume).where(
Resume.vacancy_id == vacancy_id,
Resume.session_id == session_id
Resume.vacancy_id == vacancy_id, Resume.session_id == session_id
)
result = await self._session.execute(statement)
return result.scalars().all()
async def update_status(self, resume_id: int, status: ResumeStatus) -> Optional[Resume]:
async def update_status(
self, resume_id: int, status: ResumeStatus
) -> Resume | None:
"""Обновить статус резюме"""
return await self.update(resume_id, {"status": status})
async def add_interview_report(self, resume_id: int, report_url: str) -> Optional[Resume]:
async def add_interview_report(
self, resume_id: int, report_url: str
) -> Resume | None:
"""Добавить ссылку на отчет интервью"""
return await self.update(resume_id, {"interview_report_url": report_url})

View File

@ -1,30 +1,36 @@
from typing import Optional, Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.session import Session
from app.repositories.base_repository import BaseRepository
from datetime import datetime
class SessionRepository(BaseRepository[Session]):
def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]):
super().__init__(Session, session)
async def get_by_session_id(self, session_id: str) -> Optional[Session]:
async def get_by_session_id(self, session_id: str) -> Session | None:
"""Get session by session_id"""
statement = select(Session).where(
Session.session_id == session_id,
Session.is_active == True,
Session.expires_at > datetime.utcnow()
Session.expires_at > datetime.utcnow(),
)
result = await self._session.execute(statement)
return result.scalar_one_or_none()
async def create_session(self, user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> Session:
async def create_session(
self, user_agent: str | None = None, ip_address: str | None = None
) -> Session:
"""Create a new session"""
new_session = Session.create_new_session(user_agent=user_agent, ip_address=ip_address)
new_session = Session.create_new_session(
user_agent=user_agent, ip_address=ip_address
)
return await self.create(new_session)
async def deactivate_session(self, session_id: str) -> bool:
@ -56,11 +62,11 @@ class SessionRepository(BaseRepository[Session]):
statement = select(Session).where(Session.expires_at < datetime.utcnow())
result = await self._session.execute(statement)
expired_sessions = result.scalars().all()
count = 0
for session in expired_sessions:
await self._session.delete(session)
count += 1
await self._session.commit()
return count
return count

View File

@ -1,9 +1,12 @@
from typing import List, Optional, Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import Annotated
from fastapi import Depends
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.vacancy import Vacancy, VacancyCreate, VacancyUpdate
from app.models.vacancy import Vacancy
from .base_repository import BaseRepository
@ -11,12 +14,12 @@ class VacancyRepository(BaseRepository[Vacancy]):
def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]):
super().__init__(Vacancy, session)
async def get_by_company(self, company_name: str) -> List[Vacancy]:
async def get_by_company(self, company_name: str) -> list[Vacancy]:
statement = select(Vacancy).where(Vacancy.company_name == company_name)
result = await self._session.execute(statement)
return result.scalars().all()
async def get_active(self, skip: int = 0, limit: int = 100) -> List[Vacancy]:
async def get_active(self, skip: int = 0, limit: int = 100) -> list[Vacancy]:
statement = (
select(Vacancy)
.where(Vacancy.is_archived == False)
@ -28,12 +31,12 @@ class VacancyRepository(BaseRepository[Vacancy]):
async def search(
self,
title: Optional[str] = None,
company_name: Optional[str] = None,
area_name: Optional[str] = None,
title: str | None = None,
company_name: str | None = None,
area_name: str | None = None,
skip: int = 0,
limit: int = 100
) -> List[Vacancy]:
limit: int = 100,
) -> list[Vacancy]:
"""Поиск вакансий по критериям"""
statement = select(Vacancy)
conditions = []
@ -52,28 +55,29 @@ class VacancyRepository(BaseRepository[Vacancy]):
result = await self._session.execute(statement)
return result.scalars().all()
async def get_active_vacancies(self, skip: int = 0, limit: int = 100) -> List[Vacancy]:
async def get_active_vacancies(
self, skip: int = 0, limit: int = 100
) -> list[Vacancy]:
"""Получить активные вакансии (алиас для get_active)"""
return await self.get_active(skip=skip, limit=limit)
async def search_vacancies(
self,
title: Optional[str] = None,
company_name: Optional[str] = None,
area_name: Optional[str] = None,
title: str | None = None,
company_name: str | None = None,
area_name: str | None = None,
skip: int = 0,
limit: int = 100
) -> List[Vacancy]:
limit: int = 100,
) -> list[Vacancy]:
"""Поиск вакансий (алиас для search)"""
return await self.search(
title=title,
company_name=company_name,
area_name=area_name,
skip=skip,
limit=limit
limit=limit,
)
async def archive(self, vacancy_id: int) -> Optional[Vacancy]:
async def archive(self, vacancy_id: int) -> Vacancy | None:
"""Архивировать вакансию"""
return await self.update(vacancy_id, {"is_active": False})
return await self.update(vacancy_id, {"is_active": False})

View File

@ -1,4 +1,4 @@
from .vacancy_router import router as vacancy_router
from .resume_router import router as resume_router
from .vacancy_router import router as vacancy_router
__all__ = ["vacancy_router", "resume_router"]
__all__ = ["vacancy_router", "resume_router"]

View File

@ -1,79 +1,118 @@
from fastapi import APIRouter, Depends, HTTPException
from app.services.admin_service import AdminService
from typing import Dict
from app.services.agent_manager import agent_manager
router = APIRouter(prefix="/admin", tags=["Admin"])
@router.get("/interview-processes")
async def list_active_interview_processes(
admin_service: AdminService = Depends(AdminService)
) -> Dict:
admin_service: AdminService = Depends(AdminService),
) -> dict:
"""Список всех активных AI процессов интервью"""
return await admin_service.get_active_interview_processes()
@router.post("/interview-processes/{session_id}/stop")
async def stop_interview_process(
session_id: int,
admin_service: AdminService = Depends(AdminService)
) -> Dict:
session_id: int, admin_service: AdminService = Depends(AdminService)
) -> dict:
"""Остановить AI процесс для конкретного интервью"""
result = await admin_service.stop_interview_process(session_id)
if not result["success"]:
raise HTTPException(status_code=404, detail=result["message"])
return result
@router.post("/interview-processes/cleanup")
async def cleanup_dead_processes(
admin_service: AdminService = Depends(AdminService)
) -> Dict:
admin_service: AdminService = Depends(AdminService),
) -> dict:
"""Очистка мертвых процессов"""
return await admin_service.cleanup_dead_processes()
@router.get("/system-stats")
async def get_system_stats(
admin_service: AdminService = Depends(AdminService)
) -> Dict:
async def get_system_stats(admin_service: AdminService = Depends(AdminService)) -> dict:
"""Общая статистика системы"""
result = await admin_service.get_system_stats()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.get("/agent/status")
async def get_agent_status() -> dict:
"""Статус AI агента"""
return {"agent": agent_manager.get_status()}
@router.post("/agent/start")
async def start_agent() -> dict:
"""Запуск AI агента"""
success = await agent_manager.start_agent()
if success:
return {"success": True, "message": "AI Agent started successfully"}
else:
raise HTTPException(status_code=500, detail="Failed to start AI Agent")
@router.post("/agent/stop")
async def stop_agent() -> dict:
"""Остановка AI агента"""
success = await agent_manager.stop_agent()
if success:
return {"success": True, "message": "AI Agent stopped successfully"}
else:
raise HTTPException(status_code=500, detail="Failed to stop AI Agent")
@router.post("/agent/restart")
async def restart_agent() -> dict:
"""Перезапуск AI агента"""
# Сначала останавливаем
await agent_manager.stop_agent()
# Затем запускаем
success = await agent_manager.start_agent()
if success:
return {"success": True, "message": "AI Agent restarted successfully"}
else:
raise HTTPException(status_code=500, detail="Failed to restart AI Agent")
@router.get("/analytics/dashboard")
async def get_analytics_dashboard(
admin_service: AdminService = Depends(AdminService)
) -> Dict:
admin_service: AdminService = Depends(AdminService),
) -> dict:
"""Основная аналитическая панель"""
return await admin_service.get_analytics_dashboard()
@router.get("/analytics/candidates/{vacancy_id}")
async def get_vacancy_analytics(
vacancy_id: int,
admin_service: AdminService = Depends(AdminService)
) -> Dict:
vacancy_id: int, admin_service: AdminService = Depends(AdminService)
) -> dict:
"""Аналитика кандидатов по конкретной вакансии"""
return await admin_service.get_vacancy_analytics(vacancy_id)
@router.post("/analytics/generate-reports/{vacancy_id}")
async def generate_reports_for_vacancy(
vacancy_id: int,
admin_service: AdminService = Depends(AdminService)
) -> Dict:
vacancy_id: int, admin_service: AdminService = Depends(AdminService)
) -> dict:
"""Запустить генерацию отчетов для всех кандидатов вакансии"""
result = await admin_service.generate_reports_for_vacancy(vacancy_id)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
return result

View File

@ -1,20 +1,18 @@
# -*- coding: utf-8 -*-
from typing import List
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from app.repositories.resume_repository import ResumeRepository
from celery_worker.interview_analysis_task import generate_interview_report, analyze_multiple_candidates
router = APIRouter(
prefix="/analysis",
tags=["analysis"]
from celery_worker.interview_analysis_task import (
analyze_multiple_candidates,
generate_interview_report,
)
router = APIRouter(prefix="/analysis", tags=["analysis"])
class AnalysisResponse(BaseModel):
"""Ответ запуска задачи анализа"""
message: str
resume_id: int
task_id: str
@ -22,11 +20,13 @@ class AnalysisResponse(BaseModel):
class BulkAnalysisRequest(BaseModel):
"""Запрос массового анализа"""
resume_ids: List[int]
resume_ids: list[int]
class BulkAnalysisResponse(BaseModel):
"""Ответ массового анализа"""
message: str
resume_count: int
task_id: str
@ -34,6 +34,7 @@ class BulkAnalysisResponse(BaseModel):
class CandidateRanking(BaseModel):
"""Рейтинг кандидата"""
resume_id: int
candidate_name: str
overall_score: int
@ -45,32 +46,30 @@ class CandidateRanking(BaseModel):
async def start_interview_analysis(
resume_id: int,
background_tasks: BackgroundTasks,
resume_repo: ResumeRepository = Depends(ResumeRepository)
resume_repo: ResumeRepository = Depends(ResumeRepository),
):
"""
Запускает анализ интервью для конкретного кандидата
Анализирует:
- Соответствие резюме вакансии
- Качество ответов в диалоге интервью
- Качество ответов в диалоге интервью
- Технические навыки и опыт
- Коммуникативные способности
- Общую рекомендацию и рейтинг
"""
# Проверяем, существует ли резюме
resume = await resume_repo.get_by_id(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Запускаем задачу анализа
task = generate_interview_report.delay(resume_id)
return AnalysisResponse(
message="Interview analysis started",
resume_id=resume_id,
task_id=task.id
message="Interview analysis started", resume_id=resume_id, task_id=task.id
)
@ -78,89 +77,87 @@ async def start_interview_analysis(
async def start_bulk_analysis(
request: BulkAnalysisRequest,
background_tasks: BackgroundTasks,
resume_repo: ResumeRepository = Depends(ResumeRepository)
resume_repo: ResumeRepository = Depends(ResumeRepository),
):
"""
Запускает массовый анализ нескольких кандидатов
Возвращает ранжированный список кандидатов по общему баллу
Полезно для сравнения кандидатов на одну позицию
"""
# Проверяем, что все резюме существуют
existing_resumes = []
for resume_id in request.resume_ids:
resume = await resume_repo.get_by_id(resume_id)
if resume:
existing_resumes.append(resume_id)
if not existing_resumes:
raise HTTPException(status_code=404, detail="No valid resumes found")
# Запускаем задачу массового анализа
task = analyze_multiple_candidates.delay(existing_resumes)
return BulkAnalysisResponse(
message="Bulk analysis started",
resume_count=len(existing_resumes),
task_id=task.id
task_id=task.id,
)
@router.get("/ranking/{vacancy_id}")
async def get_candidates_ranking(
vacancy_id: int,
resume_repo: ResumeRepository = Depends(ResumeRepository)
vacancy_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
):
"""
Получить ранжированный список кандидатов для вакансии
Сортирует кандидатов по результатам анализа интервью
Показывает только тех, кто прошел интервью
"""
# Получаем все резюме для вакансии со статусом "interviewed"
resumes = await resume_repo.get_by_vacancy_id(vacancy_id)
interviewed_resumes = [r for r in resumes if r.status in ["interviewed"]]
if not interviewed_resumes:
return {
"vacancy_id": vacancy_id,
"candidates": [],
"message": "No interviewed candidates found"
"message": "No interviewed candidates found",
}
# Запускаем массовый анализ если еще не было
resume_ids = [r.id for r in interviewed_resumes]
task = analyze_multiple_candidates.delay(resume_ids)
# В реальности здесь нужно дождаться выполнения или получить из кэша
# Пока возвращаем информацию о запущенной задаче
return {
"vacancy_id": vacancy_id,
"task_id": task.id,
"message": f"Analysis started for {len(resume_ids)} candidates",
"resume_ids": resume_ids
"resume_ids": resume_ids,
}
@router.get("/report/{resume_id}")
async def get_interview_report(
resume_id: int,
resume_repo: ResumeRepository = Depends(ResumeRepository)
resume_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
):
"""
Получить готовый отчет анализа интервью
Если отчет еще не готов - запускает анализ
"""
resume = await resume_repo.get_by_id(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, есть ли уже готовый отчет в notes
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
return {
@ -168,39 +165,45 @@ async def get_interview_report(
"candidate_name": resume.applicant_name,
"status": "completed",
"report_summary": resume.notes,
"message": "Report available"
"message": "Report available",
}
# Если отчета нет - запускаем анализ
task = generate_interview_report.delay(resume_id)
return {
"resume_id": resume_id,
"candidate_name": resume.applicant_name,
"status": "in_progress",
"task_id": task.id,
"message": "Analysis started, check back later"
"message": "Analysis started, check back later",
}
@router.get("/statistics/{vacancy_id}")
async def get_analysis_statistics(
vacancy_id: int,
resume_repo: ResumeRepository = Depends(ResumeRepository)
vacancy_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
):
"""
Получить статистику анализа кандидатов по вакансии
"""
resumes = await resume_repo.get_by_vacancy_id(vacancy_id)
total_candidates = len(resumes)
interviewed = len([r for r in resumes if r.status == "interviewed"])
with_reports = len([r for r in resumes if r.notes and "ОЦЕНКА КАНДИДАТА" in r.notes])
with_reports = len(
[r for r in resumes if r.notes and "ОЦЕНКА КАНДИДАТА" in r.notes]
)
# Подсчитываем рекомендации из notes (упрощенно)
recommendations = {"strongly_recommend": 0, "recommend": 0, "consider": 0, "reject": 0}
recommendations = {
"strongly_recommend": 0,
"recommend": 0,
"consider": 0,
"reject": 0,
}
for resume in resumes:
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
notes = resume.notes.lower()
@ -212,7 +215,7 @@ async def get_analysis_statistics(
recommendations["consider"] += 1
elif "reject" in notes:
recommendations["reject"] += 1
return {
"vacancy_id": vacancy_id,
"statistics": {
@ -220,6 +223,8 @@ async def get_analysis_statistics(
"interviewed_candidates": interviewed,
"analyzed_candidates": with_reports,
"recommendations": recommendations,
"analysis_completion": round((with_reports / max(interviewed, 1)) * 100, 1) if interviewed > 0 else 0
}
}
"analysis_completion": round((with_reports / max(interviewed, 1)) * 100, 1)
if interviewed > 0
else 0,
},
}

View File

@ -1,34 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.session_middleware import get_current_session
from app.models.interview import InterviewValidationResponse, LiveKitTokenResponse
from app.models.session import Session
from app.models.interview import InterviewValidationResponse, LiveKitTokenResponse, InterviewStatus
from app.services.interview_service import InterviewRoomService
router = APIRouter(prefix="/interview", tags=["interview"])
@router.get("/{resume_id}/validate-interview", response_model=InterviewValidationResponse)
@router.get(
"/{resume_id}/validate-interview", response_model=InterviewValidationResponse
)
async def validate_interview(
request: Request,
resume_id: int,
current_session: Session = Depends(get_current_session),
interview_service: InterviewRoomService = Depends(InterviewRoomService)
interview_service: InterviewRoomService = Depends(InterviewRoomService),
):
"""Валидация резюме для проведения собеседования"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
# Проверяем валидность резюме для собеседования
validation_result = await interview_service.validate_resume_for_interview(resume_id)
# Если резюме не найдено, возвращаем 404
if "not found" in validation_result.message.lower():
raise HTTPException(status_code=404, detail=validation_result.message)
# Если резюме не готово, возвращаем 400
if not validation_result.can_interview and "not ready" in validation_result.message.lower():
if (
not validation_result.can_interview
and "not ready" in validation_result.message.lower()
):
raise HTTPException(status_code=400, detail=validation_result.message)
return validation_result
@ -37,21 +43,21 @@ async def get_interview_token(
request: Request,
resume_id: int,
current_session: Session = Depends(get_current_session),
interview_service: InterviewRoomService = Depends(InterviewRoomService)
interview_service: InterviewRoomService = Depends(InterviewRoomService),
):
"""Получение токена для LiveKit собеседования"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
# Получаем токен для LiveKit
token_response = await interview_service.get_livekit_token(resume_id)
if not token_response:
raise HTTPException(
status_code=400,
detail="Cannot create interview session. Check if resume is ready for interview."
status_code=400,
detail="Cannot create interview session. Check if resume is ready for interview.",
)
return token_response
@ -60,25 +66,24 @@ async def end_interview(
request: Request,
resume_id: int,
current_session: Session = Depends(get_current_session),
interview_service: InterviewRoomService = Depends(InterviewRoomService)
interview_service: InterviewRoomService = Depends(InterviewRoomService),
):
"""Завершение собеседования"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
# Получаем активную сессию собеседования
interview_session = await interview_service.get_interview_session(resume_id)
if not interview_session:
raise HTTPException(status_code=404, detail="No active interview session found")
# Завершаем сессию
success = await interview_service.update_session_status(
interview_session.id,
"completed"
interview_session.id, "completed"
)
if not success:
raise HTTPException(status_code=500, detail="Failed to end interview session")
return {"message": "Interview session ended successfully"}
return {"message": "Interview session ended successfully"}

View File

@ -1,12 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, Request
from typing import List, Optional
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
)
from app.core.session_middleware import get_current_session
from app.models.resume import ResumeCreate, ResumeUpdate, ResumeRead, ResumeStatus
from app.models.resume import ResumeCreate, ResumeRead, ResumeStatus, ResumeUpdate
from app.models.session import Session
from app.services.resume_service import ResumeService
from app.services.file_service import FileService
from celery_worker.tasks import parse_resume_task
from app.services.resume_service import ResumeService
from celery_worker.celery_app import celery_app
from celery_worker.tasks import parse_resume_task
router = APIRouter(prefix="/resumes", tags=["resumes"])
@ -17,71 +26,77 @@ async def create_resume(
vacancy_id: int = Form(...),
applicant_name: str = Form(...),
applicant_email: str = Form(...),
applicant_phone: Optional[str] = Form(None),
cover_letter: Optional[str] = Form(None),
applicant_phone: str | None = Form(None),
cover_letter: str | None = Form(None),
resume_file: UploadFile = File(...),
current_session: Session = Depends(get_current_session),
resume_service: ResumeService = Depends(ResumeService)
resume_service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
file_service = FileService()
upload_result = await file_service.upload_resume_file(resume_file)
if not upload_result:
raise HTTPException(status_code=400, detail="Failed to upload resume file")
resume_file_url, local_file_path = upload_result
resume_data = ResumeCreate(
vacancy_id=vacancy_id,
applicant_name=applicant_name,
applicant_email=applicant_email,
applicant_phone=applicant_phone,
resume_file_url=resume_file_url,
cover_letter=cover_letter
cover_letter=cover_letter,
)
# Создаем резюме в БД
created_resume = await resume_service.create_resume_with_session(resume_data, current_session.id)
created_resume = await resume_service.create_resume_with_session(
resume_data, current_session.id
)
# Запускаем асинхронную задачу парсинга резюме
try:
# Запускаем Celery task для парсинга с локальным файлом
task_result = parse_resume_task.delay(str(created_resume.id), local_file_path)
# Добавляем task_id в ответ для отслеживания статуса
response_data = created_resume.model_dump()
response_data["parsing_task_id"] = task_result.id
response_data["parsing_status"] = "started"
return response_data
except Exception as e:
# Если не удалось запустить парсинг, оставляем резюме в статусе PENDING
print(f"Failed to start parsing task for resume {created_resume.id}: {str(e)}")
return created_resume
@router.get("/", response_model=List[ResumeRead])
@router.get("/", response_model=list[ResumeRead])
async def get_resumes(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
vacancy_id: Optional[int] = Query(None),
status: Optional[ResumeStatus] = Query(None),
vacancy_id: int | None = Query(None),
status: ResumeStatus | None = Query(None),
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
# Получаем только резюме текущего пользователя
if vacancy_id:
return await service.get_resumes_by_vacancy_and_session(vacancy_id, current_session.id)
return await service.get_resumes_by_session(current_session.id, skip=skip, limit=limit)
return await service.get_resumes_by_vacancy_and_session(
vacancy_id, current_session.id
)
return await service.get_resumes_by_session(
current_session.id, skip=skip, limit=limit
)
@router.get("/{resume_id}", response_model=ResumeRead)
@ -89,19 +104,19 @@ async def get_resume(
request: Request,
resume_id: int,
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
resume = await service.get_resume(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, что резюме принадлежит текущей сессии
if resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
return resume
@ -111,19 +126,19 @@ async def update_resume(
resume_id: int,
resume: ResumeUpdate,
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
existing_resume = await service.get_resume(resume_id)
if not existing_resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, что резюме принадлежит текущей сессии
if existing_resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
updated_resume = await service.update_resume(resume_id, resume)
return updated_resume
@ -134,19 +149,19 @@ async def update_resume_status(
resume_id: int,
status: ResumeStatus,
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
existing_resume = await service.get_resume(resume_id)
if not existing_resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, что резюме принадлежит текущей сессии
if existing_resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
updated_resume = await service.update_resume_status(resume_id, status)
return updated_resume
@ -157,28 +172,31 @@ async def upload_interview_report(
resume_id: int,
report_file: UploadFile = File(...),
current_session: Session = Depends(get_current_session),
resume_service: ResumeService = Depends(ResumeService)
resume_service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
file_service = FileService()
existing_resume = await resume_service.get_resume(resume_id)
if not existing_resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, что резюме принадлежит текущей сессии
if existing_resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
report_url = await file_service.upload_interview_report(report_file)
if not report_url:
raise HTTPException(status_code=400, detail="Failed to upload interview report")
updated_resume = await resume_service.add_interview_report(resume_id, report_url)
return {"message": "Interview report uploaded successfully", "report_url": report_url}
return {
"message": "Interview report uploaded successfully",
"report_url": report_url,
}
@router.get("/{resume_id}/parsing-status")
@ -187,62 +205,65 @@ async def get_parsing_status(
resume_id: int,
task_id: str = Query(..., description="Task ID from resume upload response"),
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
"""Получить статус парсинга резюме по task_id"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
# Проверяем доступ к резюме
resume = await service.get_resume(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
if resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
# Получаем статус задачи из Celery
try:
task_result = celery_app.AsyncResult(task_id)
response = {
"task_id": task_id,
"task_state": task_result.state,
"resume_status": resume.status,
}
if task_result.state == 'PENDING':
response.update({
"status": "В очереди на обработку",
"progress": 0
})
elif task_result.state == 'PROGRESS':
response.update({
"status": task_result.info.get('status', 'Обрабатывается'),
"progress": task_result.info.get('progress', 0)
})
elif task_result.state == 'SUCCESS':
response.update({
"status": "Завершено успешно",
"progress": 100,
"result": task_result.info
})
elif task_result.state == 'FAILURE':
response.update({
"status": f"Ошибка: {str(task_result.info)}",
"progress": 0,
"error": str(task_result.info)
})
if task_result.state == "PENDING":
response.update({"status": "В очереди на обработку", "progress": 0})
elif task_result.state == "PROGRESS":
response.update(
{
"status": task_result.info.get("status", "Обрабатывается"),
"progress": task_result.info.get("progress", 0),
}
)
elif task_result.state == "SUCCESS":
response.update(
{
"status": "Завершено успешно",
"progress": 100,
"result": task_result.info,
}
)
elif task_result.state == "FAILURE":
response.update(
{
"status": f"Ошибка: {str(task_result.info)}",
"progress": 0,
"error": str(task_result.info),
}
)
return response
except Exception as e:
return {
"task_id": task_id,
"task_state": "UNKNOWN",
"resume_status": resume.status,
"error": f"Failed to get task status: {str(e)}"
"error": f"Failed to get task status: {str(e)}",
}
@ -251,18 +272,18 @@ async def delete_resume(
request: Request,
resume_id: int,
current_session: Session = Depends(get_current_session),
service: ResumeService = Depends(ResumeService)
service: ResumeService = Depends(ResumeService),
):
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
existing_resume = await service.get_resume(resume_id)
if not existing_resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Проверяем, что резюме принадлежит текущей сессии
if existing_resume.session_id != current_session.id:
raise HTTPException(status_code=403, detail="Access denied")
success = await service.delete_resume(resume_id)
return {"message": "Resume deleted successfully"}
return {"message": "Resume deleted successfully"}

View File

@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import JSONResponse
from app.core.session_middleware import get_current_session
from app.repositories.session_repository import SessionRepository
from app.models.session import Session, SessionRead
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from app.core.session_middleware import get_current_session
from app.models.session import Session, SessionRead
from app.repositories.session_repository import SessionRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sessions", tags=["Sessions"])
@ -13,13 +13,12 @@ router = APIRouter(prefix="/sessions", tags=["Sessions"])
@router.get("/current", response_model=SessionRead)
async def get_current_session_info(
request: Request,
current_session: Session = Depends(get_current_session)
request: Request, current_session: Session = Depends(get_current_session)
):
"""Получить информацию о текущей сессии"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
return SessionRead(
id=current_session.id,
session_id=current_session.session_id,
@ -29,7 +28,7 @@ async def get_current_session_info(
expires_at=current_session.expires_at,
last_activity=current_session.last_activity,
created_at=current_session.created_at,
updated_at=current_session.updated_at
updated_at=current_session.updated_at,
)
@ -37,22 +36,22 @@ async def get_current_session_info(
async def refresh_session(
request: Request,
current_session: Session = Depends(get_current_session),
session_repo: SessionRepository = Depends(SessionRepository)
session_repo: SessionRepository = Depends(SessionRepository),
):
"""Продлить сессию на 30 дней"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
current_session.extend_session(days=30)
# Обновляем через репозиторий
await session_repo.update_last_activity(current_session.session_id)
logger.info(f"Extended session {current_session.session_id}")
return {
"message": "Session extended successfully",
"expires_at": current_session.expires_at,
"session_id": current_session.session_id
"session_id": current_session.session_id,
}
@ -60,13 +59,13 @@ async def refresh_session(
async def logout(
request: Request,
current_session: Session = Depends(get_current_session),
session_repo: SessionRepository = Depends(SessionRepository)
session_repo: SessionRepository = Depends(SessionRepository),
):
"""Завершить текущую сессию"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
deactivated = await session_repo.deactivate_session(current_session.session_id)
if deactivated:
logger.info(f"Deactivated session {current_session.session_id}")
response = JSONResponse(content={"message": "Logged out successfully"})
@ -82,5 +81,5 @@ async def session_health_check():
return {
"status": "healthy",
"service": "session_management",
"message": "Session management is working properly"
}
"message": "Session management is working properly",
}

View File

@ -1,29 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from app.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate
from app.services.vacancy_service import VacancyService
from app.models.vacancy import VacancyCreate, VacancyUpdate, VacancyRead
router = APIRouter(prefix="/vacancies", tags=["vacancies"])
@router.post("/", response_model=VacancyRead)
async def create_vacancy(
vacancy: VacancyCreate,
vacancy_service: VacancyService = Depends(VacancyService)
vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService)
):
return await vacancy_service.create_vacancy(vacancy)
@router.get("/", response_model=List[VacancyRead])
@router.get("/", response_model=list[VacancyRead])
async def get_vacancies(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
active_only: bool = Query(False),
title: Optional[str] = Query(None),
company_name: Optional[str] = Query(None),
area_name: Optional[str] = Query(None),
vacancy_service: VacancyService = Depends(VacancyService)
title: str | None = Query(None),
company_name: str | None = Query(None),
area_name: str | None = Query(None),
vacancy_service: VacancyService = Depends(VacancyService),
):
if any([title, company_name, area_name]):
return await vacancy_service.search_vacancies(
@ -31,19 +29,18 @@ async def get_vacancies(
company_name=company_name,
area_name=area_name,
skip=skip,
limit=limit
limit=limit,
)
if active_only:
return await vacancy_service.get_active_vacancies(skip=skip, limit=limit)
return await vacancy_service.get_all_vacancies(skip=skip, limit=limit)
@router.get("/{vacancy_id}", response_model=VacancyRead)
async def get_vacancy(
vacancy_id: int,
vacancy_service: VacancyService = Depends(VacancyService)
vacancy_id: int, vacancy_service: VacancyService = Depends(VacancyService)
):
vacancy = await vacancy_service.get_vacancy(vacancy_id)
if not vacancy:
@ -55,7 +52,7 @@ async def get_vacancy(
async def update_vacancy(
vacancy_id: int,
vacancy: VacancyUpdate,
vacancy_service: VacancyService = Depends(VacancyService)
vacancy_service: VacancyService = Depends(VacancyService),
):
updated_vacancy = await vacancy_service.update_vacancy(vacancy_id, vacancy)
if not updated_vacancy:
@ -65,8 +62,7 @@ async def update_vacancy(
@router.delete("/{vacancy_id}")
async def delete_vacancy(
vacancy_id: int,
vacancy_service: VacancyService = Depends(VacancyService)
vacancy_id: int, vacancy_service: VacancyService = Depends(VacancyService)
):
success = await vacancy_service.delete_vacancy(vacancy_id)
if not success:
@ -76,10 +72,9 @@ async def delete_vacancy(
@router.patch("/{vacancy_id}/archive", response_model=VacancyRead)
async def archive_vacancy(
vacancy_id: int,
vacancy_service: VacancyService = Depends(VacancyService)
vacancy_id: int, vacancy_service: VacancyService = Depends(VacancyService)
):
archived_vacancy = await vacancy_service.archive_vacancy(vacancy_id)
if not archived_vacancy:
raise HTTPException(status_code=404, detail="Vacancy not found")
return archived_vacancy
return archived_vacancy

View File

@ -1,5 +1,5 @@
from .vacancy_service import VacancyService
from .resume_service import ResumeService
from .file_service import FileService
from .resume_service import ResumeService
from .vacancy_service import VacancyService
__all__ = ["VacancyService", "ResumeService", "FileService"]
__all__ = ["VacancyService", "ResumeService", "FileService"]

View File

@ -1,10 +1,11 @@
from typing import Dict, List, Annotated
from typing import Annotated
from fastapi import Depends
from app.repositories.interview_repository import InterviewRepository
from app.repositories.resume_repository import ResumeRepository
from app.services.interview_service import InterviewRoomService
from app.services.interview_finalization_service import InterviewFinalizationService
from app.services.interview_service import InterviewRoomService
class AdminService:
@ -12,8 +13,12 @@ class AdminService:
self,
interview_repo: Annotated[InterviewRepository, Depends(InterviewRepository)],
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)],
interview_service: Annotated[InterviewRoomService, Depends(InterviewRoomService)],
finalization_service: Annotated[InterviewFinalizationService, Depends(InterviewFinalizationService)]
interview_service: Annotated[
InterviewRoomService, Depends(InterviewRoomService)
],
finalization_service: Annotated[
InterviewFinalizationService, Depends(InterviewFinalizationService)
],
):
self.interview_repo = interview_repo
self.resume_repo = resume_repo
@ -23,10 +28,11 @@ class AdminService:
async def get_active_interview_processes(self):
"""Получить список активных AI процессов"""
active_sessions = await self.interview_service.get_active_agent_processes()
import psutil
processes_info = []
for session in active_sessions:
process_info = {
"session_id": session.id,
@ -34,66 +40,81 @@ class AdminService:
"room_name": session.room_name,
"pid": session.ai_agent_pid,
"status": session.ai_agent_status,
"started_at": session.started_at.isoformat() if session.started_at else None,
"started_at": session.started_at.isoformat()
if session.started_at
else None,
"is_running": False,
"memory_mb": 0,
"cpu_percent": 0
"cpu_percent": 0,
}
if session.ai_agent_pid:
try:
process = psutil.Process(session.ai_agent_pid)
if process.is_running():
process_info["is_running"] = True
process_info["memory_mb"] = round(process.memory_info().rss / 1024 / 1024, 1)
process_info["cpu_percent"] = round(process.cpu_percent(interval=0.1), 1)
process_info["memory_mb"] = round(
process.memory_info().rss / 1024 / 1024, 1
)
process_info["cpu_percent"] = round(
process.cpu_percent(interval=0.1), 1
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
processes_info.append(process_info)
return {
"active_processes": len([p for p in processes_info if p["is_running"]]),
"total_sessions": len(processes_info),
"processes": processes_info
"processes": processes_info,
}
async def stop_interview_process(self, session_id: int):
"""Остановить AI процесс интервью"""
success = await self.interview_service.stop_agent_process(session_id)
return {
"success": success,
"message": f"Process for session {session_id} {'stopped' if success else 'failed to stop'}"
"message": f"Process for session {session_id} {'stopped' if success else 'failed to stop'}",
}
async def cleanup_dead_processes(self):
"""Очистить информацию о мертвых процессах"""
cleaned_count = await self.finalization_service.cleanup_dead_processes()
return {
"cleaned_processes": cleaned_count,
"message": f"Cleaned up {cleaned_count} dead processes"
"message": f"Cleaned up {cleaned_count} dead processes",
}
async def get_analytics_dashboard(self) -> Dict:
async def get_analytics_dashboard(self) -> dict:
"""Основная аналитическая панель"""
all_resumes = await self.resume_repo.get_all()
status_stats = {}
for resume in all_resumes:
status = resume.status.value if hasattr(resume.status, 'value') else str(resume.status)
status = (
resume.status.value
if hasattr(resume.status, "value")
else str(resume.status)
)
status_stats[status] = status_stats.get(status, 0) + 1
analyzed_count = 0
recommendation_stats = {"strongly_recommend": 0, "recommend": 0, "consider": 0, "reject": 0}
recommendation_stats = {
"strongly_recommend": 0,
"recommend": 0,
"consider": 0,
"reject": 0,
}
for resume in all_resumes:
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
analyzed_count += 1
notes = resume.notes.lower()
if "strongly_recommend" in notes:
recommendation_stats["strongly_recommend"] += 1
elif "recommend" in notes and "strongly_recommend" not in notes:
@ -102,156 +123,196 @@ class AdminService:
recommendation_stats["consider"] += 1
elif "reject" in notes:
recommendation_stats["reject"] += 1
recent_resumes = sorted(all_resumes, key=lambda x: x.updated_at, reverse=True)[:10]
recent_resumes = sorted(all_resumes, key=lambda x: x.updated_at, reverse=True)[
:10
]
recent_activity = []
for resume in recent_resumes:
activity_item = {
"resume_id": resume.id,
"candidate_name": resume.applicant_name,
"status": resume.status.value if hasattr(resume.status, 'value') else str(resume.status),
"updated_at": resume.updated_at.isoformat() if resume.updated_at else None,
"has_analysis": resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes
"status": resume.status.value
if hasattr(resume.status, "value")
else str(resume.status),
"updated_at": resume.updated_at.isoformat()
if resume.updated_at
else None,
"has_analysis": resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes,
}
recent_activity.append(activity_item)
return {
"summary": {
"total_candidates": len(all_resumes),
"interviewed_candidates": status_stats.get("interviewed", 0),
"analyzed_candidates": analyzed_count,
"analysis_completion_rate": round((analyzed_count / max(len(all_resumes), 1)) * 100, 1)
"analysis_completion_rate": round(
(analyzed_count / max(len(all_resumes), 1)) * 100, 1
),
},
"status_distribution": status_stats,
"recommendation_distribution": recommendation_stats,
"recent_activity": recent_activity
"recent_activity": recent_activity,
}
async def get_vacancy_analytics(self, vacancy_id: int) -> Dict:
async def get_vacancy_analytics(self, vacancy_id: int) -> dict:
"""Аналитика кандидатов по конкретной вакансии"""
vacancy_resumes = await self.resume_repo.get_by_vacancy_id(vacancy_id)
if not vacancy_resumes:
return {
"vacancy_id": vacancy_id,
"message": "No candidates found for this vacancy",
"candidates": []
"candidates": [],
}
candidates_info = []
for resume in vacancy_resumes:
overall_score = None
recommendation = None
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
notes = resume.notes
if "Общий балл:" in notes:
try:
score_line = [line for line in notes.split('\n') if "Общий балл:" in line][0]
overall_score = int(score_line.split("Общий балл:")[1].split("/")[0].strip())
score_line = [
line for line in notes.split("\n") if "Общий балл:" in line
][0]
overall_score = int(
score_line.split("Общий балл:")[1].split("/")[0].strip()
)
except:
pass
if "Рекомендация:" in notes:
try:
rec_line = [line for line in notes.split('\n') if "Рекомендация:" in line][0]
rec_line = [
line
for line in notes.split("\n")
if "Рекомендация:" in line
][0]
recommendation = rec_line.split("Рекомендация:")[1].strip()
except:
pass
candidate_info = {
"resume_id": resume.id,
"candidate_name": resume.applicant_name,
"email": resume.applicant_email,
"status": resume.status.value if hasattr(resume.status, 'value') else str(resume.status),
"created_at": resume.created_at.isoformat() if resume.created_at else None,
"updated_at": resume.updated_at.isoformat() if resume.updated_at else None,
"status": resume.status.value
if hasattr(resume.status, "value")
else str(resume.status),
"created_at": resume.created_at.isoformat()
if resume.created_at
else None,
"updated_at": resume.updated_at.isoformat()
if resume.updated_at
else None,
"has_analysis": resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes,
"overall_score": overall_score,
"recommendation": recommendation,
"has_parsed_data": bool(resume.parsed_data),
"has_interview_plan": bool(resume.interview_plan)
"has_interview_plan": bool(resume.interview_plan),
}
candidates_info.append(candidate_info)
candidates_info.sort(key=lambda x: (x['overall_score'] or 0, x['updated_at'] or ''), reverse=True)
candidates_info.sort(
key=lambda x: (x["overall_score"] or 0, x["updated_at"] or ""), reverse=True
)
return {
"vacancy_id": vacancy_id,
"total_candidates": len(candidates_info),
"candidates": candidates_info
"candidates": candidates_info,
}
async def generate_reports_for_vacancy(self, vacancy_id: int) -> Dict:
async def generate_reports_for_vacancy(self, vacancy_id: int) -> dict:
"""Запустить генерацию отчетов для всех кандидатов вакансии"""
from celery_worker.interview_analysis_task import analyze_multiple_candidates
vacancy_resumes = await self.resume_repo.get_by_vacancy_id(vacancy_id)
interviewed_resumes = [r for r in vacancy_resumes if r.status in ["interviewed"]]
interviewed_resumes = [
r for r in vacancy_resumes if r.status in ["interviewed"]
]
if not interviewed_resumes:
return {
"error": "No interviewed candidates found for this vacancy",
"vacancy_id": vacancy_id
"vacancy_id": vacancy_id,
}
resume_ids = [r.id for r in interviewed_resumes]
task = analyze_multiple_candidates.delay(resume_ids)
return {
"vacancy_id": vacancy_id,
"task_id": task.id,
"message": f"Analysis started for {len(resume_ids)} candidates",
"resume_ids": resume_ids
"resume_ids": resume_ids,
}
async def get_system_stats(self) -> Dict:
async def get_system_stats(self) -> dict:
"""Общая статистика системы"""
import psutil
try:
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
disk = psutil.disk_usage("/")
python_processes = []
for proc in psutil.process_iter(['pid', 'name', 'memory_info', 'cpu_percent', 'cmdline']):
for proc in psutil.process_iter(
["pid", "name", "memory_info", "cpu_percent", "cmdline"]
):
try:
if proc.info['name'] and 'python' in proc.info['name'].lower():
cmdline = ' '.join(proc.info['cmdline']) if proc.info['cmdline'] else ''
if 'ai_interviewer_agent' in cmdline:
python_processes.append({
'pid': proc.info['pid'],
'memory_mb': round(proc.info['memory_info'].rss / 1024 / 1024, 1),
'cpu_percent': proc.info['cpu_percent'] or 0,
'cmdline': cmdline
})
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
if proc.info["name"] and "python" in proc.info["name"].lower():
cmdline = (
" ".join(proc.info["cmdline"])
if proc.info["cmdline"]
else ""
)
if "ai_interviewer_agent" in cmdline:
python_processes.append(
{
"pid": proc.info["pid"],
"memory_mb": round(
proc.info["memory_info"].rss / 1024 / 1024, 1
),
"cpu_percent": proc.info["cpu_percent"] or 0,
"cmdline": cmdline,
}
)
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
):
pass
return {
"system": {
"cpu_percent": cpu_percent,
"memory_percent": memory.percent,
"memory_available_gb": round(memory.available / 1024 / 1024 / 1024, 1),
"memory_available_gb": round(
memory.available / 1024 / 1024 / 1024, 1
),
"disk_percent": disk.percent,
"disk_free_gb": round(disk.free / 1024 / 1024 / 1024, 1)
"disk_free_gb": round(disk.free / 1024 / 1024 / 1024, 1),
},
"ai_agents": {
"count": len(python_processes),
"total_memory_mb": sum(p['memory_mb'] for p in python_processes),
"processes": python_processes
}
"total_memory_mb": sum(p["memory_mb"] for p in python_processes),
"processes": python_processes,
},
}
except Exception as e:
return {
"error": f"Error getting system stats: {str(e)}"
}
return {"error": f"Error getting system stats: {str(e)}"}

View File

@ -0,0 +1,297 @@
import asyncio
import json
import logging
import os
import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
import psutil
from app.core.config import settings
logger = logging.getLogger(__name__)
@dataclass
class AgentProcess:
pid: int
session_id: int | None
room_name: str | None
started_at: datetime
status: str # "idle", "active", "stopping"
class AgentManager:
"""Singleton менеджер для управления AI агентом интервьюера"""
_instance: "AgentManager | None" = None
_agent_process: AgentProcess | None = None
_lock = asyncio.Lock()
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "_initialized"):
self._initialized = True
self.livekit_url = settings.livekit_url or "ws://localhost:7880"
self.api_key = settings.livekit_api_key or "devkey"
self.api_secret = (
settings.livekit_api_secret or "devkey_secret_32chars_minimum_length"
)
async def start_agent(self) -> bool:
"""Запускает AI агента в режиме ожидания (без конкретной сессии)"""
async with self._lock:
if self._agent_process and self._is_process_alive(self._agent_process.pid):
logger.info(f"Agent already running with PID {self._agent_process.pid}")
return True
try:
# Запускаем агента в режиме worker (будет ждать подключения к комнатам)
agent_cmd = [
"uv",
"run",
"ai_interviewer_agent.py",
"start",
"--url",
self.livekit_url,
"--api-key",
self.api_key,
"--api-secret",
self.api_secret,
]
# Настройка окружения
env = os.environ.copy()
env.update(
{
"OPENAI_API_KEY": settings.openai_api_key or "",
"DEEPGRAM_API_KEY": settings.deepgram_api_key or "",
"CARTESIA_API_KEY": settings.cartesia_api_key or "",
"PYTHONIOENCODING": "utf-8",
}
)
# Запуск процесса
with open("ai_agent.log", "w") as log_file:
process = subprocess.Popen(
agent_cmd,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=".",
)
self._agent_process = AgentProcess(
pid=process.pid,
session_id=None,
room_name=None,
started_at=datetime.now(UTC),
status="idle",
)
logger.info(f"AI Agent started with PID {process.pid}")
return True
except Exception as e:
logger.error(f"Failed to start AI agent: {e}")
return False
async def stop_agent(self) -> bool:
"""Останавливает AI агента"""
async with self._lock:
if not self._agent_process:
return True
try:
if self._is_process_alive(self._agent_process.pid):
process = psutil.Process(self._agent_process.pid)
# Сначала пытаемся graceful shutdown
process.terminate()
# Ждем до 10 секунд
for _ in range(100):
if not process.is_running():
break
await asyncio.sleep(0.1)
# Если не завершился, убиваем принудительно
if process.is_running():
process.kill()
logger.info(f"AI Agent with PID {self._agent_process.pid} stopped")
self._agent_process = None
return True
except Exception as e:
logger.error(f"Error stopping AI agent: {e}")
self._agent_process = None
return False
async def assign_session(
self, session_id: int, room_name: str, interview_plan: dict
) -> bool:
"""Назначает агенту конкретную сессию интервью"""
async with self._lock:
if not self._agent_process or not self._is_process_alive(
self._agent_process.pid
):
logger.error("No active agent to assign session to")
return False
if self._agent_process.status == "active":
logger.error(
f"Agent is busy with session {self._agent_process.session_id}"
)
return False
try:
# Создаем файл метаданных для сессии
metadata_file = f"session_metadata_{session_id}.json"
with open(metadata_file, "w", encoding="utf-8") as f:
json.dump(
{
"session_id": session_id,
"room_name": room_name,
"interview_plan": interview_plan,
"command": "start_interview",
},
f,
ensure_ascii=False,
indent=2,
)
# Отправляем сигнал агенту через файл команд
command_file = "agent_commands.json"
with open(command_file, "w", encoding="utf-8") as f:
json.dump(
{
"action": "start_session",
"session_id": session_id,
"room_name": room_name,
"metadata_file": metadata_file,
"timestamp": datetime.now(UTC).isoformat(),
},
f,
ensure_ascii=False,
indent=2,
)
# Обновляем статус агента
self._agent_process.session_id = session_id
self._agent_process.room_name = room_name
self._agent_process.status = "active"
logger.info(
f"Assigned session {session_id} to agent PID {self._agent_process.pid}"
)
return True
except Exception as e:
logger.error(f"Error assigning session to agent: {e}")
return False
async def release_session(self) -> bool:
"""Освобождает агента от текущей сессии"""
async with self._lock:
if not self._agent_process:
return True
try:
# Отправляем команду завершения сессии
command_file = "agent_commands.json"
with open(command_file, "w", encoding="utf-8") as f:
json.dump(
{
"action": "end_session",
"session_id": self._agent_process.session_id,
"timestamp": datetime.now(UTC).isoformat(),
},
f,
ensure_ascii=False,
indent=2,
)
# Очищаем файлы метаданных
if self._agent_process.session_id:
try:
os.remove(
f"session_metadata_{self._agent_process.session_id}.json"
)
except FileNotFoundError:
pass
# Возвращаем агента в режим ожидания
self._agent_process.session_id = None
self._agent_process.room_name = None
self._agent_process.status = "idle"
logger.info("Released agent from current session")
return True
except Exception as e:
logger.error(f"Error releasing agent session: {e}")
return False
def get_status(self) -> dict:
"""Возвращает текущий статус агента"""
if not self._agent_process:
return {
"status": "stopped",
"pid": None,
"session_id": None,
"room_name": None,
"uptime": None,
}
is_alive = self._is_process_alive(self._agent_process.pid)
if not is_alive:
self._agent_process = None
return {
"status": "dead",
"pid": None,
"session_id": None,
"room_name": None,
"uptime": None,
}
uptime = datetime.now(UTC) - self._agent_process.started_at
return {
"status": self._agent_process.status,
"pid": self._agent_process.pid,
"session_id": self._agent_process.session_id,
"room_name": self._agent_process.room_name,
"uptime": str(uptime),
"started_at": self._agent_process.started_at.isoformat(),
}
def is_available(self) -> bool:
"""Проверяет, доступен ли агент для новой сессии"""
if not self._agent_process:
return False
if not self._is_process_alive(self._agent_process.pid):
self._agent_process = None
return False
return self._agent_process.status == "idle"
def _is_process_alive(self, pid: int) -> bool:
"""Проверяет, жив ли процесс"""
try:
process = psutil.Process(pid)
return process.is_running()
except psutil.NoSuchProcess:
return False
except Exception:
return False
# Глобальный экземпляр менеджера
agent_manager = AgentManager()

View File

@ -1,68 +1,71 @@
import asyncio
import json
import logging
from typing import Dict, Optional, List
from datetime import datetime
from livekit import api, rtc
from livekit import rtc
from rag.settings import settings
from app.services.interview_service import InterviewRoomService
logger = logging.getLogger(__name__)
class AIInterviewerService:
"""Сервис AI интервьюера, который подключается к LiveKit комнате как участник"""
def __init__(self, interview_session_id: int, resume_data: Dict):
def __init__(self, interview_session_id: int, resume_data: dict):
self.interview_session_id = interview_session_id
self.resume_data = resume_data
self.room: Optional[rtc.Room] = None
self.audio_source: Optional[rtc.AudioSource] = None
self.conversation_history: List[Dict] = []
self.room: rtc.Room | None = None
self.audio_source: rtc.AudioSource | None = None
self.conversation_history: list[dict] = []
self.current_question_index = 0
self.interview_questions = []
async def connect_to_room(self, room_name: str, token: str):
"""Подключение AI агента к LiveKit комнате"""
try:
self.room = rtc.Room()
# Настройка обработчиков событий
self.room.on("participant_connected", self.on_participant_connected)
self.room.on("track_subscribed", self.on_track_subscribed)
self.room.on("data_received", self.on_data_received)
# Подключение к комнате
await self.room.connect(settings.livekit_url, token)
logger.info(f"AI agent connected to room: {room_name}")
# Создание аудио источника для TTS
self.audio_source = rtc.AudioSource(sample_rate=16000, num_channels=1)
track = rtc.LocalAudioTrack.create_audio_track("ai_voice", self.audio_source)
track = rtc.LocalAudioTrack.create_audio_track(
"ai_voice", self.audio_source
)
# Публикация аудио трека
await self.room.local_participant.publish_track(track, rtc.TrackPublishOptions())
await self.room.local_participant.publish_track(
track, rtc.TrackPublishOptions()
)
# Генерация первого вопроса
await self.generate_interview_questions()
await self.start_interview()
except Exception as e:
logger.error(f"Error connecting to room: {str(e)}")
raise
async def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Обработка подключения пользователя"""
logger.info(f"Participant connected: {participant.identity}")
# Можем отправить приветственное сообщение
await self.send_message({
"type": "ai_speaking_start"
})
await self.send_message({"type": "ai_speaking_start"})
async def on_track_subscribed(
self,
track: rtc.Track,
self,
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant
participant: rtc.RemoteParticipant,
):
"""Обработка получения аудио трека от пользователя"""
if track.kind == rtc.TrackKind.KIND_AUDIO:
@ -70,7 +73,7 @@ class AIInterviewerService:
# Настройка обработки аудио для STT
audio_stream = rtc.AudioStream(track)
asyncio.create_task(self.process_user_audio(audio_stream))
async def on_data_received(self, data: bytes, participant: rtc.RemoteParticipant):
"""Обработка сообщений от фронтенда"""
try:
@ -78,11 +81,11 @@ class AIInterviewerService:
await self.handle_frontend_message(message)
except Exception as e:
logger.error(f"Error processing data message: {str(e)}")
async def handle_frontend_message(self, message: Dict):
async def handle_frontend_message(self, message: dict):
"""Обработка сообщений от фронтенда"""
msg_type = message.get("type")
if msg_type == "start_interview":
await self.start_interview()
elif msg_type == "end_interview":
@ -90,7 +93,7 @@ class AIInterviewerService:
elif msg_type == "user_finished_speaking":
# Пользователь закончил говорить, можем обрабатывать его ответ
pass
async def process_user_audio(self, audio_stream: rtc.AudioStream):
"""Обработка аудио от пользователя через STT"""
try:
@ -105,22 +108,22 @@ class AIInterviewerService:
except Exception as e:
logger.error(f"Error processing user audio: {str(e)}")
async def generate_interview_questions(self):
"""Генерация вопросов для интервью на основе резюме"""
try:
from rag.registry import registry
chat_model = registry.get_chat_model()
# Используем существующую логику генерации вопросов
questions_prompt = f"""
Сгенерируй 8 вопросов для голосового собеседования кандидата.
РЕЗЮМЕ КАНДИДАТА:
Имя: {self.resume_data.get('name', 'Не указано')}
Навыки: {', '.join(self.resume_data.get('skills', []))}
Опыт работы: {self.resume_data.get('total_years', 0)} лет
Образование: {self.resume_data.get('education', 'Не указано')}
Имя: {self.resume_data.get("name", "Не указано")}
Навыки: {", ".join(self.resume_data.get("skills", []))}
Опыт работы: {self.resume_data.get("total_years", 0)} лет
Образование: {self.resume_data.get("education", "Не указано")}
ВАЖНО:
1. Вопросы должны быть короткими и ясными для голосового формата
@ -131,18 +134,21 @@ class AIInterviewerService:
Верни только JSON массив строк с вопросами:
["Привет! Расскажи немного о себе", "Какой у тебя опыт в...", ...]
"""
from langchain.schema import HumanMessage, SystemMessage
messages = [
SystemMessage(content="Ты HR интервьюер. Говори естественно и дружелюбно."),
HumanMessage(content=questions_prompt)
SystemMessage(
content="Ты HR интервьюер. Говори естественно и дружелюбно."
),
HumanMessage(content=questions_prompt),
]
response = chat_model.get_llm().invoke(messages)
response_text = response.content.strip()
# Парсим JSON ответ
if response_text.startswith('[') and response_text.endswith(']'):
if response_text.startswith("[") and response_text.endswith("]"):
self.interview_questions = json.loads(response_text)
else:
# Fallback вопросы
@ -152,94 +158,102 @@ class AIInterviewerService:
"Расскажи о своем самом значимом проекте",
"Какие технологии ты используешь в работе?",
"Как ты решаешь сложные задачи?",
"Есть ли у тебя вопросы ко мне?"
"Есть ли у тебя вопросы ко мне?",
]
logger.info(f"Generated {len(self.interview_questions)} interview questions")
logger.info(
f"Generated {len(self.interview_questions)} interview questions"
)
except Exception as e:
logger.error(f"Error generating questions: {str(e)}")
# Используем базовые вопросы
self.interview_questions = [
"Привет! Расскажи о своем опыте",
"Что тебя интересует в этой позиции?",
"Есть ли у тебя вопросы?"
"Есть ли у тебя вопросы?",
]
async def start_interview(self):
"""Начало интервью"""
if not self.interview_questions:
await self.generate_interview_questions()
# Отправляем первый вопрос
await self.ask_next_question()
async def ask_next_question(self):
"""Задать следующий вопрос"""
if self.current_question_index >= len(self.interview_questions):
await self.end_interview()
return
question = self.interview_questions[self.current_question_index]
# Отправляем сообщение фронтенду
await self.send_message({
"type": "question",
"text": question,
"questionNumber": self.current_question_index + 1
})
await self.send_message(
{
"type": "question",
"text": question,
"questionNumber": self.current_question_index + 1,
}
)
# Конвертируем в речь и воспроизводим
# TODO: Реализовать TTS
# audio_data = await self.text_to_speech(question)
# await self.play_audio(audio_data)
self.current_question_index += 1
logger.info(f"Asked question {self.current_question_index}: {question}")
async def process_user_response(self, user_text: str):
"""Обработка ответа пользователя"""
# Сохраняем ответ в историю
self.conversation_history.append({
"type": "user_response",
"text": user_text,
"timestamp": datetime.utcnow().isoformat(),
"question_index": self.current_question_index - 1
})
self.conversation_history.append(
{
"type": "user_response",
"text": user_text,
"timestamp": datetime.utcnow().isoformat(),
"question_index": self.current_question_index - 1,
}
)
# Можем добавить анализ ответа через LLM
# И решить - задать уточняющий вопрос или перейти к следующему
# Пока просто переходим к следующему вопросу
await asyncio.sleep(1) # Небольшая пауза
await self.ask_next_question()
async def send_message(self, message: Dict):
async def send_message(self, message: dict):
"""Отправка сообщения фронтенду"""
if self.room:
data = json.dumps(message).encode()
await self.room.local_participant.publish_data(data)
async def play_audio(self, audio_data: bytes):
"""Воспроизведение аудио через LiveKit"""
if self.audio_source:
# TODO: Конвертировать audio_data в нужный формат и отправить
pass
async def end_interview(self):
"""Завершение интервью"""
await self.send_message({
"type": "interview_complete",
"summary": f"Interview completed with {len(self.conversation_history)} responses"
})
await self.send_message(
{
"type": "interview_complete",
"summary": f"Interview completed with {len(self.conversation_history)} responses",
}
)
# Сохраняем транскрипт в базу данных
transcript = json.dumps(self.conversation_history, ensure_ascii=False, indent=2)
# TODO: Обновить interview_session в БД с транскриптом
logger.info("Interview completed")
# Отключение от комнаты
if self.room:
await self.room.disconnect()
@ -247,31 +261,32 @@ class AIInterviewerService:
class AIInterviewerManager:
"""Менеджер для управления AI интервьюерами"""
def __init__(self):
self.active_sessions: Dict[int, AIInterviewerService] = {}
async def start_interview_session(self, interview_session_id: int, room_name: str, resume_data: Dict):
self.active_sessions: dict[int, AIInterviewerService] = {}
async def start_interview_session(
self, interview_session_id: int, room_name: str, resume_data: dict
):
"""Запуск AI интервьюера для сессии"""
try:
# Создаем токен для AI агента
from app.services.interview_service import InterviewRoomService
# Нужно создать специальный токен для AI агента
ai_interviewer = AIInterviewerService(interview_session_id, resume_data)
# TODO: Генерировать токен для AI агента
# ai_token = generate_ai_agent_token(room_name)
# await ai_interviewer.connect_to_room(room_name, ai_token)
self.active_sessions[interview_session_id] = ai_interviewer
logger.info(f"Started AI interviewer for session: {interview_session_id}")
except Exception as e:
logger.error(f"Error starting AI interviewer: {str(e)}")
raise
async def stop_interview_session(self, interview_session_id: int):
"""Остановка AI интервьюера"""
if interview_session_id in self.active_sessions:
@ -282,4 +297,4 @@ class AIInterviewerManager:
# Глобальный менеджер
ai_interviewer_manager = AIInterviewerManager()
ai_interviewer_manager = AIInterviewerManager()

View File

@ -1,7 +1,8 @@
from fastapi import UploadFile
from typing import Optional
import tempfile
import os
import tempfile
from fastapi import UploadFile
from app.core.s3 import s3_service
@ -9,29 +10,27 @@ class FileService:
def __init__(self):
self.s3_service = s3_service
async def upload_resume_file(self, file: UploadFile) -> Optional[tuple[str, str]]:
async def upload_resume_file(self, file: UploadFile) -> tuple[str, str] | None:
"""
Загружает резюме в S3 и сохраняет локальную копию для парсинга
Returns:
tuple[str, str]: (s3_url, local_file_path) или None при ошибке
"""
if not file.filename:
return None
content = await file.read()
content_type = file.content_type or "application/octet-stream"
# Загружаем в S3
s3_url = await self.s3_service.upload_file(
file_content=content,
file_name=file.filename,
content_type=content_type
file_content=content, file_name=file.filename, content_type=content_type
)
if not s3_url:
return None
# Сохраняем локальную копию для парсинга
try:
# Создаем временный файл с сохранением расширения
@ -39,43 +38,45 @@ class FileService:
file_extension = os.path.splitext(file.filename)[1]
if not file_extension:
# Пытаемся определить расширение по MIME типу
if content_type == 'application/pdf':
file_extension = '.pdf'
elif content_type in ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']:
file_extension = '.docx'
elif content_type in ['application/msword']:
file_extension = '.doc'
elif content_type == 'text/plain':
file_extension = '.txt'
if content_type == "application/pdf":
file_extension = ".pdf"
elif content_type in [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
]:
file_extension = ".docx"
elif content_type in ["application/msword"]:
file_extension = ".doc"
elif content_type == "text/plain":
file_extension = ".txt"
else:
file_extension = '.pdf' # fallback
file_extension = ".pdf" # fallback
temp_filename = f"resume_{hash(s3_url)}_{file.filename}"
local_file_path = os.path.join(temp_dir, temp_filename)
# Сохраняем содержимое файла
with open(local_file_path, 'wb') as temp_file:
with open(local_file_path, "wb") as temp_file:
temp_file.write(content)
return (s3_url, local_file_path)
except Exception as e:
print(f"Failed to save local copy: {str(e)}")
# Если не удалось сохранить локально, возвращаем только S3 URL
return (s3_url, s3_url)
async def upload_interview_report(self, file: UploadFile) -> Optional[str]:
async def upload_interview_report(self, file: UploadFile) -> str | None:
if not file.filename:
return None
content = await file.read()
content_type = file.content_type or "application/octet-stream"
return await self.s3_service.upload_file(
file_content=content,
file_name=f"interview_report_{file.filename}",
content_type=content_type
content_type=content_type,
)
async def delete_file(self, file_url: str) -> bool:
return await self.s3_service.delete_file(file_url)
return await self.s3_service.delete_file(file_url)

View File

@ -1,111 +1,132 @@
from typing import Optional, Annotated
from datetime import datetime
import logging
from datetime import datetime
from typing import Annotated
from fastapi import Depends
from app.models.resume import ResumeStatus
from app.repositories.interview_repository import InterviewRepository
from app.repositories.resume_repository import ResumeRepository
from app.models.resume import ResumeStatus
logger = logging.getLogger("interview-finalization")
class InterviewFinalizationService:
"""Сервис для завершения интервью и запуска анализа"""
def __init__(
self,
interview_repo: Annotated[InterviewRepository, Depends(InterviewRepository)],
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)]
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)],
):
self.interview_repo = interview_repo
self.resume_repo = resume_repo
async def finalize_interview(
self,
room_name: str,
dialogue_history: list,
interview_metrics: dict = None
) -> Optional[dict]:
self, room_name: str, dialogue_history: list, interview_metrics: dict = None
) -> dict | None:
"""
Завершает интервью и запускает анализ
Args:
room_name: Имя комнаты LiveKit
dialogue_history: История диалога
interview_metrics: Метрики интервью (количество вопросов, время и т.д.)
Returns:
dict с информацией о завершенном интервью или None если ошибка
"""
try:
logger.info(f"[FINALIZE] Starting finalization for room: {room_name}")
# 1. Находим сессию интервью
interview_session = await self.interview_repo.get_by_room_name(room_name)
if not interview_session:
logger.error(f"[FINALIZE] Interview session not found for room: {room_name}")
logger.error(
f"[FINALIZE] Interview session not found for room: {room_name}"
)
return None
# 2. Обновляем статус сессии интервью на "completed"
success = await self.interview_repo.update_status(
interview_session.id,
"completed",
datetime.utcnow()
interview_session.id, "completed", datetime.utcnow()
)
if not success:
logger.error(f"[FINALIZE] Failed to update session status for {interview_session.id}")
logger.error(
f"[FINALIZE] Failed to update session status for {interview_session.id}"
)
return None
resume_id = interview_session.resume_id
logger.info(f"[FINALIZE] Interview session {interview_session.id} marked as completed")
logger.info(
f"[FINALIZE] Interview session {interview_session.id} marked as completed"
)
# 3. Обновляем статус резюме на "INTERVIEWED"
resume = await self.resume_repo.get(resume_id)
if resume:
await self.resume_repo.update(resume_id, {
"status": ResumeStatus.INTERVIEWED,
"updated_at": datetime.utcnow()
})
logger.info(f"[FINALIZE] Resume {resume_id} status updated to INTERVIEWED")
await self.resume_repo.update(
resume_id,
{
"status": ResumeStatus.INTERVIEWED,
"updated_at": datetime.utcnow(),
},
)
logger.info(
f"[FINALIZE] Resume {resume_id} status updated to INTERVIEWED"
)
else:
logger.warning(f"[FINALIZE] Resume {resume_id} not found")
# 4. Сохраняем финальную историю диалога
await self.interview_repo.update_dialogue_history(room_name, dialogue_history)
logger.info(f"[FINALIZE] Saved final dialogue ({len(dialogue_history)} messages)")
await self.interview_repo.update_dialogue_history(
room_name, dialogue_history
)
logger.info(
f"[FINALIZE] Saved final dialogue ({len(dialogue_history)} messages)"
)
# 5. Обновляем статус AI агента
await self.interview_repo.update_ai_agent_status(interview_session.id, None, "stopped")
await self.interview_repo.update_ai_agent_status(
interview_session.id, None, "stopped"
)
# 6. Запускаем анализ интервью через Celery
analysis_task = await self._start_interview_analysis(resume_id)
# 7. Собираем итоговые метрики
finalization_result = {
"session_id": interview_session.id,
"resume_id": resume_id,
"room_name": room_name,
"total_messages": len(dialogue_history),
"analysis_task_id": analysis_task.get('task_id') if analysis_task else None,
"analysis_task_id": analysis_task.get("task_id")
if analysis_task
else None,
"completed_at": datetime.utcnow().isoformat(),
"metrics": interview_metrics or {}
"metrics": interview_metrics or {},
}
logger.info(f"[FINALIZE] Interview successfully finalized: {finalization_result}")
logger.info(
f"[FINALIZE] Interview successfully finalized: {finalization_result}"
)
return finalization_result
except Exception as e:
logger.error(f"[FINALIZE] Error finalizing interview for room {room_name}: {str(e)}")
logger.error(
f"[FINALIZE] Error finalizing interview for room {room_name}: {str(e)}"
)
return None
async def _start_interview_analysis(self, resume_id: int):
"""Запускает анализ интервью через Celery"""
# try:
logger.info(f"[FINALIZE] Attempting to start analysis task for resume_id: {resume_id}")
# Импортируем задачу
logger.info(
f"[FINALIZE] Attempting to start analysis task for resume_id: {resume_id}"
)
# Импортируем задачу
# from celery_worker.interview_analysis_task import generate_interview_report
# logger.debug(f"[FINALIZE] Successfully imported generate_interview_report task")
#
@ -126,54 +147,70 @@ class InterviewFinalizationService:
# except Exception as e:
# logger.error(f"[FINALIZE] Failed to start analysis task for resume {resume_id}: {str(e)}")
# logger.debug(f"[FINALIZE] Exception type: {type(e).__name__}")
# Fallback: попытка запуска анализа через HTTP API для любых других ошибок
# Fallback: попытка запуска анализа через HTTP API для любых других ошибок
return await self._start_analysis_via_http(resume_id)
async def _start_analysis_via_http(self, resume_id: int):
"""Fallback: запуск анализа через HTTP API (когда Celery недоступен из AI агента)"""
try:
import httpx
url = f"http://localhost:8000/api/v1/analysis/interview-report/{resume_id}"
logger.info(f"[FINALIZE] Attempting HTTP fallback to URL: {url}")
# Попробуем отправить HTTP запрос на локальный API для запуска анализа
async with httpx.AsyncClient() as client:
response = await client.post(url, timeout=5.0)
if response.status_code == 200:
result = response.json()
logger.info(f"[FINALIZE] Analysis started via HTTP API for resume_id: {resume_id}, task_id: {result.get('task_id', 'unknown')}")
logger.info(
f"[FINALIZE] Analysis started via HTTP API for resume_id: {resume_id}, task_id: {result.get('task_id', 'unknown')}"
)
return result
else:
logger.error(f"[FINALIZE] HTTP API returned {response.status_code} for resume_id: {resume_id}")
logger.error(
f"[FINALIZE] HTTP API returned {response.status_code} for resume_id: {resume_id}"
)
logger.debug(f"[FINALIZE] Response body: {response.text[:200]}")
return None
except Exception as e:
logger.error(f"[FINALIZE] HTTP fallback failed for resume {resume_id}: {str(e)}")
logger.error(
f"[FINALIZE] HTTP fallback failed for resume {resume_id}: {str(e)}"
)
return None
async def save_dialogue_to_session(self, room_name: str, dialogue_history: list) -> bool:
async def save_dialogue_to_session(
self, room_name: str, dialogue_history: list
) -> bool:
"""Сохраняет диалог в сессию (для промежуточных сохранений)"""
try:
success = await self.interview_repo.update_dialogue_history(room_name, dialogue_history)
success = await self.interview_repo.update_dialogue_history(
room_name, dialogue_history
)
if success:
logger.info(f"[DIALOGUE] Saved {len(dialogue_history)} messages for room: {room_name}")
logger.info(
f"[DIALOGUE] Saved {len(dialogue_history)} messages for room: {room_name}"
)
return success
except Exception as e:
logger.error(f"[DIALOGUE] Error saving dialogue for room {room_name}: {str(e)}")
logger.error(
f"[DIALOGUE] Error saving dialogue for room {room_name}: {str(e)}"
)
return False
async def cleanup_dead_processes(self) -> int:
"""Очищает информацию о мертвых AI процессах"""
try:
import psutil
active_sessions = await self.interview_repo.get_sessions_with_running_agents()
active_sessions = (
await self.interview_repo.get_sessions_with_running_agents()
)
cleaned_count = 0
for session in active_sessions:
if session.ai_agent_pid:
try:
@ -188,10 +225,10 @@ class InterviewFinalizationService:
session.id, None, "stopped"
)
cleaned_count += 1
logger.info(f"[CLEANUP] Cleaned up {cleaned_count} dead processes")
return cleaned_count
except Exception as e:
logger.error(f"[CLEANUP] Error cleaning up processes: {str(e)}")
return 0
return 0

View File

@ -1,21 +1,19 @@
import os
import time
import uuid
import json
import subprocess
from typing import Optional, Annotated
from datetime import datetime
from livekit.api import AccessToken, VideoGrants
from typing import Annotated
from fastapi import Depends
from livekit.api import AccessToken, VideoGrants
from app.models.interview import (
InterviewSession,
InterviewValidationResponse,
LiveKitTokenResponse,
)
from app.models.resume import ResumeStatus
from app.repositories.interview_repository import InterviewRepository
from app.repositories.resume_repository import ResumeRepository
from app.models.interview import (
InterviewSession,
InterviewSessionCreate,
InterviewValidationResponse,
LiveKitTokenResponse
)
from app.models.resume import Resume, ResumeStatus
from app.services.agent_manager import agent_manager
from rag.settings import settings
@ -23,224 +21,189 @@ class InterviewRoomService:
def __init__(
self,
interview_repo: Annotated[InterviewRepository, Depends(InterviewRepository)],
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)]
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)],
):
self.interview_repo = interview_repo
self.resume_repo = resume_repo
self.livekit_url = settings.livekit_url or "ws://localhost:7880"
self.api_key = settings.livekit_api_key or "devkey"
self.api_secret = settings.livekit_api_secret or "secret"
async def validate_resume_for_interview(self, resume_id: int) -> InterviewValidationResponse:
async def validate_resume_for_interview(
self, resume_id: int
) -> InterviewValidationResponse:
"""Проверяет, можно ли проводить собеседование для данного резюме"""
try:
# Получаем резюме
resume = await self.resume_repo.get(resume_id)
if not resume:
return InterviewValidationResponse(
can_interview=False,
message="Resume not found"
can_interview=False, message="Resume not found"
)
# Проверяем статус резюме
if resume.status != ResumeStatus.PARSED:
return InterviewValidationResponse(
can_interview=False,
message=f"Resume is not ready for interview. Current status: {resume.status}"
message=f"Resume is not ready for interview. Current status: {resume.status}",
)
# Проверяем активную сессию только для информации (не блокируем)
active_session = await self.interview_repo.get_active_session_by_resume_id(resume_id)
active_session = await self.interview_repo.get_active_session_by_resume_id(
resume_id
)
message = "Resume is ready for interview"
if active_session:
message = "Resume has an active interview session"
return InterviewValidationResponse(
can_interview=True,
message=message
)
return InterviewValidationResponse(can_interview=True, message=message)
except Exception as e:
return InterviewValidationResponse(
can_interview=False,
message=f"Error validating resume: {str(e)}"
can_interview=False, message=f"Error validating resume: {str(e)}"
)
async def create_interview_session(self, resume_id: int) -> Optional[InterviewSession]:
async def create_interview_session(self, resume_id: int) -> InterviewSession | None:
"""Создает новую сессию собеседования"""
try:
# Генерируем уникальное имя комнаты с UUID
unique_id = str(uuid.uuid4())[:8]
timestamp = int(time.time())
room_name = f"interview_{resume_id}_{timestamp}_{unique_id}"
# Создаем сессию в БД через репозиторий
interview_session = await self.interview_repo.create_interview_session(resume_id, room_name)
interview_session = await self.interview_repo.create_interview_session(
resume_id, room_name
)
return interview_session
except Exception as e:
print(f"Error creating interview session: {str(e)}")
return None
def generate_access_token(self, room_name: str, participant_name: str) -> str:
"""Генерирует JWT токен для LiveKit"""
try:
at = AccessToken(self.api_key, self.api_secret)
# Исправляем использование grants
grants = VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True
room_join=True, room=room_name, can_publish=True, can_subscribe=True
)
at.with_grants(grants).with_identity(participant_name)
return at.to_jwt()
except Exception as e:
print(f"Error generating LiveKit token: {str(e)}")
raise
async def get_livekit_token(self, resume_id: int) -> Optional[LiveKitTokenResponse]:
async def get_livekit_token(self, resume_id: int) -> LiveKitTokenResponse | None:
"""Создает сессию собеседования и возвращает токен для LiveKit"""
try:
# Проверяем доступность агента
if not agent_manager.is_available():
print("[ERROR] AI Agent is not available for new interview")
return None
# Валидируем резюме
validation = await self.validate_resume_for_interview(resume_id)
if not validation.can_interview:
return None
# Проверяем, есть ли уже созданная сессия для этого резюме
existing_session = await self.interview_repo.get_active_session_by_resume_id(resume_id)
existing_session = (
await self.interview_repo.get_active_session_by_resume_id(resume_id)
)
if existing_session:
# Используем существующую сессию
interview_session = existing_session
print(f"[DEBUG] Using existing interview session: {interview_session.id}")
print(
f"[DEBUG] Using existing interview session: {interview_session.id}"
)
else:
# Создаем новую сессию собеседования
interview_session = await self.create_interview_session(resume_id)
if not interview_session:
return None
print(f"[DEBUG] Created new interview session: {interview_session.id}")
# Генерируем токен
participant_name = f"user_{resume_id}"
token = self.generate_access_token(
interview_session.room_name,
participant_name
interview_session.room_name, participant_name
)
# Получаем готовый план интервью для AI агента
interview_plan = await self.get_resume_data_for_interview(resume_id)
# Обновляем статус сессии на ACTIVE
await self.interview_repo.update_session_status(interview_session.id, "active")
# Запускаем AI агента для этой сессии
await self.start_ai_interviewer(interview_session, interview_plan)
await self.interview_repo.update_session_status(
interview_session.id, "active"
)
# Назначаем сессию агенту через менеджер
success = await agent_manager.assign_session(
interview_session.id, interview_session.room_name, interview_plan
)
if not success:
print("[ERROR] Failed to assign session to AI agent")
return None
return LiveKitTokenResponse(
token=token,
room_name=interview_session.room_name,
server_url=self.livekit_url
server_url=self.livekit_url,
)
except Exception as e:
print(f"Error getting LiveKit token: {str(e)}")
return None
async def update_session_status(self, session_id: int, status: str) -> bool:
"""Обновляет статус сессии собеседования"""
return await self.interview_repo.update_session_status(session_id, status)
async def get_interview_session(self, resume_id: int) -> Optional[InterviewSession]:
async def get_interview_session(self, resume_id: int) -> InterviewSession | None:
"""Получает активную сессию собеседования для резюме"""
return await self.interview_repo.get_active_session_by_resume_id(resume_id)
async def start_ai_interviewer(self, interview_session: InterviewSession, interview_plan: dict):
"""Запускает AI интервьюера для сессии"""
async def end_interview_session(self, session_id: int) -> bool:
"""Завершает интервью-сессию и освобождает агента"""
try:
# Создаем токен для AI агента
ai_token = self.generate_access_token(
interview_session.room_name,
f"ai_interviewer_{interview_session.id}"
)
# Сохраняем метаданные во временный файл для избежания проблем с кодировкой
import tempfile
metadata_file = f"interview_metadata_{interview_session.id}.json"
with open(metadata_file, 'w', encoding='utf-8') as f:
json.dump({
"interview_plan": interview_plan,
"session_id": interview_session.id
}, f, ensure_ascii=False, indent=2)
# Запускаем AI агента в отдельном процессе
agent_cmd = [
"uv",
"run",
"ai_interviewer_agent.py",
"connect",
"--room", interview_session.room_name,
"--url", self.livekit_url,
"--api-key", self.api_key,
"--api-secret", self.api_secret,
]
# Устанавливаем переменные окружения
env = os.environ.copy()
env.update({
"INTERVIEW_METADATA_FILE": metadata_file,
"OPENAI_API_KEY": settings.openai_api_key or "",
"DEEPGRAM_API_KEY": settings.deepgram_api_key or "",
"CARTESIA_API_KEY": settings.cartesia_api_key or "",
"PYTHONIOENCODING": "utf-8",
})
# Запускаем процесс в фоне
with open(f"ai_interviewer_{interview_session.id}.log", "wb") as f_out, \
open(f"ai_interviewer_{interview_session.id}.err", "wb") as f_err:
process = subprocess.Popen(
agent_cmd,
env=env,
stdout=f_out,
stderr=f_err,
cwd="."
)
print(f"[DEBUG] Started AI interviewer process {process.pid} for session {interview_session.id}")
# Сохраняем PID процесса в БД для управления
await self.interview_repo.update_ai_agent_status(
interview_session.id,
process.pid,
"running"
)
# Освобождаем агента от текущей сессии
await agent_manager.release_session()
# Обновляем статус сессии
await self.interview_repo.update_session_status(session_id, "completed")
print(f"[DEBUG] Interview session {session_id} ended successfully")
return True
except Exception as e:
print(f"Error starting AI interviewer: {str(e)}")
# Обновляем статус на failed
await self.interview_repo.update_ai_agent_status(
interview_session.id,
None,
"failed"
)
print(f"Error ending interview session {session_id}: {str(e)}")
return False
def get_agent_status(self) -> dict:
"""Получает текущий статус AI агента"""
return agent_manager.get_status()
async def get_resume_data_for_interview(self, resume_id: int) -> dict:
"""Получает готовый план интервью из базы данных"""
try:
# Получаем резюме с готовым планом интервью
resume = await self.resume_repo.get(resume_id)
if not resume:
return self._get_fallback_interview_plan()
# Если есть готовый план интервью - используем его
if resume.interview_plan:
return resume.interview_plan
# Если плана нет, создаем базовый план на основе имеющихся данных
fallback_plan = {
"interview_structure": {
@ -250,42 +213,50 @@ class InterviewRoomService:
{
"name": "Знакомство",
"duration_minutes": 5,
"questions": ["Расскажи немного о себе", "Что тебя привлекло в этой позиции?"]
"questions": [
"Расскажи немного о себе",
"Что тебя привлекло в этой позиции?",
],
},
{
"name": "Опыт работы",
"duration_minutes": 15,
"questions": ["Расскажи о своем опыте", "Какие технологии используешь?"]
"questions": [
"Расскажи о своем опыте",
"Какие технологии используешь?",
],
},
{
"name": "Вопросы кандидата",
"duration_minutes": 10,
"questions": ["Есть ли у тебя вопросы ко мне?"]
}
]
"questions": ["Есть ли у тебя вопросы ко мне?"],
},
],
},
"focus_areas": ["experience", "technical_skills"],
"candidate_info": {
"name": resume.applicant_name,
"email": resume.applicant_email,
"phone": resume.applicant_phone
}
"phone": resume.applicant_phone,
},
}
# Добавляем parsed данные если есть
if resume.parsed_data:
fallback_plan["candidate_info"].update({
"skills": resume.parsed_data.get("skills", []),
"total_years": resume.parsed_data.get("total_years", 0),
"education": resume.parsed_data.get("education", "")
})
fallback_plan["candidate_info"].update(
{
"skills": resume.parsed_data.get("skills", []),
"total_years": resume.parsed_data.get("total_years", 0),
"education": resume.parsed_data.get("education", ""),
}
)
return fallback_plan
except Exception as e:
print(f"Error getting interview plan: {str(e)}")
return self._get_fallback_interview_plan()
def _get_fallback_interview_plan(self) -> dict:
"""Fallback план интервью если не удалось загрузить из БД"""
return {
@ -296,98 +267,114 @@ class InterviewRoomService:
{
"name": "Знакомство",
"duration_minutes": 10,
"questions": ["Расскажи о себе", "Что тебя привлекло в этой позиции?"]
"questions": [
"Расскажи о себе",
"Что тебя привлекло в этой позиции?",
],
},
{
"name": "Опыт работы",
"duration_minutes": 15,
"questions": ["Расскажи о своем опыте", "Какие технологии используешь?"]
"questions": [
"Расскажи о своем опыте",
"Какие технологии используешь?",
],
},
{
"name": "Вопросы кандидата",
"duration_minutes": 5,
"questions": ["Есть ли у тебя вопросы?"]
}
]
"questions": ["Есть ли у тебя вопросы?"],
},
],
},
"focus_areas": ["experience", "technical_skills"],
"candidate_info": {
"name": "Кандидат",
"skills": [],
"total_years": 0
}
"candidate_info": {"name": "Кандидат", "skills": [], "total_years": 0},
}
async def update_agent_process_info(self, session_id: int, pid: int = None, status: str = "not_started") -> bool:
async def update_agent_process_info(
self, session_id: int, pid: int = None, status: str = "not_started"
) -> bool:
"""Обновляет информацию о процессе AI агента"""
return await self.interview_repo.update_ai_agent_status(session_id, pid, status)
async def get_active_agent_processes(self) -> list:
"""Получает список активных AI процессов"""
return await self.interview_repo.get_sessions_with_running_agents()
async def stop_agent_process(self, session_id: int) -> bool:
"""Останавливает AI процесс для сессии"""
try:
session = await self.interview_repo.get(session_id)
if not session or not session.ai_agent_pid:
return False
import psutil
try:
# Пытаемся gracefully остановить процесс
process = psutil.Process(session.ai_agent_pid)
process.terminate()
# Ждем завершения до 5 секунд
import time
for _ in range(50):
if not process.is_running():
break
time.sleep(0.1)
# Если не завершился, принудительно убиваем
if process.is_running():
process.kill()
# Обновляем статус в БД
await self.interview_repo.update_ai_agent_status(session_id, None, "stopped")
print(f"Stopped AI agent process {session.ai_agent_pid} for session {session_id}")
await self.interview_repo.update_ai_agent_status(
session_id, None, "stopped"
)
print(
f"Stopped AI agent process {session.ai_agent_pid} for session {session_id}"
)
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Процесс уже не существует
await self.interview_repo.update_ai_agent_status(session_id, None, "stopped")
await self.interview_repo.update_ai_agent_status(
session_id, None, "stopped"
)
return True
except Exception as e:
print(f"Error stopping agent process: {str(e)}")
return False
async def cleanup_dead_processes(self) -> int:
"""Очищает информацию о мертвых процессах"""
try:
import psutil
active_sessions = await self.get_active_agent_processes()
cleaned_count = 0
for session in active_sessions:
if session.ai_agent_pid:
try:
process = psutil.Process(session.ai_agent_pid)
if not process.is_running():
await self.interview_repo.update_ai_agent_status(session.id, None, "stopped")
await self.interview_repo.update_ai_agent_status(
session.id, None, "stopped"
)
cleaned_count += 1
except psutil.NoSuchProcess:
await self.interview_repo.update_ai_agent_status(session.id, None, "stopped")
await self.interview_repo.update_ai_agent_status(
session.id, None, "stopped"
)
cleaned_count += 1
print(f"Cleaned up {cleaned_count} dead processes")
return cleaned_count
except Exception as e:
print(f"Error cleaning up processes: {str(e)}")
return 0

View File

@ -1,43 +1,55 @@
from typing import List, Optional, Annotated
from typing import Annotated
from fastapi import Depends
from app.models.resume import Resume, ResumeCreate, ResumeUpdate, ResumeStatus
from app.models.resume import Resume, ResumeCreate, ResumeStatus, ResumeUpdate
from app.repositories.resume_repository import ResumeRepository
class ResumeService:
def __init__(self, repository: Annotated[ResumeRepository, Depends(ResumeRepository)]):
def __init__(
self, repository: Annotated[ResumeRepository, Depends(ResumeRepository)]
):
self.repository = repository
async def create_resume(self, resume_data: ResumeCreate) -> Resume:
resume = Resume.model_validate(resume_data)
return await self.repository.create(resume)
async def create_resume_with_session(self, resume_data: ResumeCreate, session_id: int) -> Resume:
async def create_resume_with_session(
self, resume_data: ResumeCreate, session_id: int
) -> Resume:
"""Создать резюме с привязкой к сессии"""
resume_dict = resume_data.model_dump()
return await self.repository.create_with_session(resume_dict, session_id)
async def get_resume(self, resume_id: int) -> Optional[Resume]:
async def get_resume(self, resume_id: int) -> Resume | None:
return await self.repository.get(resume_id)
async def get_all_resumes(self, skip: int = 0, limit: int = 100) -> List[Resume]:
async def get_all_resumes(self, skip: int = 0, limit: int = 100) -> list[Resume]:
return await self.repository.get_all(skip=skip, limit=limit)
async def get_resumes_by_vacancy(self, vacancy_id: int) -> List[Resume]:
async def get_resumes_by_vacancy(self, vacancy_id: int) -> list[Resume]:
return await self.repository.get_by_vacancy_id(vacancy_id)
async def get_resumes_by_session(self, session_id: int, skip: int = 0, limit: int = 100) -> List[Resume]:
async def get_resumes_by_session(
self, session_id: int, skip: int = 0, limit: int = 100
) -> list[Resume]:
"""Получить резюме пользователя по session_id"""
return await self.repository.get_by_session_id(session_id)
async def get_resumes_by_vacancy_and_session(self, vacancy_id: int, session_id: int) -> List[Resume]:
async def get_resumes_by_vacancy_and_session(
self, vacancy_id: int, session_id: int
) -> list[Resume]:
"""Получить резюме пользователя для конкретной вакансии"""
return await self.repository.get_by_vacancy_and_session(vacancy_id, session_id)
async def get_resumes_by_status(self, status: ResumeStatus) -> List[Resume]:
async def get_resumes_by_status(self, status: ResumeStatus) -> list[Resume]:
return await self.repository.get_by_status(status)
async def update_resume(self, resume_id: int, resume_data: ResumeUpdate) -> Optional[Resume]:
async def update_resume(
self, resume_id: int, resume_data: ResumeUpdate
) -> Resume | None:
update_data = resume_data.model_dump(exclude_unset=True)
if not update_data:
return await self.repository.get(resume_id)
@ -46,8 +58,12 @@ class ResumeService:
async def delete_resume(self, resume_id: int) -> bool:
return await self.repository.delete(resume_id)
async def update_resume_status(self, resume_id: int, status: ResumeStatus) -> Optional[Resume]:
async def update_resume_status(
self, resume_id: int, status: ResumeStatus
) -> Resume | None:
return await self.repository.update_status(resume_id, status)
async def add_interview_report(self, resume_id: int, report_url: str) -> Optional[Resume]:
return await self.repository.add_interview_report(resume_id, report_url)
async def add_interview_report(
self, resume_id: int, report_url: str
) -> Resume | None:
return await self.repository.add_interview_report(resume_id, report_url)

View File

@ -1,27 +1,35 @@
from typing import List, Optional, Annotated
from typing import Annotated
from fastapi import Depends
from app.models.vacancy import Vacancy, VacancyCreate, VacancyUpdate
from app.repositories.vacancy_repository import VacancyRepository
class VacancyService:
def __init__(self, repository: Annotated[VacancyRepository, Depends(VacancyRepository)]):
def __init__(
self, repository: Annotated[VacancyRepository, Depends(VacancyRepository)]
):
self.repository = repository
async def create_vacancy(self, vacancy_data: VacancyCreate) -> Vacancy:
vacancy = Vacancy.model_validate(vacancy_data)
return await self.repository.create(vacancy)
async def get_vacancy(self, vacancy_id: int) -> Optional[Vacancy]:
async def get_vacancy(self, vacancy_id: int) -> Vacancy | None:
return await self.repository.get(vacancy_id)
async def get_all_vacancies(self, skip: int = 0, limit: int = 100) -> List[Vacancy]:
async def get_all_vacancies(self, skip: int = 0, limit: int = 100) -> list[Vacancy]:
return await self.repository.get_all(skip=skip, limit=limit)
async def get_active_vacancies(self, skip: int = 0, limit: int = 100) -> List[Vacancy]:
async def get_active_vacancies(
self, skip: int = 0, limit: int = 100
) -> list[Vacancy]:
return await self.repository.get_active_vacancies(skip=skip, limit=limit)
async def update_vacancy(self, vacancy_id: int, vacancy_data: VacancyUpdate) -> Optional[Vacancy]:
async def update_vacancy(
self, vacancy_id: int, vacancy_data: VacancyUpdate
) -> Vacancy | None:
update_data = vacancy_data.model_dump(exclude_unset=True)
if not update_data:
return await self.repository.get(vacancy_id)
@ -30,21 +38,21 @@ class VacancyService:
async def delete_vacancy(self, vacancy_id: int) -> bool:
return await self.repository.delete(vacancy_id)
async def archive_vacancy(self, vacancy_id: int) -> Optional[Vacancy]:
async def archive_vacancy(self, vacancy_id: int) -> Vacancy | None:
return await self.repository.update(vacancy_id, {"is_archived": True})
async def search_vacancies(
self,
title: Optional[str] = None,
company_name: Optional[str] = None,
area_name: Optional[str] = None,
title: str | None = None,
company_name: str | None = None,
area_name: str | None = None,
skip: int = 0,
limit: int = 100
) -> List[Vacancy]:
limit: int = 100,
) -> list[Vacancy]:
return await self.repository.search_vacancies(
title=title,
company_name=company_name,
area_name=area_name,
skip=skip,
limit=limit
)
limit=limit,
)

View File

@ -1,11 +1,12 @@
from celery import Celery
from rag.settings import settings
celery_app = Celery(
"hr_ai_backend",
broker=f"redis://{settings.redis_cache_url}:{settings.redis_cache_port}/{settings.redis_cache_db}",
backend=f"redis://{settings.redis_cache_url}:{settings.redis_cache_port}/{settings.redis_cache_db}",
include=["celery_worker.tasks"]
include=["celery_worker.tasks"],
)
celery_app.conf.update(
@ -14,4 +15,4 @@ celery_app.conf.update(
result_serializer="json",
timezone="UTC",
enable_utc=True,
)
)

View File

@ -1,23 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from contextlib import contextmanager
from rag.settings import settings
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from rag.settings import settings
# Создаем синхронный engine для Celery (так как Celery работает в отдельных процессах)
sync_engine = create_engine(
settings.database_url.replace("asyncpg", "psycopg2"), # Убираем asyncpg для синхронного подключения
settings.database_url.replace(
"asyncpg", "psycopg2"
), # Убираем asyncpg для синхронного подключения
echo=False,
future=True,
connect_args={"client_encoding": "utf8"} # Принудительно UTF-8
connect_args={"client_encoding": "utf8"}, # Принудительно UTF-8
)
# Создаем синхронный session maker
SyncSessionLocal = sessionmaker(
bind=sync_engine,
autocommit=False,
autoflush=False
)
SyncSessionLocal = sessionmaker(bind=sync_engine, autocommit=False, autoflush=False)
@contextmanager
@ -36,78 +35,89 @@ def get_sync_session() -> Session:
class SyncResumeRepository:
"""Синхронный repository для работы с Resume в Celery tasks"""
def __init__(self, session: Session):
self.session = session
def get_by_id(self, resume_id: int):
"""Получить резюме по ID"""
from app.models.resume import Resume
return self.session.query(Resume).filter(Resume.id == resume_id).first()
def update_status(self, resume_id: int, status: str, parsed_data: dict = None, error_message: str = None):
def update_status(
self,
resume_id: int,
status: str,
parsed_data: dict = None,
error_message: str = None,
):
"""Обновить статус резюме"""
from app.models.resume import Resume, ResumeStatus
from datetime import datetime
from app.models.resume import Resume, ResumeStatus
resume = self.session.query(Resume).filter(Resume.id == resume_id).first()
if resume:
# Обновляем статус
if status == 'parsing':
if status == "parsing":
resume.status = ResumeStatus.PARSING
elif status == 'parsed':
elif status == "parsed":
resume.status = ResumeStatus.PARSED
if parsed_data:
resume.parsed_data = parsed_data
elif status == 'failed':
elif status == "failed":
resume.status = ResumeStatus.PARSE_FAILED
if error_message:
resume.parse_error = error_message
resume.updated_at = datetime.utcnow()
self.session.add(resume)
return resume
return None
def update_interview_plan(self, resume_id: int, interview_plan: dict):
"""Обновить план интервью"""
from app.models.resume import Resume
from datetime import datetime
from app.models.resume import Resume
resume = self.session.query(Resume).filter(Resume.id == resume_id).first()
if resume:
resume.interview_plan = interview_plan
resume.updated_at = datetime.utcnow()
self.session.add(resume)
return resume
return None
def _normalize_utf8_dict(self, data):
"""Нормализует UTF-8 в словаре рекурсивно"""
import json
# Сериализуем в JSON с ensure_ascii=False, потом парсим обратно
# Это принудительно конвертирует все unicode escape sequences в нормальные символы
try:
json_str = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
json_str = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return json.loads(json_str)
except (TypeError, ValueError):
# Fallback - рекурсивная обработка
if isinstance(data, dict):
return {key: self._normalize_utf8_dict(value) for key, value in data.items()}
return {
key: self._normalize_utf8_dict(value) for key, value in data.items()
}
elif isinstance(data, list):
return [self._normalize_utf8_dict(item) for item in data]
elif isinstance(data, str):
try:
# Пытаемся декодировать unicode escape sequences
if '\\u' in data:
return data.encode().decode('unicode_escape')
if "\\u" in data:
return data.encode().decode("unicode_escape")
return data
except (UnicodeDecodeError, UnicodeEncodeError):
return data
else:
return data
return data

View File

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
import json
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from typing import Any
from celery import shared_task
from celery_worker.database import SyncResumeRepository, get_sync_session
from rag.settings import settings
from celery_worker.database import get_sync_session, SyncResumeRepository
logger = logging.getLogger(__name__)
@ -15,46 +15,52 @@ logger = logging.getLogger(__name__)
def generate_interview_report(resume_id: int):
"""
Комплексная оценка кандидата на основе резюме, вакансии и диалога интервью
Args:
resume_id: ID резюме для анализа
Returns:
dict: Полный отчет с оценками и рекомендациями
"""
logger.info(f"[INTERVIEW_ANALYSIS] Starting analysis for resume_id: {resume_id}")
try:
with get_sync_session() as db:
repo = SyncResumeRepository(db)
# Получаем данные резюме
resume = repo.get_by_id(resume_id)
if not resume:
logger.error(f"[INTERVIEW_ANALYSIS] Resume {resume_id} not found")
return {"error": "Resume not found"}
# Получаем данные вакансии (если нет - используем пустые данные)
vacancy = _get_vacancy_data(db, resume.vacancy_id)
if not vacancy:
logger.warning(f"[INTERVIEW_ANALYSIS] Vacancy {resume.vacancy_id} not found, using empty vacancy data")
logger.warning(
f"[INTERVIEW_ANALYSIS] Vacancy {resume.vacancy_id} not found, using empty vacancy data"
)
vacancy = {
'id': resume.vacancy_id,
'title': 'Неизвестная позиция',
'description': 'Описание недоступно',
'requirements': [],
'skills_required': [],
'experience_level': 'middle'
"id": resume.vacancy_id,
"title": "Неизвестная позиция",
"description": "Описание недоступно",
"requirements": [],
"skills_required": [],
"experience_level": "middle",
}
# Получаем историю интервью
interview_session = _get_interview_session(db, resume_id)
# Парсим JSON данные
parsed_resume = _parse_json_field(resume.parsed_data)
interview_plan = _parse_json_field(resume.interview_plan)
dialogue_history = _parse_json_field(interview_session.dialogue_history) if interview_session else []
dialogue_history = (
_parse_json_field(interview_session.dialogue_history)
if interview_session
else []
)
# Генерируем отчет
report = _generate_comprehensive_report(
resume_id=resume_id,
@ -62,24 +68,29 @@ def generate_interview_report(resume_id: int):
vacancy=vacancy,
parsed_resume=parsed_resume,
interview_plan=interview_plan,
dialogue_history=dialogue_history
dialogue_history=dialogue_history,
)
# Сохраняем отчет в БД
_save_report_to_db(db, resume_id, report)
logger.info(f"[INTERVIEW_ANALYSIS] Analysis completed for resume_id: {resume_id}, score: {report['overall_score']}")
logger.info(
f"[INTERVIEW_ANALYSIS] Analysis completed for resume_id: {resume_id}, score: {report['overall_score']}"
)
return report
except Exception as e:
logger.error(f"[INTERVIEW_ANALYSIS] Error analyzing resume {resume_id}: {str(e)}")
logger.error(
f"[INTERVIEW_ANALYSIS] Error analyzing resume {resume_id}: {str(e)}"
)
return {"error": str(e)}
def _get_vacancy_data(db, vacancy_id: int) -> Optional[Dict]:
def _get_vacancy_data(db, vacancy_id: int) -> dict | None:
"""Получить данные вакансии"""
try:
from app.models.vacancy import Vacancy
vacancy = db.query(Vacancy).filter(Vacancy.id == vacancy_id).first()
if vacancy:
# Парсим key_skills в список, если это строка
@ -87,28 +98,36 @@ def _get_vacancy_data(db, vacancy_id: int) -> Optional[Dict]:
if vacancy.key_skills:
if isinstance(vacancy.key_skills, str):
# Разделяем по запятым и очищаем от пробелов
key_skills = [skill.strip() for skill in vacancy.key_skills.split(',') if skill.strip()]
key_skills = [
skill.strip()
for skill in vacancy.key_skills.split(",")
if skill.strip()
]
elif isinstance(vacancy.key_skills, list):
key_skills = vacancy.key_skills
# Маппинг Experience enum в строку уровня опыта
experience_mapping = {
'noExperience': 'junior',
'between1And3': 'junior',
'between3And6': 'middle',
'moreThan6': 'senior'
"noExperience": "junior",
"between1And3": "junior",
"between3And6": "middle",
"moreThan6": "senior",
}
experience_level = experience_mapping.get(vacancy.experience, 'middle')
experience_level = experience_mapping.get(vacancy.experience, "middle")
return {
'id': vacancy.id,
'title': vacancy.title,
'description': vacancy.description,
'requirements': [vacancy.description] if vacancy.description else [], # Используем описание как требования
'skills_required': key_skills,
'experience_level': experience_level,
'employment_type': vacancy.employment_type,
'salary_range': f"{vacancy.salary_from or 0}-{vacancy.salary_to or 0}" if vacancy.salary_from or vacancy.salary_to else None
"id": vacancy.id,
"title": vacancy.title,
"description": vacancy.description,
"requirements": [vacancy.description]
if vacancy.description
else [], # Используем описание как требования
"skills_required": key_skills,
"experience_level": experience_level,
"employment_type": vacancy.employment_type,
"salary_range": f"{vacancy.salary_from or 0}-{vacancy.salary_to or 0}"
if vacancy.salary_from or vacancy.salary_to
else None,
}
return None
except Exception as e:
@ -120,13 +139,18 @@ def _get_interview_session(db, resume_id: int):
"""Получить сессию интервью"""
try:
from app.models.interview import InterviewSession
return db.query(InterviewSession).filter(InterviewSession.resume_id == resume_id).first()
return (
db.query(InterviewSession)
.filter(InterviewSession.resume_id == resume_id)
.first()
)
except Exception as e:
logger.error(f"Error getting interview session: {e}")
return None
def _parse_json_field(field_data) -> Dict:
def _parse_json_field(field_data) -> dict:
"""Безопасный парсинг JSON поля"""
if field_data is None:
return {}
@ -143,138 +167,139 @@ def _parse_json_field(field_data) -> Dict:
def _generate_comprehensive_report(
resume_id: int,
candidate_name: str,
vacancy: Dict,
parsed_resume: Dict,
interview_plan: Dict,
dialogue_history: List[Dict]
) -> Dict[str, Any]:
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
) -> dict[str, Any]:
"""
Генерирует комплексный отчет о кандидате с использованием LLM
"""
# Подготавливаем контекст для анализа
context = _prepare_analysis_context(
vacancy=vacancy,
parsed_resume=parsed_resume,
interview_plan=interview_plan,
dialogue_history=dialogue_history
dialogue_history=dialogue_history,
)
# Генерируем оценку через OpenAI
evaluation = _call_openai_for_evaluation(context)
# Формируем финальный отчет
report = {
"resume_id": resume_id,
"candidate_name": candidate_name,
"position": vacancy.get('title', 'Unknown Position'),
"position": vacancy.get("title", "Unknown Position"),
"interview_date": datetime.utcnow().isoformat(),
"analysis_context": {
"has_parsed_resume": bool(parsed_resume),
"has_interview_plan": bool(interview_plan),
"dialogue_messages_count": len(dialogue_history),
"vacancy_requirements_count": len(vacancy.get('requirements', []))
}
"vacancy_requirements_count": len(vacancy.get("requirements", [])),
},
}
# Добавляем результаты оценки
if evaluation:
# Убеждаемся, что есть overall_score
if 'overall_score' not in evaluation:
evaluation['overall_score'] = _calculate_overall_score(evaluation)
if "overall_score" not in evaluation:
evaluation["overall_score"] = _calculate_overall_score(evaluation)
report.update(evaluation)
else:
# Fallback оценка, если LLM не сработал
report.update(_generate_fallback_evaluation(
parsed_resume, vacancy, dialogue_history
))
report.update(
_generate_fallback_evaluation(parsed_resume, vacancy, dialogue_history)
)
return report
def _calculate_overall_score(evaluation: Dict) -> int:
def _calculate_overall_score(evaluation: dict) -> int:
"""Вычисляет общий балл как среднее арифметическое всех критериев"""
try:
scores = evaluation.get('scores', {})
scores = evaluation.get("scores", {})
if not scores:
return 50 # Default score
total_score = 0
count = 0
for criterion_name, criterion_data in scores.items():
if isinstance(criterion_data, dict) and 'score' in criterion_data:
total_score += criterion_data['score']
if isinstance(criterion_data, dict) and "score" in criterion_data:
total_score += criterion_data["score"]
count += 1
if count == 0:
return 50 # Default if no valid scores
overall = int(total_score / count)
return max(0, min(100, overall)) # Ensure 0-100 range
except Exception:
return 50 # Safe fallback
def _prepare_analysis_context(
vacancy: Dict,
parsed_resume: Dict,
interview_plan: Dict,
dialogue_history: List[Dict]
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
) -> str:
"""Подготавливает контекст для анализа LLM"""
# Собираем диалог интервью
dialogue_text = ""
if dialogue_history:
dialogue_messages = []
for msg in dialogue_history[-20:]: # Последние 20 сообщений
role = msg.get('role', 'unknown')
content = msg.get('content', '')
role = msg.get("role", "unknown")
content = msg.get("content", "")
dialogue_messages.append(f"{role.upper()}: {content}")
dialogue_text = "\n".join(dialogue_messages)
# Формируем контекст
context = f"""
АНАЛИЗ КАНДИДАТА НА СОБЕСЕДОВАНИЕ
ВАКАНСИЯ:
- Позиция: {vacancy.get('title', 'Не указана')}
- Описание: {vacancy.get('description', 'Не указано')[:500]}
- Требования: {', '.join(vacancy.get('requirements', []))}
- Требуемые навыки: {', '.join(vacancy.get('skills_required', []))}
- Уровень опыта: {vacancy.get('experience_level', 'middle')}
- Позиция: {vacancy.get("title", "Не указана")}
- Описание: {vacancy.get("description", "Не указано")[:500]}
- Требования: {", ".join(vacancy.get("requirements", []))}
- Требуемые навыки: {", ".join(vacancy.get("skills_required", []))}
- Уровень опыта: {vacancy.get("experience_level", "middle")}
РЕЗЮМЕ КАНДИДАТА:
- Имя: {parsed_resume.get('name', 'Не указано')}
- Опыт работы: {parsed_resume.get('total_years', 'Не указано')} лет
- Навыки: {', '.join(parsed_resume.get('skills', []))}
- Образование: {parsed_resume.get('education', 'Не указано')}
- Предыдущие позиции: {'; '.join([pos.get('title', '') + ' в ' + pos.get('company', '') for pos in parsed_resume.get('work_experience', [])])}
- Имя: {parsed_resume.get("name", "Не указано")}
- Опыт работы: {parsed_resume.get("total_years", "Не указано")} лет
- Навыки: {", ".join(parsed_resume.get("skills", []))}
- Образование: {parsed_resume.get("education", "Не указано")}
- Предыдущие позиции: {"; ".join([pos.get("title", "") + " в " + pos.get("company", "") for pos in parsed_resume.get("work_experience", [])])}
ПЛАН ИНТЕРВЬЮ:
{json.dumps(interview_plan, ensure_ascii=False, indent=2) if interview_plan else 'План интервью не найден'}
{json.dumps(interview_plan, ensure_ascii=False, indent=2) if interview_plan else "План интервью не найден"}
ДИАЛОГ ИНТЕРВЬЮ:
{dialogue_text if dialogue_text else 'Диалог интервью не найден или пуст'}
{dialogue_text if dialogue_text else "Диалог интервью не найден или пуст"}
"""
return context
def _call_openai_for_evaluation(context: str) -> Optional[Dict]:
def _call_openai_for_evaluation(context: str) -> dict | None:
"""Вызывает OpenAI для генерации оценки"""
if not settings.openai_api_key:
logger.warning("OpenAI API key not configured, skipping LLM evaluation")
return None
try:
import openai
openai.api_key = settings.openai_api_key
evaluation_prompt = f"""
{context}
@ -313,35 +338,35 @@ def _call_openai_for_evaluation(context: str) -> Optional[Dict]:
model="gpt-4o-mini",
messages=[{"role": "user", "content": evaluation_prompt}],
response_format={"type": "json_object"},
temperature=0.3
temperature=0.3,
)
evaluation = json.loads(response.choices[0].message.content)
logger.info(f"[INTERVIEW_ANALYSIS] OpenAI evaluation completed")
logger.info("[INTERVIEW_ANALYSIS] OpenAI evaluation completed")
return evaluation
except Exception as e:
logger.error(f"[INTERVIEW_ANALYSIS] Error calling OpenAI: {str(e)}")
return None
def _generate_fallback_evaluation(
parsed_resume: Dict,
vacancy: Dict,
dialogue_history: List[Dict]
) -> Dict[str, Any]:
parsed_resume: dict, vacancy: dict, dialogue_history: list[dict]
) -> dict[str, Any]:
"""Генерирует базовую оценку без LLM"""
# Простая эвристическая оценка
technical_score = _calculate_technical_match(parsed_resume, vacancy)
experience_score = _calculate_experience_score(parsed_resume, vacancy)
communication_score = 70 # Средняя оценка, если нет диалога
if dialogue_history:
communication_score = min(90, 50 + len(dialogue_history) * 2) # Больше диалога = лучше коммуникация
communication_score = min(
90, 50 + len(dialogue_history) * 2
) # Больше диалога = лучше коммуникация
overall_score = (technical_score + experience_score + communication_score) // 3
# Определяем рекомендацию
if overall_score >= 90:
recommendation = "strongly_recommend"
@ -351,84 +376,86 @@ def _generate_fallback_evaluation(
recommendation = "consider"
else:
recommendation = "reject"
return {
"scores": {
"technical_skills": {
"score": technical_score,
"justification": f"Соответствие по навыкам: {technical_score}%",
"concerns": "Автоматическая оценка без анализа LLM"
"concerns": "Автоматическая оценка без анализа LLM",
},
"experience_relevance": {
"score": experience_score,
"justification": f"Опыт работы: {parsed_resume.get('total_years', 0)} лет",
"concerns": "Требуется ручная проверка релевантности опыта"
"concerns": "Требуется ручная проверка релевантности опыта",
},
"communication": {
"score": communication_score,
"justification": f"Активность в диалоге: {len(dialogue_history)} сообщений",
"concerns": "Оценка основана на количестве сообщений"
"concerns": "Оценка основана на количестве сообщений",
},
"problem_solving": {
"score": 60,
"justification": "Средняя оценка (нет данных для анализа)",
"concerns": "Требуется техническое интервью"
"concerns": "Требуется техническое интервью",
},
"cultural_fit": {
"score": 65,
"justification": "Средняя оценка (нет данных для анализа)",
"concerns": "Требуется личная встреча с командой"
}
"concerns": "Требуется личная встреча с командой",
},
},
"overall_score": overall_score,
"recommendation": recommendation,
"strengths": [
f"Опыт работы: {parsed_resume.get('total_years', 0)} лет",
f"Технические навыки: {len(parsed_resume.get('skills', []))} навыков",
f"Участие в интервью: {len(dialogue_history)} сообщений"
f"Участие в интервью: {len(dialogue_history)} сообщений",
],
"weaknesses": [
"Автоматическая оценка без LLM анализа",
"Требуется дополнительное техническое интервью",
"Нет глубокого анализа ответов на вопросы"
"Нет глубокого анализа ответов на вопросы",
],
"red_flags": [],
"next_steps": "Рекомендуется провести техническое интервью с тимлидом для более точной оценки.",
"analysis_method": "fallback_heuristic"
"analysis_method": "fallback_heuristic",
}
def _calculate_technical_match(parsed_resume: Dict, vacancy: Dict) -> int:
def _calculate_technical_match(parsed_resume: dict, vacancy: dict) -> int:
"""Вычисляет соответствие технических навыков"""
resume_skills = set([skill.lower() for skill in parsed_resume.get('skills', [])])
required_skills = set([skill.lower() for skill in vacancy.get('skills_required', [])])
resume_skills = set([skill.lower() for skill in parsed_resume.get("skills", [])])
required_skills = set(
[skill.lower() for skill in vacancy.get("skills_required", [])]
)
if not required_skills:
return 70 # Если требования не указаны
matching_skills = resume_skills.intersection(required_skills)
match_percentage = (len(matching_skills) / len(required_skills)) * 100
return min(100, int(match_percentage))
def _calculate_experience_score(parsed_resume: Dict, vacancy: Dict) -> int:
def _calculate_experience_score(parsed_resume: dict, vacancy: dict) -> int:
"""Вычисляет оценку релевантности опыта"""
years_experience = parsed_resume.get('total_years', 0)
required_level = vacancy.get('experience_level', 'middle')
years_experience = parsed_resume.get("total_years", 0)
required_level = vacancy.get("experience_level", "middle")
# Маппинг уровней на годы опыта
level_mapping = {
'junior': (0, 2),
'middle': (2, 5),
'senior': (5, 10),
'lead': (8, 15)
"junior": (0, 2),
"middle": (2, 5),
"senior": (5, 10),
"lead": (8, 15),
}
min_years, max_years = level_mapping.get(required_level, (2, 5))
if years_experience < min_years:
# Недостаток опыта
return max(30, int(70 * (years_experience / min_years)))
@ -440,194 +467,248 @@ def _calculate_experience_score(parsed_resume: Dict, vacancy: Dict) -> int:
return 90
def _save_report_to_db(db, resume_id: int, report: Dict):
def _save_report_to_db(db, resume_id: int, report: dict):
"""Сохраняет отчет в базу данных в таблицу interview_reports"""
try:
from app.models.interview import InterviewSession
from app.models.interview_report import InterviewReport, RecommendationType
from app.models.interview_report import InterviewReport
# Находим сессию интервью по resume_id
interview_session = db.query(InterviewSession).filter(
InterviewSession.resume_id == resume_id
).first()
interview_session = (
db.query(InterviewSession)
.filter(InterviewSession.resume_id == resume_id)
.first()
)
if not interview_session:
logger.warning(f"[INTERVIEW_ANALYSIS] No interview session found for resume_id: {resume_id}")
logger.warning(
f"[INTERVIEW_ANALYSIS] No interview session found for resume_id: {resume_id}"
)
return
# Проверяем, есть ли уже отчет для этой сессии
existing_report = db.query(InterviewReport).filter(
InterviewReport.interview_session_id == interview_session.id
).first()
existing_report = (
db.query(InterviewReport)
.filter(InterviewReport.interview_session_id == interview_session.id)
.first()
)
if existing_report:
logger.info(f"[INTERVIEW_ANALYSIS] Updating existing report for session: {interview_session.id}")
logger.info(
f"[INTERVIEW_ANALYSIS] Updating existing report for session: {interview_session.id}"
)
# Обновляем существующий отчет
_update_report_from_dict(existing_report, report)
existing_report.updated_at = datetime.utcnow()
db.add(existing_report)
else:
logger.info(f"[INTERVIEW_ANALYSIS] Creating new report for session: {interview_session.id}")
logger.info(
f"[INTERVIEW_ANALYSIS] Creating new report for session: {interview_session.id}"
)
# Создаем новый отчет
new_report = _create_report_from_dict(interview_session.id, report)
db.add(new_report)
logger.info(f"[INTERVIEW_ANALYSIS] Report saved for resume_id: {resume_id}, session: {interview_session.id}")
logger.info(
f"[INTERVIEW_ANALYSIS] Report saved for resume_id: {resume_id}, session: {interview_session.id}"
)
except Exception as e:
logger.error(f"[INTERVIEW_ANALYSIS] Error saving report: {str(e)}")
def _create_report_from_dict(interview_session_id: int, report: Dict) -> 'InterviewReport':
def _create_report_from_dict(
interview_session_id: int, report: dict
) -> "InterviewReport":
"""Создает объект InterviewReport из словаря отчета"""
from app.models.interview_report import InterviewReport, RecommendationType
# Извлекаем баллы по критериям
scores = report.get('scores', {})
scores = report.get("scores", {})
return InterviewReport(
interview_session_id=interview_session_id,
# Основные критерии оценки
technical_skills_score=scores.get('technical_skills', {}).get('score', 0),
technical_skills_justification=scores.get('technical_skills', {}).get('justification', ''),
technical_skills_concerns=scores.get('technical_skills', {}).get('concerns', ''),
experience_relevance_score=scores.get('experience_relevance', {}).get('score', 0),
experience_relevance_justification=scores.get('experience_relevance', {}).get('justification', ''),
experience_relevance_concerns=scores.get('experience_relevance', {}).get('concerns', ''),
communication_score=scores.get('communication', {}).get('score', 0),
communication_justification=scores.get('communication', {}).get('justification', ''),
communication_concerns=scores.get('communication', {}).get('concerns', ''),
problem_solving_score=scores.get('problem_solving', {}).get('score', 0),
problem_solving_justification=scores.get('problem_solving', {}).get('justification', ''),
problem_solving_concerns=scores.get('problem_solving', {}).get('concerns', ''),
cultural_fit_score=scores.get('cultural_fit', {}).get('score', 0),
cultural_fit_justification=scores.get('cultural_fit', {}).get('justification', ''),
cultural_fit_concerns=scores.get('cultural_fit', {}).get('concerns', ''),
technical_skills_score=scores.get("technical_skills", {}).get("score", 0),
technical_skills_justification=scores.get("technical_skills", {}).get(
"justification", ""
),
technical_skills_concerns=scores.get("technical_skills", {}).get(
"concerns", ""
),
experience_relevance_score=scores.get("experience_relevance", {}).get(
"score", 0
),
experience_relevance_justification=scores.get("experience_relevance", {}).get(
"justification", ""
),
experience_relevance_concerns=scores.get("experience_relevance", {}).get(
"concerns", ""
),
communication_score=scores.get("communication", {}).get("score", 0),
communication_justification=scores.get("communication", {}).get(
"justification", ""
),
communication_concerns=scores.get("communication", {}).get("concerns", ""),
problem_solving_score=scores.get("problem_solving", {}).get("score", 0),
problem_solving_justification=scores.get("problem_solving", {}).get(
"justification", ""
),
problem_solving_concerns=scores.get("problem_solving", {}).get("concerns", ""),
cultural_fit_score=scores.get("cultural_fit", {}).get("score", 0),
cultural_fit_justification=scores.get("cultural_fit", {}).get(
"justification", ""
),
cultural_fit_concerns=scores.get("cultural_fit", {}).get("concerns", ""),
# Агрегированные поля
overall_score=report.get('overall_score', 0),
recommendation=RecommendationType(report.get('recommendation', 'reject')),
overall_score=report.get("overall_score", 0),
recommendation=RecommendationType(report.get("recommendation", "reject")),
# Дополнительные поля
strengths=report.get('strengths', []),
weaknesses=report.get('weaknesses', []),
red_flags=report.get('red_flags', []),
strengths=report.get("strengths", []),
weaknesses=report.get("weaknesses", []),
red_flags=report.get("red_flags", []),
# Метрики интервью
dialogue_messages_count=report.get('analysis_context', {}).get('dialogue_messages_count', 0),
dialogue_messages_count=report.get("analysis_context", {}).get(
"dialogue_messages_count", 0
),
# Дополнительная информация
next_steps=report.get('next_steps', ''),
questions_analysis=report.get('questions_analysis', []),
next_steps=report.get("next_steps", ""),
questions_analysis=report.get("questions_analysis", []),
# Метаданные анализа
analysis_method=report.get('analysis_method', 'openai_gpt4'),
analysis_method=report.get("analysis_method", "openai_gpt4"),
)
def _update_report_from_dict(existing_report, report: Dict):
def _update_report_from_dict(existing_report, report: dict):
"""Обновляет существующий отчет данными из словаря"""
from app.models.interview_report import RecommendationType
scores = report.get('scores', {})
scores = report.get("scores", {})
# Основные критерии оценки
if 'technical_skills' in scores:
existing_report.technical_skills_score = scores['technical_skills'].get('score', 0)
existing_report.technical_skills_justification = scores['technical_skills'].get('justification', '')
existing_report.technical_skills_concerns = scores['technical_skills'].get('concerns', '')
if 'experience_relevance' in scores:
existing_report.experience_relevance_score = scores['experience_relevance'].get('score', 0)
existing_report.experience_relevance_justification = scores['experience_relevance'].get('justification', '')
existing_report.experience_relevance_concerns = scores['experience_relevance'].get('concerns', '')
if 'communication' in scores:
existing_report.communication_score = scores['communication'].get('score', 0)
existing_report.communication_justification = scores['communication'].get('justification', '')
existing_report.communication_concerns = scores['communication'].get('concerns', '')
if 'problem_solving' in scores:
existing_report.problem_solving_score = scores['problem_solving'].get('score', 0)
existing_report.problem_solving_justification = scores['problem_solving'].get('justification', '')
existing_report.problem_solving_concerns = scores['problem_solving'].get('concerns', '')
if 'cultural_fit' in scores:
existing_report.cultural_fit_score = scores['cultural_fit'].get('score', 0)
existing_report.cultural_fit_justification = scores['cultural_fit'].get('justification', '')
existing_report.cultural_fit_concerns = scores['cultural_fit'].get('concerns', '')
if "technical_skills" in scores:
existing_report.technical_skills_score = scores["technical_skills"].get(
"score", 0
)
existing_report.technical_skills_justification = scores["technical_skills"].get(
"justification", ""
)
existing_report.technical_skills_concerns = scores["technical_skills"].get(
"concerns", ""
)
if "experience_relevance" in scores:
existing_report.experience_relevance_score = scores["experience_relevance"].get(
"score", 0
)
existing_report.experience_relevance_justification = scores[
"experience_relevance"
].get("justification", "")
existing_report.experience_relevance_concerns = scores[
"experience_relevance"
].get("concerns", "")
if "communication" in scores:
existing_report.communication_score = scores["communication"].get("score", 0)
existing_report.communication_justification = scores["communication"].get(
"justification", ""
)
existing_report.communication_concerns = scores["communication"].get(
"concerns", ""
)
if "problem_solving" in scores:
existing_report.problem_solving_score = scores["problem_solving"].get(
"score", 0
)
existing_report.problem_solving_justification = scores["problem_solving"].get(
"justification", ""
)
existing_report.problem_solving_concerns = scores["problem_solving"].get(
"concerns", ""
)
if "cultural_fit" in scores:
existing_report.cultural_fit_score = scores["cultural_fit"].get("score", 0)
existing_report.cultural_fit_justification = scores["cultural_fit"].get(
"justification", ""
)
existing_report.cultural_fit_concerns = scores["cultural_fit"].get(
"concerns", ""
)
# Агрегированные поля
if 'overall_score' in report:
existing_report.overall_score = report['overall_score']
if 'recommendation' in report:
existing_report.recommendation = RecommendationType(report['recommendation'])
if "overall_score" in report:
existing_report.overall_score = report["overall_score"]
if "recommendation" in report:
existing_report.recommendation = RecommendationType(report["recommendation"])
# Дополнительные поля
if 'strengths' in report:
existing_report.strengths = report['strengths']
if 'weaknesses' in report:
existing_report.weaknesses = report['weaknesses']
if 'red_flags' in report:
existing_report.red_flags = report['red_flags']
if "strengths" in report:
existing_report.strengths = report["strengths"]
if "weaknesses" in report:
existing_report.weaknesses = report["weaknesses"]
if "red_flags" in report:
existing_report.red_flags = report["red_flags"]
# Метрики интервью
if 'analysis_context' in report:
existing_report.dialogue_messages_count = report['analysis_context'].get('dialogue_messages_count', 0)
if "analysis_context" in report:
existing_report.dialogue_messages_count = report["analysis_context"].get(
"dialogue_messages_count", 0
)
# Дополнительная информация
if 'next_steps' in report:
existing_report.next_steps = report['next_steps']
if 'questions_analysis' in report:
existing_report.questions_analysis = report['questions_analysis']
if "next_steps" in report:
existing_report.next_steps = report["next_steps"]
if "questions_analysis" in report:
existing_report.questions_analysis = report["questions_analysis"]
# Метаданные анализа
if 'analysis_method' in report:
existing_report.analysis_method = report['analysis_method']
if "analysis_method" in report:
existing_report.analysis_method = report["analysis_method"]
# Дополнительная задача для массового анализа
@shared_task
def analyze_multiple_candidates(resume_ids: List[int]):
def analyze_multiple_candidates(resume_ids: list[int]):
"""
Анализирует несколько кандидатов и возвращает их рейтинг
Args:
resume_ids: Список ID резюме для анализа
Returns:
List[Dict]: Список кандидатов с оценками, отсортированный по рейтингу
"""
logger.info(f"[MASS_ANALYSIS] Starting analysis for {len(resume_ids)} candidates")
results = []
for resume_id in resume_ids:
try:
result = generate_interview_report(resume_id)
if 'error' not in result:
results.append({
'resume_id': resume_id,
'candidate_name': result.get('candidate_name', 'Unknown'),
'overall_score': result.get('overall_score', 0),
'recommendation': result.get('recommendation', 'reject'),
'position': result.get('position', 'Unknown')
})
if "error" not in result:
results.append(
{
"resume_id": resume_id,
"candidate_name": result.get("candidate_name", "Unknown"),
"overall_score": result.get("overall_score", 0),
"recommendation": result.get("recommendation", "reject"),
"position": result.get("position", "Unknown"),
}
)
except Exception as e:
logger.error(f"[MASS_ANALYSIS] Error analyzing resume {resume_id}: {str(e)}")
logger.error(
f"[MASS_ANALYSIS] Error analyzing resume {resume_id}: {str(e)}"
)
# Сортируем по общему баллу
results.sort(key=lambda x: x['overall_score'], reverse=True)
results.sort(key=lambda x: x["overall_score"], reverse=True)
logger.info(f"[MASS_ANALYSIS] Completed analysis for {len(results)} candidates")
return results
return results

View File

@ -1,9 +1,7 @@
import asyncio
from celery import current_task
import psutil
from celery_worker.celery_app import celery_app
from celery_worker.database import get_sync_session
from app.services.interview_service import InterviewRoomService
import psutil
@celery_app.task(bind=True)
@ -13,86 +11,94 @@ def cleanup_interview_processes_task(self):
"""
try:
self.update_state(
state='PROGRESS',
meta={'status': 'Checking for dead AI processes...', 'progress': 10}
state="PROGRESS",
meta={"status": "Checking for dead AI processes...", "progress": 10},
)
# Используем синхронный подход для Celery
with get_sync_session() as session:
# Получаем все "активные" сессии из БД
from app.models.interview import InterviewSession
active_sessions = session.query(InterviewSession).filter(
InterviewSession.ai_agent_status == "running"
).all()
active_sessions = (
session.query(InterviewSession)
.filter(InterviewSession.ai_agent_status == "running")
.all()
)
cleaned_count = 0
total_sessions = len(active_sessions)
self.update_state(
state='PROGRESS',
meta={'status': f'Found {total_sessions} potentially active sessions...', 'progress': 30}
state="PROGRESS",
meta={
"status": f"Found {total_sessions} potentially active sessions...",
"progress": 30,
},
)
for i, interview_session in enumerate(active_sessions):
if interview_session.ai_agent_pid:
try:
# Проверяем, жив ли процесс
process = psutil.Process(interview_session.ai_agent_pid)
if not process.is_running():
# Процесс мертв, обновляем статус
interview_session.ai_agent_pid = None
interview_session.ai_agent_status = "stopped"
session.add(interview_session)
cleaned_count += 1
except psutil.NoSuchProcess:
# Процесс не существует
interview_session.ai_agent_pid = None
interview_session.ai_agent_status = "stopped"
session.add(interview_session)
cleaned_count += 1
except Exception as e:
print(f"Error checking process {interview_session.ai_agent_pid}: {str(e)}")
print(
f"Error checking process {interview_session.ai_agent_pid}: {str(e)}"
)
# Обновляем прогресс
progress = 30 + (i + 1) / total_sessions * 60
self.update_state(
state='PROGRESS',
state="PROGRESS",
meta={
'status': f'Processed {i + 1}/{total_sessions} sessions...',
'progress': progress
}
"status": f"Processed {i + 1}/{total_sessions} sessions...",
"progress": progress,
},
)
# Сохраняем изменения
session.commit()
self.update_state(
state='SUCCESS',
state="SUCCESS",
meta={
'status': f'Cleanup completed. Cleaned {cleaned_count} dead processes.',
'progress': 100,
'cleaned_count': cleaned_count,
'total_checked': total_sessions
}
"status": f"Cleanup completed. Cleaned {cleaned_count} dead processes.",
"progress": 100,
"cleaned_count": cleaned_count,
"total_checked": total_sessions,
},
)
return {
'status': 'completed',
'cleaned_count': cleaned_count,
'total_checked': total_sessions
"status": "completed",
"cleaned_count": cleaned_count,
"total_checked": total_sessions,
}
except Exception as e:
self.update_state(
state='FAILURE',
state="FAILURE",
meta={
'status': f'Error during cleanup: {str(e)}',
'progress': 0,
'error': str(e)
}
"status": f"Error during cleanup: {str(e)}",
"progress": 0,
"error": str(e),
},
)
raise
@ -104,87 +110,93 @@ def force_kill_interview_process_task(self, session_id: int):
"""
try:
self.update_state(
state='PROGRESS',
meta={'status': f'Looking for session {session_id}...', 'progress': 20}
state="PROGRESS",
meta={"status": f"Looking for session {session_id}...", "progress": 20},
)
with get_sync_session() as session:
from app.models.interview import InterviewSession
interview_session = session.query(InterviewSession).filter(
InterviewSession.id == session_id
).first()
interview_session = (
session.query(InterviewSession)
.filter(InterviewSession.id == session_id)
.first()
)
if not interview_session:
return {
'status': 'not_found',
'message': f'Session {session_id} not found'
"status": "not_found",
"message": f"Session {session_id} not found",
}
if not interview_session.ai_agent_pid:
return {
'status': 'no_process',
'message': f'No AI process found for session {session_id}'
"status": "no_process",
"message": f"No AI process found for session {session_id}",
}
self.update_state(
state='PROGRESS',
meta={'status': f'Terminating process {interview_session.ai_agent_pid}...', 'progress': 50}
state="PROGRESS",
meta={
"status": f"Terminating process {interview_session.ai_agent_pid}...",
"progress": 50,
},
)
try:
process = psutil.Process(interview_session.ai_agent_pid)
# Graceful terminate
process.terminate()
# Ждем до 5 секунд
import time
for _ in range(50):
if not process.is_running():
break
time.sleep(0.1)
# Если не помогло, убиваем принудительно
if process.is_running():
process.kill()
time.sleep(0.5) # Даем время на завершение
# Обновляем статус в БД
interview_session.ai_agent_pid = None
interview_session.ai_agent_status = "stopped"
session.add(interview_session)
session.commit()
self.update_state(
state='SUCCESS',
meta={'status': 'Process terminated successfully', 'progress': 100}
state="SUCCESS",
meta={"status": "Process terminated successfully", "progress": 100},
)
return {
'status': 'terminated',
'message': f'AI process for session {session_id} terminated successfully'
"status": "terminated",
"message": f"AI process for session {session_id} terminated successfully",
}
except psutil.NoSuchProcess:
# Процесс уже не существует
interview_session.ai_agent_pid = None
interview_session.ai_agent_status = "stopped"
session.add(interview_session)
session.commit()
return {
'status': 'already_dead',
'message': f'Process was already dead, cleaned up database'
"status": "already_dead",
"message": "Process was already dead, cleaned up database",
}
except Exception as e:
self.update_state(
state='FAILURE',
state="FAILURE",
meta={
'status': f'Error terminating process: {str(e)}',
'progress': 0,
'error': str(e)
}
"status": f"Error terminating process: {str(e)}",
"progress": 0,
"error": str(e),
},
)
raise
raise

View File

@ -1,55 +1,54 @@
import os
import json
from typing import Dict, Any
from celery import current_task
from datetime import datetime
import os
from typing import Any
from celery_worker.celery_app import celery_app
from celery_worker.database import get_sync_session, SyncResumeRepository
from celery_worker.database import SyncResumeRepository, get_sync_session
from rag.llm.model import ResumeParser
from rag.registry import registry
# Импортируем новые задачи анализа интервью
from celery_worker.interview_analysis_task import generate_interview_report, analyze_multiple_candidates
def generate_interview_plan(resume_id: int, combined_data: Dict[str, Any]) -> Dict[str, Any]:
def generate_interview_plan(
resume_id: int, combined_data: dict[str, Any]
) -> dict[str, Any]:
"""Генерирует план интервью на основе резюме и вакансии"""
try:
# Получаем данные о вакансии из БД
with get_sync_session() as session:
repo = SyncResumeRepository(session)
resume_record = repo.get_by_id(resume_id)
if not resume_record:
return None
# Здесь нужно получить данные вакансии
# Пока используем заглушку, потом добавим связь с vacancy
vacancy_data = {
"title": "Python Developer",
"requirements": "Python, FastAPI, PostgreSQL, Docker",
"company_name": "Tech Company",
"experience_level": "Middle"
"experience_level": "Middle",
}
# Генерируем план через LLM
chat_model = registry.get_chat_model()
plan_prompt = f"""
Создай детальный план интервью для кандидата на основе его резюме и требований вакансии.
РЕЗЮМЕ КАНДИДАТА:
- Имя: {combined_data.get('name', 'Не указано')}
- Навыки: {', '.join(combined_data.get('skills', []))}
- Опыт: {combined_data.get('total_years', 0)} лет
- Образование: {combined_data.get('education', 'Не указано')}
- Имя: {combined_data.get("name", "Не указано")}
- Навыки: {", ".join(combined_data.get("skills", []))}
- Опыт: {combined_data.get("total_years", 0)} лет
- Образование: {combined_data.get("education", "Не указано")}
ВАКАНСИЯ:
- Позиция: {vacancy_data['title']}
- Требования: {vacancy_data['requirements']}
- Компания: {vacancy_data['company_name']}
- Уровень: {vacancy_data['experience_level']}
- Позиция: {vacancy_data["title"]}
- Требования: {vacancy_data["requirements"]}
- Компания: {vacancy_data["company_name"]}
- Уровень: {vacancy_data["experience_level"]}
Создай план интервью в формате JSON:
{{
@ -89,28 +88,31 @@ def generate_interview_plan(resume_id: int, combined_data: Dict[str, Any]) -> Di
"personalization_notes": "Кандидат имеет хороший технический опыт"
}}
"""
from langchain.schema import HumanMessage, SystemMessage
messages = [
SystemMessage(content="Ты HR эксперт по планированию интервью. Создавай структурированные планы."),
HumanMessage(content=plan_prompt)
SystemMessage(
content="Ты HR эксперт по планированию интервью. Создавай структурированные планы."
),
HumanMessage(content=plan_prompt),
]
response = chat_model.get_llm().invoke(messages)
response_text = response.content.strip()
# Парсим JSON ответ
if response_text.startswith('{') and response_text.endswith('}'):
if response_text.startswith("{") and response_text.endswith("}"):
return json.loads(response_text)
else:
# Ищем JSON в тексте
start = response_text.find('{')
end = response_text.rfind('}') + 1
start = response_text.find("{")
end = response_text.rfind("}") + 1
if start != -1 and end > start:
return json.loads(response_text[start:end])
return None
except Exception as e:
print(f"Ошибка генерации плана интервью: {str(e)}")
return None
@ -120,24 +122,24 @@ def generate_interview_plan(resume_id: int, combined_data: Dict[str, Any]) -> Di
def parse_resume_task(self, resume_id: str, file_path: str):
"""
Асинхронная задача парсинга резюме
Args:
resume_id: ID резюме
file_path: Путь к PDF файлу резюме
"""
try:
# Шаг 0: Обновляем статус в БД - начали парсинг
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), 'parsing')
repo.update_status(int(resume_id), "parsing")
# Обновляем статус задачи
self.update_state(
state='PENDING',
meta={'status': 'Начинаем парсинг резюме...', 'progress': 10}
state="PENDING",
meta={"status": "Начинаем парсинг резюме...", "progress": 10},
)
# Инициализируем модели из registry
try:
chat_model = registry.get_chat_model()
@ -147,108 +149,116 @@ def parse_resume_task(self, resume_id: str, file_path: str):
# Обновляем статус в БД - ошибка инициализации
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), 'failed', error_message=f"Ошибка инициализации моделей: {str(e)}")
repo.update_status(
int(resume_id),
"failed",
error_message=f"Ошибка инициализации моделей: {str(e)}",
)
raise Exception(f"Ошибка инициализации моделей: {str(e)}")
# Шаг 1: Парсинг резюме
self.update_state(
state='PROGRESS',
meta={'status': 'Извлекаем текст из PDF...', 'progress': 20}
state="PROGRESS",
meta={"status": "Извлекаем текст из PDF...", "progress": 20},
)
parser = ResumeParser(chat_model)
if not os.path.exists(file_path):
# Обновляем статус в БД - файл не найден
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), 'failed', error_message=f"Файл не найден: {file_path}")
repo.update_status(
int(resume_id),
"failed",
error_message=f"Файл не найден: {file_path}",
)
raise Exception(f"Файл не найден: {file_path}")
parsed_resume = parser.parse_resume_from_file(file_path)
# Получаем оригинальные данные из формы
with get_sync_session() as session:
repo = SyncResumeRepository(session)
resume_record = repo.get_by_id(int(resume_id))
if not resume_record:
raise Exception(f"Резюме с ID {resume_id} не найдено в базе данных")
# Извлекаем нужные данные пока сессия активна
applicant_name = resume_record.applicant_name
applicant_email = resume_record.applicant_email
applicant_phone = resume_record.applicant_phone
# Создаем комбинированные данные: навыки и опыт из парсинга, контакты из формы
combined_data = parsed_resume.copy()
combined_data['name'] = applicant_name
combined_data['email'] = applicant_email
combined_data['phone'] = applicant_phone or parsed_resume.get('phone', '')
combined_data["name"] = applicant_name
combined_data["email"] = applicant_email
combined_data["phone"] = applicant_phone or parsed_resume.get("phone", "")
# Шаг 2: Векторизация и сохранение в Milvus
self.update_state(
state='PENDING',
meta={'status': 'Сохраняем в векторную базу...', 'progress': 60}
state="PENDING",
meta={"status": "Сохраняем в векторную базу...", "progress": 60},
)
vector_store.add_candidate_profile(str(resume_id), combined_data)
# Шаг 3: Обновляем статус в PostgreSQL - успешно обработано
self.update_state(
state='PENDING',
meta={'status': 'Обновляем статус в базе данных...', 'progress': 85}
state="PENDING",
meta={"status": "Обновляем статус в базе данных...", "progress": 85},
)
# Шаг 4: Генерируем план интервью
self.update_state(
state='PENDING',
meta={'status': 'Генерируем план интервью...', 'progress': 90}
state="PENDING",
meta={"status": "Генерируем план интервью...", "progress": 90},
)
interview_plan = generate_interview_plan(int(resume_id), combined_data)
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), 'parsed', parsed_data=combined_data)
repo.update_status(int(resume_id), "parsed", parsed_data=combined_data)
# Сохраняем план интервью
if interview_plan:
repo.update_interview_plan(int(resume_id), interview_plan)
# Завершено успешно
self.update_state(
state='SUCCESS',
state="SUCCESS",
meta={
'status': 'Резюме успешно обработано и план интервью готов',
'progress': 100,
'result': combined_data
}
"status": "Резюме успешно обработано и план интервью готов",
"progress": 100,
"result": combined_data,
},
)
return {
'resume_id': resume_id,
'status': 'completed',
'parsed_data': combined_data
"resume_id": resume_id,
"status": "completed",
"parsed_data": combined_data,
}
except Exception as e:
# В случае ошибки
self.update_state(
state='FAILURE',
state="FAILURE",
meta={
'status': f'Ошибка при обработке резюме: {str(e)}',
'progress': 0,
'error': str(e)
}
"status": f"Ошибка при обработке резюме: {str(e)}",
"progress": 0,
"error": str(e),
},
)
# Обновляем статус в БД как failed
try:
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), 'failed', error_message=str(e))
repo.update_status(int(resume_id), "failed", error_message=str(e))
except Exception as db_error:
print(f"Ошибка при обновлении статуса в БД: {str(db_error)}")
raise
@ -259,63 +269,65 @@ def parse_resume_task(self, resume_id: str, file_path: str):
def generate_interview_questions_task(self, resume_id: str, job_description: str):
"""
Генерация персонализированных вопросов для интервью на основе резюме и описания вакансии
Args:
resume_id: ID резюме
job_description: Описание вакансии
"""
try:
self.update_state(
state='PENDING',
meta={'status': 'Начинаем генерацию вопросов...', 'progress': 10}
state="PENDING",
meta={"status": "Начинаем генерацию вопросов...", "progress": 10},
)
# Инициализируем модели
try:
chat_model = registry.get_chat_model()
vector_store = registry.get_vector_store()
except Exception as e:
raise Exception(f"Ошибка инициализации моделей: {str(e)}")
# Шаг 1: Получить parsed резюме из базы данных
self.update_state(
state='PENDING',
meta={'status': 'Получаем данные резюме...', 'progress': 20}
state="PENDING",
meta={"status": "Получаем данные резюме...", "progress": 20},
)
with get_sync_session() as session:
repo = SyncResumeRepository(session)
resume = repo.get_by_id(int(resume_id))
if not resume:
raise Exception(f"Резюме с ID {resume_id} не найдено")
if not resume.parsed_data:
raise Exception(f"Резюме {resume_id} еще не обработано")
# Шаг 2: Получить похожие кандидатов из Milvus для анализа
self.update_state(
state='PENDING',
meta={'status': 'Анализируем профиль кандидата...', 'progress': 40}
state="PENDING",
meta={"status": "Анализируем профиль кандидата...", "progress": 40},
)
candidate_skills = " ".join(resume.parsed_data.get('skills', []))
similar_candidates = vector_store.search_similar_candidates(candidate_skills, k=3)
candidate_skills = " ".join(resume.parsed_data.get("skills", []))
similar_candidates = vector_store.search_similar_candidates(
candidate_skills, k=3
)
# Шаг 3: Сгенерировать персонализированные вопросы через LLM
self.update_state(
state='PENDING',
meta={'status': 'Генерируем вопросы для интервью...', 'progress': 70}
state="PENDING",
meta={"status": "Генерируем вопросы для интервью...", "progress": 70},
)
questions_prompt = f"""
Сгенерируй 10 персонализированных вопросов для интервью кандидата на основе его резюме и описания вакансии.
РЕЗЮМЕ КАНДИДАТА:
Имя: {resume.parsed_data.get('name', 'Не указано')}
Навыки: {', '.join(resume.parsed_data.get('skills', []))}
Опыт работы: {resume.parsed_data.get('total_years', 0)} лет
Образование: {resume.parsed_data.get('education', 'Не указано')}
Имя: {resume.parsed_data.get("name", "Не указано")}
Навыки: {", ".join(resume.parsed_data.get("skills", []))}
Опыт работы: {resume.parsed_data.get("total_years", 0)} лет
Образование: {resume.parsed_data.get("education", "Не указано")}
ОПИСАНИЕ ВАКАНСИИ:
{job_description}
@ -339,77 +351,84 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str
]
}}
"""
from langchain.schema import HumanMessage, SystemMessage
messages = [
SystemMessage(content="Ты эксперт по проведению технических интервью. Генерируй качественные, персонализированные вопросы."),
HumanMessage(content=questions_prompt)
SystemMessage(
content="Ты эксперт по проведению технических интервью. Генерируй качественные, персонализированные вопросы."
),
HumanMessage(content=questions_prompt),
]
response = chat_model.get_llm().invoke(messages)
# Парсим ответ
import json
response_text = response.content.strip()
# Извлекаем JSON из ответа
if response_text.startswith('{') and response_text.endswith('}'):
if response_text.startswith("{") and response_text.endswith("}"):
questions_data = json.loads(response_text)
else:
# Ищем JSON внутри текста
start = response_text.find('{')
end = response_text.rfind('}') + 1
start = response_text.find("{")
end = response_text.rfind("}") + 1
if start != -1 and end > start:
json_str = response_text[start:end]
questions_data = json.loads(json_str)
else:
raise ValueError("JSON не найден в ответе LLM")
# Шаг 4: Сохранить вопросы в notes резюме (пока так, потом можно создать отдельную таблицу)
self.update_state(
state='PENDING',
meta={'status': 'Сохраняем вопросы...', 'progress': 90}
state="PENDING", meta={"status": "Сохраняем вопросы...", "progress": 90}
)
with get_sync_session() as session:
repo = SyncResumeRepository(session)
resume = repo.get_by_id(int(resume_id))
if resume:
# Сохраняем вопросы в notes (временно)
existing_notes = resume.notes or ""
interview_questions = json.dumps(questions_data, ensure_ascii=False, indent=2)
resume.notes = f"{existing_notes}\n\nINTERVIEW QUESTIONS:\n{interview_questions}"
interview_questions = json.dumps(
questions_data, ensure_ascii=False, indent=2
)
resume.notes = (
f"{existing_notes}\n\nINTERVIEW QUESTIONS:\n{interview_questions}"
)
from datetime import datetime
resume.updated_at = datetime.utcnow()
session.add(resume)
# Завершено успешно
self.update_state(
state='SUCCESS',
state="SUCCESS",
meta={
'status': 'Вопросы для интервью успешно сгенерированы',
'progress': 100,
'result': questions_data
}
"status": "Вопросы для интервью успешно сгенерированы",
"progress": 100,
"result": questions_data,
},
)
return {
'resume_id': resume_id,
'status': 'questions_generated',
'questions': questions_data['questions']
"resume_id": resume_id,
"status": "questions_generated",
"questions": questions_data["questions"],
}
except Exception as e:
# В случае ошибки
self.update_state(
state='FAILURE',
state="FAILURE",
meta={
'status': f'Ошибка при генерации вопросов: {str(e)}',
'progress': 0,
'error': str(e)
}
"status": f"Ошибка при генерации вопросов: {str(e)}",
"progress": 0,
"error": str(e),
},
)
raise Exception(f"Ошибка при генерации вопросов: {str(e)}")
raise Exception(f"Ошибка при генерации вопросов: {str(e)}")

32
main.py
View File

@ -1,24 +1,42 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.core.session_middleware import SessionMiddleware
from app.routers import vacancy_router, resume_router
from app.routers.session_router import router as session_router
from app.routers.interview_router import router as interview_router
from app.routers.analysis_router import router as analysis_router
from app.routers import resume_router, vacancy_router
from app.routers.admin_router import router as admin_router
from app.routers.analysis_router import router as analysis_router
from app.routers.interview_router import router as interview_router
from app.routers.session_router import router as session_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Запускаем AI агента при старте приложения
from app.services.agent_manager import agent_manager
print("[STARTUP] Starting AI Agent...")
success = await agent_manager.start_agent()
if success:
print("[STARTUP] AI Agent started successfully")
else:
print("[STARTUP] Failed to start AI Agent")
yield
# Останавливаем AI агента при завершении приложения
print("[SHUTDOWN] Stopping AI Agent...")
await agent_manager.stop_agent()
print("[SHUTDOWN] AI Agent stopped")
app = FastAPI(
title="HR AI Backend",
description="Backend API for HR AI system with vacancies and resumes management",
version="1.0.0",
lifespan=lifespan
lifespan=lifespan,
)
app.add_middleware(
@ -47,4 +65,4 @@ async def root():
@app.get("/health")
async def health_check():
return {"status": "healthy"}
return {"status": "healthy"}

View File

@ -1,11 +1,9 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from sqlmodel import SQLModel
from app.core.config import settings
@ -75,9 +73,7 @@ def run_migrations_online() -> None:
await connection.run_sync(do_run_migrations)
def do_run_migrations(connection):
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

View File

@ -5,26 +5,26 @@ Revises: 4d04e6e32445
Create Date: 2025-09-02 23:38:36.541565
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1a2cda4df181'
down_revision: Union[str, Sequence[str], None] = '4d04e6e32445'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "1a2cda4df181"
down_revision: str | Sequence[str] | None = "4d04e6e32445"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Add interview_plan column to resume table
op.add_column('resume', sa.Column('interview_plan', sa.JSON(), nullable=True))
op.add_column("resume", sa.Column("interview_plan", sa.JSON(), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
# Drop interview_plan column
op.drop_column('resume', 'interview_plan')
op.drop_column("resume", "interview_plan")

View File

@ -5,28 +5,32 @@ Revises: 4723b138a3bb
Create Date: 2025-09-02 20:00:00.689080
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '385d03e3281c'
down_revision: Union[str, Sequence[str], None] = '4723b138a3bb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "385d03e3281c"
down_revision: str | Sequence[str] | None = "4723b138a3bb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Create InterviewStatus enum type
interview_status_enum = sa.Enum('created', 'active', 'completed', 'failed', name='interviewstatus')
interview_status_enum = sa.Enum(
"created", "active", "completed", "failed", name="interviewstatus"
)
interview_status_enum.create(op.get_bind())
def downgrade() -> None:
"""Downgrade schema."""
# Drop InterviewStatus enum type
interview_status_enum = sa.Enum('created', 'active', 'completed', 'failed', name='interviewstatus')
interview_status_enum = sa.Enum(
"created", "active", "completed", "failed", name="interviewstatus"
)
interview_status_enum.drop(op.get_bind())

View File

@ -5,43 +5,48 @@ Revises: dba37152ae9a
Create Date: 2025-09-02 19:31:03.531702
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4723b138a3bb'
down_revision: Union[str, Sequence[str], None] = 'dba37152ae9a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "4723b138a3bb"
down_revision: str | Sequence[str] | None = "dba37152ae9a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'interview_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resume_id', sa.Integer(), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('ai_feedback', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_name')
"interview_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("resume_id", sa.Integer(), nullable=False),
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("transcript", sa.Text(), nullable=True),
sa.Column("ai_feedback", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["resume_id"],
["resume.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_name"),
)
op.create_index(
op.f("ix_interview_sessions_id"), "interview_sessions", ["id"], unique=False
)
op.create_index(op.f('ix_interview_sessions_id'), 'interview_sessions', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_interview_sessions_id'), table_name='interview_sessions')
op.drop_table('interview_sessions')
op.drop_index(op.f("ix_interview_sessions_id"), table_name="interview_sessions")
op.drop_table("interview_sessions")
# ### end Alembic commands ###

View File

@ -5,59 +5,83 @@ Revises: 96ffcf34e1de
Create Date: 2025-09-02 20:10:52.321402
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4d04e6e32445'
down_revision: Union[str, Sequence[str], None] = '96ffcf34e1de'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "4d04e6e32445"
down_revision: str | Sequence[str] | None = "96ffcf34e1de"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Recreate interview_sessions table with proper enum (enum already exists)
op.drop_index(op.f('ix_interview_sessions_id'), table_name='interview_sessions')
op.drop_table('interview_sessions')
op.drop_index(op.f("ix_interview_sessions_id"), table_name="interview_sessions")
op.drop_table("interview_sessions")
# Create table with existing enum type
op.create_table('interview_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resume_id', sa.Integer(), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=False),
sa.Column('status', postgresql.ENUM('created', 'active', 'completed', 'failed', name='interviewstatus', create_type=False), nullable=False),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('ai_feedback', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_name')
op.create_table(
"interview_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("resume_id", sa.Integer(), nullable=False),
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"created",
"active",
"completed",
"failed",
name="interviewstatus",
create_type=False,
),
nullable=False,
),
sa.Column("transcript", sa.Text(), nullable=True),
sa.Column("ai_feedback", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["resume_id"],
["resume.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_name"),
)
op.create_index(
op.f("ix_interview_sessions_id"), "interview_sessions", ["id"], unique=False
)
op.create_index(op.f('ix_interview_sessions_id'), 'interview_sessions', ['id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_interview_sessions_id'), table_name='interview_sessions')
op.drop_table('interview_sessions')
# Recreate old table structure
op.create_table('interview_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resume_id', sa.Integer(), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('ai_feedback', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_name')
op.drop_index(op.f("ix_interview_sessions_id"), table_name="interview_sessions")
op.drop_table("interview_sessions")
# Recreate old table structure
op.create_table(
"interview_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("resume_id", sa.Integer(), nullable=False),
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("transcript", sa.Text(), nullable=True),
sa.Column("ai_feedback", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["resume_id"],
["resume.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_name"),
)
op.create_index(
op.f("ix_interview_sessions_id"), "interview_sessions", ["id"], unique=False
)
op.create_index(op.f('ix_interview_sessions_id'), 'interview_sessions', ['id'], unique=False)

View File

@ -5,25 +5,25 @@ Revises: ae966b3e742e
Create Date: 2025-08-30 20:38:36.867781
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4e19b8fe4a88'
down_revision: Union[str, Sequence[str], None] = 'ae966b3e742e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "4e19b8fe4a88"
down_revision: str | Sequence[str] | None = "ae966b3e742e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Сначала добавляем колонку как nullable
op.add_column('resume', sa.Column('session_id', sa.Integer(), nullable=True))
op.add_column("resume", sa.Column("session_id", sa.Integer(), nullable=True))
# Создаем временную сессию для существующих резюме (если есть)
op.execute("""
INSERT INTO session (session_id, is_active, expires_at, last_activity, created_at, updated_at)
@ -36,24 +36,24 @@ def upgrade() -> None:
NOW()
WHERE NOT EXISTS (SELECT 1 FROM session LIMIT 1)
""")
# Обновляем существующие резюме, привязывая их к первой сессии
op.execute("""
UPDATE resume
SET session_id = (SELECT id FROM session ORDER BY id LIMIT 1)
WHERE session_id IS NULL
""")
# Теперь делаем колонку NOT NULL
op.alter_column('resume', 'session_id', nullable=False)
op.create_foreign_key(None, 'resume', 'session', ['session_id'], ['id'])
op.alter_column("resume", "session_id", nullable=False)
op.create_foreign_key(None, "resume", "session", ["session_id"], ["id"])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'resume', type_='foreignkey')
op.drop_column('resume', 'session_id')
op.drop_constraint(None, "resume", type_="foreignkey")
op.drop_column("resume", "session_id")
# ### end Alembic commands ###

View File

@ -5,17 +5,16 @@ Revises: a816820baadb
Create Date: 2025-09-04 00:02:15.230498
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '772538626a9e'
down_revision: Union[str, Sequence[str], None] = 'a816820baadb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "772538626a9e"
down_revision: str | Sequence[str] | None = "a816820baadb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@ -26,7 +25,7 @@ def upgrade() -> None:
ALTER COLUMN parsed_data TYPE JSON USING parsed_data::JSON,
ALTER COLUMN interview_plan TYPE JSON USING interview_plan::JSON
""")
op.execute("""
ALTER TABLE interview_sessions
ALTER COLUMN dialogue_history TYPE JSON USING dialogue_history::JSON
@ -41,7 +40,7 @@ def downgrade() -> None:
ALTER COLUMN parsed_data TYPE TEXT USING parsed_data::TEXT,
ALTER COLUMN interview_plan TYPE TEXT USING interview_plan::TEXT
""")
op.execute("""
ALTER TABLE interview_sessions
ALTER COLUMN dialogue_history TYPE TEXT USING dialogue_history::TEXT

View File

@ -5,27 +5,26 @@ Revises: a694f7c9e766
Create Date: 2025-08-30 20:00:00.661534
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7ffa784ab042'
down_revision: Union[str, Sequence[str], None] = 'a694f7c9e766'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "7ffa784ab042"
down_revision: str | Sequence[str] | None = "a694f7c9e766"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add sample vacancies."""
# Create sample vacancies data
vacancies_data = [
{
'title': 'Senior Python Developer',
'description': '''Мы ищем опытного Python-разработчика для работы в команде разработки высоконагруженного веб-сервиса.
"title": "Senior Python Developer",
"description": """Мы ищем опытного Python-разработчика для работы в команде разработки высоконагруженного веб-сервиса.
Обязанности:
Разработка и поддержка API на Python (FastAPI/Django)
@ -45,31 +44,31 @@ def upgrade() -> None:
Будет плюсом:
Опыт работы с облачными сервисами (AWS/GCP)
Знание Go или Node.js
Опыт ведения технических интервью''',
'key_skills': 'Python, FastAPI, Django, PostgreSQL, Redis, Docker, Kubernetes, Микросервисы, REST API, Git',
'employment_type': 'FULL_TIME',
'experience': 'MORE_THAN_6',
'schedule': 'REMOTE',
'salary_from': 250000,
'salary_to': 400000,
'salary_currency': 'RUR',
'gross_salary': False,
'company_name': 'TechCorp Solutions',
'company_description': 'Компания-разработчик инновационных решений в области fintech. У нас работает более 500 специалистов, офисы в Москве и Санкт-Петербурге.',
'area_name': 'Москва',
'metro_stations': 'Сокольники, Красносельская',
'address': 'г. Москва, ул. Русаковская, д. 13',
'professional_roles': 'Программист, разработчик',
'contacts_name': 'Анна Петрова',
'contacts_email': 'hr@techcorp.ru',
'contacts_phone': '+7 (495) 123-45-67',
'is_archived': False,
'premium': True,
'url': 'https://techcorp.ru/careers/senior-python'
Опыт ведения технических интервью""",
"key_skills": "Python, FastAPI, Django, PostgreSQL, Redis, Docker, Kubernetes, Микросервисы, REST API, Git",
"employment_type": "FULL_TIME",
"experience": "MORE_THAN_6",
"schedule": "REMOTE",
"salary_from": 250000,
"salary_to": 400000,
"salary_currency": "RUR",
"gross_salary": False,
"company_name": "TechCorp Solutions",
"company_description": "Компания-разработчик инновационных решений в области fintech. У нас работает более 500 специалистов, офисы в Москве и Санкт-Петербурге.",
"area_name": "Москва",
"metro_stations": "Сокольники, Красносельская",
"address": "г. Москва, ул. Русаковская, д. 13",
"professional_roles": "Программист, разработчик",
"contacts_name": "Анна Петрова",
"contacts_email": "hr@techcorp.ru",
"contacts_phone": "+7 (495) 123-45-67",
"is_archived": False,
"premium": True,
"url": "https://techcorp.ru/careers/senior-python",
},
{
'title': 'Frontend React Developer',
'description': '''Приглашаем талантливого фронтенд-разработчика для создания современных веб-приложений.
"title": "Frontend React Developer",
"description": """Приглашаем талантливого фронтенд-разработчика для создания современных веб-приложений.
Задачи:
Разработка пользовательских интерфейсов на React
@ -91,31 +90,31 @@ def upgrade() -> None:
Гибкий график работы
Медицинское страхование
Обучение за счет компании
Дружная команда профессионалов''',
'key_skills': 'React, TypeScript, JavaScript, HTML5, CSS3, SASS, Redux, Webpack, Git, REST API',
'employment_type': 'FULL_TIME',
'experience': 'BETWEEN_3_AND_6',
'schedule': 'FLEXIBLE',
'salary_from': 150000,
'salary_to': 250000,
'salary_currency': 'RUR',
'gross_salary': False,
'company_name': 'Digital Agency Pro',
'company_description': 'Креативное digital-агентство, специализирующееся на разработке веб-приложений и мобильных решений для крупных брендов.',
'area_name': 'Санкт-Петербург',
'metro_stations': 'Технологический институт, Пушкинская',
'address': 'г. Санкт-Петербург, ул. Правды, д. 10',
'professional_roles': 'Программист, разработчик',
'contacts_name': 'Михаил Сидоров',
'contacts_email': 'jobs@digitalagency.ru',
'contacts_phone': '+7 (812) 987-65-43',
'is_archived': False,
'premium': False,
'url': 'https://digitalagency.ru/vacancy/react-dev'
Дружная команда профессионалов""",
"key_skills": "React, TypeScript, JavaScript, HTML5, CSS3, SASS, Redux, Webpack, Git, REST API",
"employment_type": "FULL_TIME",
"experience": "BETWEEN_3_AND_6",
"schedule": "FLEXIBLE",
"salary_from": 150000,
"salary_to": 250000,
"salary_currency": "RUR",
"gross_salary": False,
"company_name": "Digital Agency Pro",
"company_description": "Креативное digital-агентство, специализирующееся на разработке веб-приложений и мобильных решений для крупных брендов.",
"area_name": "Санкт-Петербург",
"metro_stations": "Технологический институт, Пушкинская",
"address": "г. Санкт-Петербург, ул. Правды, д. 10",
"professional_roles": "Программист, разработчик",
"contacts_name": "Михаил Сидоров",
"contacts_email": "jobs@digitalagency.ru",
"contacts_phone": "+7 (812) 987-65-43",
"is_archived": False,
"premium": False,
"url": "https://digitalagency.ru/vacancy/react-dev",
},
{
'title': 'DevOps Engineer',
'description': '''Ищем DevOps-инженера для автоматизации процессов CI/CD и управления облачной инфраструктурой.
"title": "DevOps Engineer",
"description": """Ищем DevOps-инженера для автоматизации процессов CI/CD и управления облачной инфраструктурой.
Основные задачи:
Проектирование и поддержка CI/CD pipeline
@ -138,31 +137,31 @@ def upgrade() -> None:
Официальное трудоустройство
Компенсация обучения и сертификации
Современное оборудование
Возможность работы из дома''',
'key_skills': 'Docker, Kubernetes, AWS, Terraform, Ansible, Jenkins, GitLab CI/CD, Prometheus, Grafana, Linux',
'employment_type': 'FULL_TIME',
'experience': 'BETWEEN_3_AND_6',
'schedule': 'REMOTE',
'salary_from': 200000,
'salary_to': 350000,
'salary_currency': 'RUR',
'gross_salary': False,
'company_name': 'CloudTech Systems',
'company_description': 'Системный интегратор, специализирующийся на внедрении облачных решений и автоматизации IT-процессов для корпоративных клиентов.',
'area_name': 'Москва',
'metro_stations': 'Белорусская, Маяковская',
'address': 'г. Москва, Тверская ул., д. 25',
'professional_roles': 'Системный администратор, DevOps',
'contacts_name': 'Елена Васильева',
'contacts_email': 'hr@cloudtech.ru',
'contacts_phone': '+7 (495) 555-12-34',
'is_archived': False,
'premium': True,
'url': 'https://cloudtech.ru/careers/devops'
Возможность работы из дома""",
"key_skills": "Docker, Kubernetes, AWS, Terraform, Ansible, Jenkins, GitLab CI/CD, Prometheus, Grafana, Linux",
"employment_type": "FULL_TIME",
"experience": "BETWEEN_3_AND_6",
"schedule": "REMOTE",
"salary_from": 200000,
"salary_to": 350000,
"salary_currency": "RUR",
"gross_salary": False,
"company_name": "CloudTech Systems",
"company_description": "Системный интегратор, специализирующийся на внедрении облачных решений и автоматизации IT-процессов для корпоративных клиентов.",
"area_name": "Москва",
"metro_stations": "Белорусская, Маяковская",
"address": "г. Москва, Тверская ул., д. 25",
"professional_roles": "Системный администратор, DevOps",
"contacts_name": "Елена Васильева",
"contacts_email": "hr@cloudtech.ru",
"contacts_phone": "+7 (495) 555-12-34",
"is_archived": False,
"premium": True,
"url": "https://cloudtech.ru/careers/devops",
},
{
'title': 'Junior Java Developer',
'description': '''Приглашаем начинающего Java-разработчика для участия в крупных enterprise-проектах.
"title": "Junior Java Developer",
"description": """Приглашаем начинающего Java-разработчика для участия в крупных enterprise-проектах.
Обязанности:
Разработка backend-сервисов на Java
@ -185,31 +184,31 @@ def upgrade() -> None:
Карьерный рост
Стабильную зарплату
Молодая и амбициозная команда
Интересные проекты в финтех сфере''',
'key_skills': 'Java, Spring Framework, SQL, Git, REST API, JUnit, Maven, PostgreSQL',
'employment_type': 'FULL_TIME',
'experience': 'BETWEEN_1_AND_3',
'schedule': 'FULL_DAY',
'salary_from': 80000,
'salary_to': 120000,
'salary_currency': 'RUR',
'gross_salary': False,
'company_name': 'FinTech Innovations',
'company_description': 'Быстро развивающийся стартап в области финансовых технологий. Создаем инновационные решения для банков и финансовых институтов.',
'area_name': 'Екатеринбург',
'metro_stations': 'Площадь 1905 года, Динамо',
'address': 'г. Екатеринбург, ул. Ленина, д. 33',
'professional_roles': 'Программист, разработчик',
'contacts_name': 'Дмитрий Козлов',
'contacts_email': 'recruitment@fintech-inn.ru',
'contacts_phone': '+7 (343) 456-78-90',
'is_archived': False,
'premium': False,
'url': 'https://fintech-inn.ru/jobs/java-junior'
Интересные проекты в финтех сфере""",
"key_skills": "Java, Spring Framework, SQL, Git, REST API, JUnit, Maven, PostgreSQL",
"employment_type": "FULL_TIME",
"experience": "BETWEEN_1_AND_3",
"schedule": "FULL_DAY",
"salary_from": 80000,
"salary_to": 120000,
"salary_currency": "RUR",
"gross_salary": False,
"company_name": "FinTech Innovations",
"company_description": "Быстро развивающийся стартап в области финансовых технологий. Создаем инновационные решения для банков и финансовых институтов.",
"area_name": "Екатеринбург",
"metro_stations": "Площадь 1905 года, Динамо",
"address": "г. Екатеринбург, ул. Ленина, д. 33",
"professional_roles": "Программист, разработчик",
"contacts_name": "Дмитрий Козлов",
"contacts_email": "recruitment@fintech-inn.ru",
"contacts_phone": "+7 (343) 456-78-90",
"is_archived": False,
"premium": False,
"url": "https://fintech-inn.ru/jobs/java-junior",
},
{
'title': 'Product Manager IT',
'description': '''Ищем опытного продуктового менеджера для управления развитием digital-продуктов.
"title": "Product Manager IT",
"description": """Ищем опытного продуктового менеджера для управления развитием digital-продуктов.
Основные задачи:
Управление продуктовой стратегией и roadmap
@ -234,30 +233,30 @@ def upgrade() -> None:
Работу с топ-менеджментом компании
Современные инструменты и методики
Конкурентную заработную плату
Полный соц. пакет и ДМС''',
'key_skills': 'Product Management, Agile, Scrum, Аналитика, UX/UI, Jira, A/B тестирование, User Research',
'employment_type': 'FULL_TIME',
'experience': 'BETWEEN_3_AND_6',
'schedule': 'FLEXIBLE',
'salary_from': 180000,
'salary_to': 280000,
'salary_currency': 'RUR',
'gross_salary': False,
'company_name': 'Marketplace Solutions',
'company_description': 'Один из лидеров российского e-commerce рынка. Развиваем крупнейшую онлайн-платформу с миллионами пользователей.',
'area_name': 'Москва',
'metro_stations': 'Парк культуры, Сокольники',
'address': 'г. Москва, Садовая-Триумфальная ул., д. 4/10',
'professional_roles': 'Менеджер продукта, Product Manager',
'contacts_name': 'Ольга Смирнова',
'contacts_email': 'pm-jobs@marketplace.ru',
'contacts_phone': '+7 (495) 777-88-99',
'is_archived': False,
'premium': True,
'url': 'https://marketplace.ru/career/product-manager'
}
Полный соц. пакет и ДМС""",
"key_skills": "Product Management, Agile, Scrum, Аналитика, UX/UI, Jira, A/B тестирование, User Research",
"employment_type": "FULL_TIME",
"experience": "BETWEEN_3_AND_6",
"schedule": "FLEXIBLE",
"salary_from": 180000,
"salary_to": 280000,
"salary_currency": "RUR",
"gross_salary": False,
"company_name": "Marketplace Solutions",
"company_description": "Один из лидеров российского e-commerce рынка. Развиваем крупнейшую онлайн-платформу с миллионами пользователей.",
"area_name": "Москва",
"metro_stations": "Парк культуры, Сокольники",
"address": "г. Москва, Садовая-Триумфальная ул., д. 4/10",
"professional_roles": "Менеджер продукта, Product Manager",
"contacts_name": "Ольга Смирнова",
"contacts_email": "pm-jobs@marketplace.ru",
"contacts_phone": "+7 (495) 777-88-99",
"is_archived": False,
"premium": True,
"url": "https://marketplace.ru/career/product-manager",
},
]
# Insert vacancies using raw SQL with proper enum casting
for vacancy_data in vacancies_data:
op.execute(f"""
@ -268,29 +267,29 @@ def upgrade() -> None:
professional_roles, contacts_name, contacts_email, contacts_phone,
is_archived, premium, published_at, url, created_at, updated_at
) VALUES (
'{vacancy_data['title']}',
'{vacancy_data['description'].replace("'", "''")}',
'{vacancy_data['key_skills']}',
'{vacancy_data['employment_type']}'::employmenttype,
'{vacancy_data['experience']}'::experience,
'{vacancy_data['schedule']}'::schedule,
{vacancy_data['salary_from']},
{vacancy_data['salary_to']},
'{vacancy_data['salary_currency']}',
{vacancy_data['gross_salary']},
'{vacancy_data['company_name']}',
'{vacancy_data['company_description'].replace("'", "''")}',
'{vacancy_data['area_name']}',
'{vacancy_data['metro_stations']}',
'{vacancy_data['address']}',
'{vacancy_data['professional_roles']}',
'{vacancy_data['contacts_name']}',
'{vacancy_data['contacts_email']}',
'{vacancy_data['contacts_phone']}',
{vacancy_data['is_archived']},
{vacancy_data['premium']},
'{vacancy_data["title"]}',
'{vacancy_data["description"].replace("'", "''")}',
'{vacancy_data["key_skills"]}',
'{vacancy_data["employment_type"]}'::employmenttype,
'{vacancy_data["experience"]}'::experience,
'{vacancy_data["schedule"]}'::schedule,
{vacancy_data["salary_from"]},
{vacancy_data["salary_to"]},
'{vacancy_data["salary_currency"]}',
{vacancy_data["gross_salary"]},
'{vacancy_data["company_name"]}',
'{vacancy_data["company_description"].replace("'", "''")}',
'{vacancy_data["area_name"]}',
'{vacancy_data["metro_stations"]}',
'{vacancy_data["address"]}',
'{vacancy_data["professional_roles"]}',
'{vacancy_data["contacts_name"]}',
'{vacancy_data["contacts_email"]}',
'{vacancy_data["contacts_phone"]}',
{vacancy_data["is_archived"]},
{vacancy_data["premium"]},
NOW(),
'{vacancy_data['url']}',
'{vacancy_data["url"]}',
NOW(),
NOW()
)
@ -301,12 +300,12 @@ def downgrade() -> None:
"""Remove sample vacancies."""
# Remove the sample vacancies by their unique titles
sample_titles = [
'Senior Python Developer',
'Frontend React Developer',
'DevOps Engineer',
'Junior Java Developer',
'Product Manager IT'
"Senior Python Developer",
"Frontend React Developer",
"DevOps Engineer",
"Junior Java Developer",
"Product Manager IT",
]
for title in sample_titles:
op.execute(f"DELETE FROM vacancy WHERE title = '{title}'")

View File

@ -5,23 +5,24 @@ Revises: 385d03e3281c
Create Date: 2025-09-02 20:01:52.904608
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '96ffcf34e1de'
down_revision: Union[str, Sequence[str], None] = '385d03e3281c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "96ffcf34e1de"
down_revision: str | Sequence[str] | None = "385d03e3281c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Update status column to use interviewstatus enum
op.execute("ALTER TABLE interview_sessions ALTER COLUMN status TYPE interviewstatus USING status::interviewstatus")
op.execute(
"ALTER TABLE interview_sessions ALTER COLUMN status TYPE interviewstatus USING status::interviewstatus"
)
def downgrade() -> None:

View File

@ -5,25 +5,26 @@ Revises: 772538626a9e
Create Date: 2025-09-04 12:16:56.495018
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9c60c15f7846'
down_revision: Union[str, Sequence[str], None] = '772538626a9e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "9c60c15f7846"
down_revision: str | Sequence[str] | None = "772538626a9e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create interview reports table with scoring fields."""
# Create enum type for recommendation
op.execute("CREATE TYPE recommendationtype AS ENUM ('STRONGLY_RECOMMEND', 'RECOMMEND', 'CONSIDER', 'REJECT')")
op.execute(
"CREATE TYPE recommendationtype AS ENUM ('STRONGLY_RECOMMEND', 'RECOMMEND', 'CONSIDER', 'REJECT')"
)
# Create interview_reports table
op.execute("""
CREATE TABLE interview_reports (
@ -84,13 +85,23 @@ def upgrade() -> None:
FOREIGN KEY (interview_session_id) REFERENCES interview_sessions(id)
)
""")
# Create useful indexes
op.execute("CREATE INDEX idx_interview_reports_overall_score ON interview_reports (overall_score DESC)")
op.execute("CREATE INDEX idx_interview_reports_recommendation ON interview_reports (recommendation)")
op.execute("CREATE INDEX idx_interview_reports_technical_skills ON interview_reports (technical_skills_score DESC)")
op.execute("CREATE INDEX idx_interview_reports_communication ON interview_reports (communication_score DESC)")
op.execute("CREATE INDEX idx_interview_reports_session_id ON interview_reports (interview_session_id)")
op.execute(
"CREATE INDEX idx_interview_reports_overall_score ON interview_reports (overall_score DESC)"
)
op.execute(
"CREATE INDEX idx_interview_reports_recommendation ON interview_reports (recommendation)"
)
op.execute(
"CREATE INDEX idx_interview_reports_technical_skills ON interview_reports (technical_skills_score DESC)"
)
op.execute(
"CREATE INDEX idx_interview_reports_communication ON interview_reports (communication_score DESC)"
)
op.execute(
"CREATE INDEX idx_interview_reports_session_id ON interview_reports (interview_session_id)"
)
def downgrade() -> None:

View File

@ -5,41 +5,56 @@ Revises: 53d8b753cb71
Create Date: 2025-09-03 18:04:49.726882
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '9d415bf0ff2e'
down_revision: Union[str, Sequence[str], None] = 'c2d48b31ee30'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "9d415bf0ff2e"
down_revision: str | Sequence[str] | None = "c2d48b31ee30"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Сначала создаем таблицу interview_sessions (если была удалена)
op.create_table('interview_sessions',
sa.Column('resume_id', sa.Integer(), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=False),
sa.Column('status', sa.Enum('created', 'active', 'completed', 'failed', name='interviewstatus', create_type=False), nullable=True),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('ai_feedback', sa.Text(), nullable=True),
sa.Column('dialogue_history', sa.JSON(), nullable=True),
sa.Column('ai_agent_pid', sa.Integer(), nullable=True),
sa.Column('ai_agent_status', sa.String(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_name')
op.create_table(
"interview_sessions",
sa.Column("resume_id", sa.Integer(), nullable=False),
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column(
"status",
sa.Enum(
"created",
"active",
"completed",
"failed",
name="interviewstatus",
create_type=False,
),
nullable=True,
),
sa.Column("transcript", sa.Text(), nullable=True),
sa.Column("ai_feedback", sa.Text(), nullable=True),
sa.Column("dialogue_history", sa.JSON(), nullable=True),
sa.Column("ai_agent_pid", sa.Integer(), nullable=True),
sa.Column("ai_agent_status", sa.String(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["resume_id"],
["resume.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_name"),
)
def downgrade() -> None:
"""Downgrade schema."""
# Удаляем всю таблицу
op.drop_table('interview_sessions')
op.drop_table("interview_sessions")

View File

@ -1,71 +1,156 @@
"""initial
Revision ID: a694f7c9e766
Revises:
Revises:
Create Date: 2025-08-30 19:48:53.070679
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a694f7c9e766'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "a694f7c9e766"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('vacancy',
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('key_skills', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('employment_type', sa.Enum('FULL_TIME', 'PART_TIME', 'PROJECT', 'VOLUNTEER', 'PROBATION', name='employmenttype'), nullable=False),
sa.Column('experience', sa.Enum('NO_EXPERIENCE', 'BETWEEN_1_AND_3', 'BETWEEN_3_AND_6', 'MORE_THAN_6', name='experience'), nullable=False),
sa.Column('schedule', sa.Enum('FULL_DAY', 'SHIFT', 'FLEXIBLE', 'REMOTE', 'FLY_IN_FLY_OUT', name='schedule'), nullable=False),
sa.Column('salary_from', sa.Integer(), nullable=True),
sa.Column('salary_to', sa.Integer(), nullable=True),
sa.Column('salary_currency', sqlmodel.sql.sqltypes.AutoString(length=3), nullable=True),
sa.Column('gross_salary', sa.Boolean(), nullable=True),
sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('company_description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('area_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('metro_stations', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('professional_roles', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('contacts_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('contacts_email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('contacts_phone', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.Column('premium', sa.Boolean(), nullable=False),
sa.Column('published_at', sa.DateTime(), nullable=True),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_table(
"vacancy",
sa.Column(
"title", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("key_skills", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"employment_type",
sa.Enum(
"FULL_TIME",
"PART_TIME",
"PROJECT",
"VOLUNTEER",
"PROBATION",
name="employmenttype",
),
nullable=False,
),
sa.Column(
"experience",
sa.Enum(
"NO_EXPERIENCE",
"BETWEEN_1_AND_3",
"BETWEEN_3_AND_6",
"MORE_THAN_6",
name="experience",
),
nullable=False,
),
sa.Column(
"schedule",
sa.Enum(
"FULL_DAY",
"SHIFT",
"FLEXIBLE",
"REMOTE",
"FLY_IN_FLY_OUT",
name="schedule",
),
nullable=False,
),
sa.Column("salary_from", sa.Integer(), nullable=True),
sa.Column("salary_to", sa.Integer(), nullable=True),
sa.Column(
"salary_currency", sqlmodel.sql.sqltypes.AutoString(length=3), nullable=True
),
sa.Column("gross_salary", sa.Boolean(), nullable=True),
sa.Column(
"company_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column(
"company_description", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column(
"area_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("metro_stations", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("address", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"professional_roles", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column(
"contacts_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True
),
sa.Column(
"contacts_email",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=True,
),
sa.Column(
"contacts_phone", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True
),
sa.Column("is_archived", sa.Boolean(), nullable=False),
sa.Column("premium", sa.Boolean(), nullable=False),
sa.Column("published_at", sa.DateTime(), nullable=True),
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('resume',
sa.Column('vacancy_id', sa.Integer(), nullable=False),
sa.Column('applicant_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('applicant_email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('applicant_phone', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True),
sa.Column('resume_file_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('cover_letter', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('status', sa.Enum('PENDING', 'UNDER_REVIEW', 'INTERVIEW_SCHEDULED', 'INTERVIEWED', 'REJECTED', 'ACCEPTED', name='resumestatus'), nullable=False),
sa.Column('interview_report_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['vacancy_id'], ['vacancy.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"resume",
sa.Column("vacancy_id", sa.Integer(), nullable=False),
sa.Column(
"applicant_name",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=False,
),
sa.Column(
"applicant_email",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=False,
),
sa.Column(
"applicant_phone",
sqlmodel.sql.sqltypes.AutoString(length=50),
nullable=True,
),
sa.Column(
"resume_file_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False
),
sa.Column("cover_letter", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"status",
sa.Enum(
"PENDING",
"UNDER_REVIEW",
"INTERVIEW_SCHEDULED",
"INTERVIEWED",
"REJECTED",
"ACCEPTED",
name="resumestatus",
),
nullable=False,
),
sa.Column(
"interview_report_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["vacancy_id"],
["vacancy.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
@ -73,6 +158,6 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('resume')
op.drop_table('vacancy')
op.drop_table("resume")
op.drop_table("vacancy")
# ### end Alembic commands ###

View File

@ -5,17 +5,17 @@ Revises: c9bcdd2ddeeb
Create Date: 2025-09-03 23:45:13.221735
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a816820baadb'
down_revision: Union[str, Sequence[str], None] = 'c9bcdd2ddeeb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "a816820baadb"
down_revision: str | Sequence[str] | None = "c9bcdd2ddeeb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@ -26,17 +26,20 @@ def upgrade() -> None:
ALTER COLUMN parsed_data TYPE TEXT USING parsed_data::TEXT,
ALTER COLUMN interview_plan TYPE TEXT USING interview_plan::TEXT
""")
op.execute("""
ALTER TABLE interview_sessions
ALTER COLUMN dialogue_history TYPE TEXT USING dialogue_history::TEXT
""")
# Also fix status column
op.alter_column('interview_sessions', 'status',
existing_type=sa.VARCHAR(length=50),
nullable=False,
existing_server_default=sa.text("'created'::character varying"))
op.alter_column(
"interview_sessions",
"status",
existing_type=sa.VARCHAR(length=50),
nullable=False,
existing_server_default=sa.text("'created'::character varying"),
)
def downgrade() -> None:
@ -47,13 +50,16 @@ def downgrade() -> None:
ALTER COLUMN parsed_data TYPE JSON USING parsed_data::JSON,
ALTER COLUMN interview_plan TYPE JSON USING interview_plan::JSON
""")
op.execute("""
ALTER TABLE interview_sessions
ALTER COLUMN dialogue_history TYPE JSON USING dialogue_history::JSON
""")
op.alter_column('interview_sessions', 'status',
existing_type=sa.VARCHAR(length=50),
nullable=True,
existing_server_default=sa.text("'created'::character varying"))
op.alter_column(
"interview_sessions",
"status",
existing_type=sa.VARCHAR(length=50),
nullable=True,
existing_server_default=sa.text("'created'::character varying"),
)

View File

@ -5,42 +5,51 @@ Revises: 7ffa784ab042
Create Date: 2025-08-30 20:10:57.802953
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'ae966b3e742e'
down_revision: Union[str, Sequence[str], None] = '7ffa784ab042'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "ae966b3e742e"
down_revision: str | Sequence[str] | None = "7ffa784ab042"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('session',
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('user_agent', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(length=45), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_activity', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_table(
"session",
sa.Column(
"session_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column(
"user_agent", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True
),
sa.Column(
"ip_address", sqlmodel.sql.sqltypes.AutoString(length=45), nullable=True
),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("last_activity", sa.DateTime(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_session_session_id"), "session", ["session_id"], unique=True
)
op.create_index(op.f('ix_session_session_id'), 'session', ['session_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_session_session_id'), table_name='session')
op.drop_table('session')
op.drop_index(op.f("ix_session_session_id"), table_name="session")
op.drop_table("session")
# ### end Alembic commands ###

View File

@ -5,44 +5,74 @@ Revises: de11b016b35a
Create Date: 2025-09-03 17:55:41.653125
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c2d48b31ee30'
down_revision: Union[str, Sequence[str], None] = 'de11b016b35a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "c2d48b31ee30"
down_revision: str | Sequence[str] | None = "de11b016b35a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_interview_sessions_id'), table_name='interview_sessions')
op.drop_table('interview_sessions')
op.drop_index(op.f("ix_interview_sessions_id"), table_name="interview_sessions")
op.drop_table("interview_sessions")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('interview_sessions',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('resume_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('room_name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('status', postgresql.ENUM('created', 'active', 'completed', 'failed', name='interviewstatus'), autoincrement=False, nullable=False),
sa.Column('transcript', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('ai_feedback', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('started_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('completed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('ai_agent_pid', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('ai_agent_status', sa.VARCHAR(), server_default=sa.text("'not_started'::character varying"), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], name=op.f('interview_sessions_resume_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('interview_sessions_pkey')),
sa.UniqueConstraint('room_name', name=op.f('interview_sessions_room_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
op.create_table(
"interview_sessions",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column("resume_id", sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column(
"room_name", sa.VARCHAR(length=255), autoincrement=False, nullable=False
),
sa.Column(
"status",
postgresql.ENUM(
"created", "active", "completed", "failed", name="interviewstatus"
),
autoincrement=False,
nullable=False,
),
sa.Column("transcript", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("ai_feedback", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"started_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False
),
sa.Column(
"completed_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
sa.Column("ai_agent_pid", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"ai_agent_status",
sa.VARCHAR(),
server_default=sa.text("'not_started'::character varying"),
autoincrement=False,
nullable=False,
),
sa.ForeignKeyConstraint(
["resume_id"], ["resume.id"], name=op.f("interview_sessions_resume_id_fkey")
),
sa.PrimaryKeyConstraint("id", name=op.f("interview_sessions_pkey")),
sa.UniqueConstraint(
"room_name",
name=op.f("interview_sessions_room_name_key"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_index(
op.f("ix_interview_sessions_id"), "interview_sessions", ["id"], unique=False
)
op.create_index(op.f('ix_interview_sessions_id'), 'interview_sessions', ['id'], unique=False)
# ### end Alembic commands ###

View File

@ -5,42 +5,56 @@ Revises: 9d415bf0ff2e
Create Date: 2025-09-03 18:07:59.433986
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c9bcdd2ddeeb'
down_revision: Union[str, Sequence[str], None] = '9d415bf0ff2e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "c9bcdd2ddeeb"
down_revision: str | Sequence[str] | None = "9d415bf0ff2e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
"""Upgrade schema."""
# Создаем таблицу interview_sessions заново
op.execute("DROP TABLE IF EXISTS interview_sessions CASCADE")
op.create_table('interview_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resume_id', sa.Integer(), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(50), nullable=True, server_default='created'),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('ai_feedback', sa.Text(), nullable=True),
sa.Column('dialogue_history', sa.JSON(), nullable=True),
sa.Column('ai_agent_pid', sa.Integer(), nullable=True),
sa.Column('ai_agent_status', sa.String(50), nullable=False, server_default='not_started'),
sa.Column('started_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_name')
op.create_table(
"interview_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("resume_id", sa.Integer(), nullable=False),
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(50), nullable=True, server_default="created"),
sa.Column("transcript", sa.Text(), nullable=True),
sa.Column("ai_feedback", sa.Text(), nullable=True),
sa.Column("dialogue_history", sa.JSON(), nullable=True),
sa.Column("ai_agent_pid", sa.Integer(), nullable=True),
sa.Column(
"ai_agent_status",
sa.String(50),
nullable=False,
server_default="not_started",
),
sa.Column(
"started_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["resume_id"],
["resume.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("room_name"),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('interview_sessions')
op.drop_table("interview_sessions")

View File

@ -5,18 +5,18 @@ Revises: 4e19b8fe4a88
Create Date: 2025-09-02 14:45:30.749202
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'dba37152ae9a'
down_revision: Union[str, Sequence[str], None] = '4e19b8fe4a88'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "dba37152ae9a"
down_revision: str | Sequence[str] | None = "4e19b8fe4a88"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@ -25,19 +25,22 @@ def upgrade() -> None:
op.execute("ALTER TYPE resumestatus ADD VALUE IF NOT EXISTS 'PARSING'")
op.execute("ALTER TYPE resumestatus ADD VALUE IF NOT EXISTS 'PARSED'")
op.execute("ALTER TYPE resumestatus ADD VALUE IF NOT EXISTS 'PARSE_FAILED'")
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('resume', sa.Column('parsed_data', sa.JSON(), nullable=True))
op.add_column('resume', sa.Column('parse_error', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column("resume", sa.Column("parsed_data", sa.JSON(), nullable=True))
op.add_column(
"resume",
sa.Column("parse_error", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('resume', 'parse_error')
op.drop_column('resume', 'parsed_data')
op.drop_column("resume", "parse_error")
op.drop_column("resume", "parsed_data")
# ### end Alembic commands ###
# Note: Cannot remove ENUM values in PostgreSQL, they are permanent once added
# If needed, would require recreating the ENUM type

View File

@ -5,28 +5,35 @@ Revises: 1a2cda4df181
Create Date: 2025-09-03 00:02:24.263636
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'de11b016b35a'
down_revision: Union[str, Sequence[str], None] = '1a2cda4df181'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "de11b016b35a"
down_revision: str | Sequence[str] | None = "1a2cda4df181"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# Add AI agent process tracking columns
op.add_column('interview_sessions', sa.Column('ai_agent_pid', sa.Integer(), nullable=True))
op.add_column('interview_sessions', sa.Column('ai_agent_status', sa.String(), server_default='not_started', nullable=False))
op.add_column(
"interview_sessions", sa.Column("ai_agent_pid", sa.Integer(), nullable=True)
)
op.add_column(
"interview_sessions",
sa.Column(
"ai_agent_status", sa.String(), server_default="not_started", nullable=False
),
)
def downgrade() -> None:
"""Downgrade schema."""
# Drop AI agent process tracking columns
op.drop_column('interview_sessions', 'ai_agent_status')
op.drop_column('interview_sessions', 'ai_agent_pid')
op.drop_column("interview_sessions", "ai_agent_status")
op.drop_column("interview_sessions", "ai_agent_pid")

View File

@ -41,19 +41,35 @@ dev-dependencies = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"httpx>=0.25.0",
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
"mypy>=1.7.0",
"ruff>=0.12.12",
]
[tool.black]
[tool.ruff]
line-length = 88
target-version = ['py311']
target-version = "py311"
[tool.isort]
profile = "black"
line_length = 88
[tool.ruff.lint]
# Enable equivalent of flake8 rules
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by formatter
]
[tool.ruff.format]
# Equivalent to black configuration
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.mypy]
python_version = "3.11"

View File

@ -1,13 +1,14 @@
#!/usr/bin/env python3
"""Quick API testing script"""
import requests
import json
import time
from pathlib import Path
import requests
BASE_URL = "http://localhost:8000"
def test_health():
"""Test API health"""
try:
@ -18,6 +19,7 @@ def test_health():
print(f"API not available: {str(e)}")
return False
def upload_test_resume():
"""Upload test resume"""
try:
@ -26,62 +28,63 @@ def upload_test_resume():
if not resume_path.exists():
print("test_resume.txt not found!")
return None
# Upload file
with open(resume_path, 'r', encoding='utf-8') as f:
files = {'file': (resume_path.name, f, 'text/plain')}
with open(resume_path, encoding="utf-8") as f:
files = {"file": (resume_path.name, f, "text/plain")}
data = {
'applicant_name': 'Иванов Иван Иванович',
'applicant_email': 'ivan.ivanov@example.com',
'applicant_phone': '+7 (999) 123-45-67',
'vacancy_id': '1'
"applicant_name": "Иванов Иван Иванович",
"applicant_email": "ivan.ivanov@example.com",
"applicant_phone": "+7 (999) 123-45-67",
"vacancy_id": "1",
}
response = requests.post(
f"{BASE_URL}/resume/upload",
files=files,
data=data,
timeout=30
f"{BASE_URL}/resume/upload", files=files, data=data, timeout=30
)
print(f"Resume upload: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"Resume ID: {result.get('resume_id')}")
return result.get('resume_id')
return result.get("resume_id")
else:
print(f"Upload failed: {response.text}")
return None
except Exception as e:
print(f"Upload error: {str(e)}")
return None
def check_resume_processing(resume_id):
"""Check resume processing status"""
try:
response = requests.get(f"{BASE_URL}/resume/{resume_id}")
print(f"Resume status check: {response.status_code}")
if response.status_code == 200:
resume = response.json()
print(f"Status: {resume.get('status')}")
print(f"Has interview plan: {'interview_plan' in resume and resume['interview_plan'] is not None}")
print(
f"Has interview plan: {'interview_plan' in resume and resume['interview_plan'] is not None}"
)
return resume
else:
print(f"Resume check failed: {response.text}")
return None
except Exception as e:
print(f"Status check error: {str(e)}")
return None
def create_interview_session(resume_id):
"""Create interview session"""
try:
response = requests.post(f"{BASE_URL}/interview/{resume_id}/token")
print(f"Interview session creation: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"Room: {result.get('room_name')}")
@ -90,97 +93,102 @@ def create_interview_session(resume_id):
else:
print(f"Interview creation failed: {response.text}")
return None
except Exception as e:
print(f"Interview creation error: {str(e)}")
return None
def check_admin_processes():
"""Check admin process monitoring"""
try:
response = requests.get(f"{BASE_URL}/admin/interview-processes")
print(f"Admin processes check: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"Active sessions: {result.get('total_active_sessions')}")
for proc in result.get('processes', []):
print(f" Session {proc['session_id']}: PID {proc['pid']}, Running: {proc['is_running']}")
for proc in result.get("processes", []):
print(
f" Session {proc['session_id']}: PID {proc['pid']}, Running: {proc['is_running']}"
)
return result
else:
print(f"Admin check failed: {response.text}")
return None
except Exception as e:
print(f"Admin check error: {str(e)}")
return None
def main():
"""Run quick API tests"""
print("=" * 50)
print("QUICK API TEST")
print("=" * 50)
# 1. Check if API is running
if not test_health():
print("❌ API not running! Start with: uvicorn app.main:app --reload")
return
print("✅ API is running")
# 2. Upload test resume
print("\n--- Testing Resume Upload ---")
resume_id = upload_test_resume()
if not resume_id:
print("❌ Resume upload failed!")
return
print(f"✅ Resume uploaded with ID: {resume_id}")
# 3. Wait for processing and check status
print("\n--- Checking Resume Processing ---")
print("Waiting 10 seconds for Celery processing...")
time.sleep(10)
resume_data = check_resume_processing(resume_id)
if not resume_data:
print("❌ Could not check resume status!")
return
if resume_data.get('status') == 'parsed':
if resume_data.get("status") == "parsed":
print("✅ Resume processed successfully")
else:
print(f"⚠️ Resume status: {resume_data.get('status')}")
# 4. Create interview session
print("\n--- Testing Interview Session ---")
print("\n--- Testing Interview Session ---")
interview_data = create_interview_session(resume_id)
if interview_data:
print("✅ Interview session created")
else:
print("❌ Interview session creation failed")
# 5. Check admin monitoring
print("\n--- Testing Admin Monitoring ---")
admin_data = check_admin_processes()
if admin_data:
print("✅ Admin monitoring works")
else:
print("❌ Admin monitoring failed")
print("\n" + "=" * 50)
print("QUICK TEST COMPLETED")
print("=" * 50)
print("\nNext steps:")
print("1. Check Celery worker logs for task processing")
print("2. Inspect database for interview_plan data")
print("2. Inspect database for interview_plan data")
print("3. For voice testing, start LiveKit server")
print("4. Monitor system with: curl http://localhost:8000/admin/system-stats")
if __name__ == "__main__":
main()
main()

View File

@ -1,5 +1,4 @@
from .database import VectorStore
from .llm import ChatModel, EmbeddingsModel
from .service import RagService
__all__ = ['RagService', 'ChatModel', 'EmbeddingsModel']
__all__ = ["RagService", "ChatModel", "EmbeddingsModel"]

View File

@ -1,3 +1,3 @@
from .model import VectorStoreModel as VectorStore
__all__ = ['VectorStore']
__all__ = ["VectorStore"]

View File

@ -1,3 +1,3 @@
from .model import ChatModel, EmbeddingsModel
__all__ = ['ChatModel', 'EmbeddingsModel']
__all__ = ["ChatModel", "EmbeddingsModel"]

View File

@ -1,10 +1,11 @@
import json
import pdfplumber
import os
from typing import Dict, Any
from typing import Any
import pdfplumber
from langchain.schema import HumanMessage, SystemMessage
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
from langchain.schema import HumanMessage, SystemMessage
try:
from docx import Document
@ -16,6 +17,7 @@ try:
except ImportError:
docx2txt = None
class EmbeddingsModel:
def __init__(self, model: Embeddings):
self.model = model
@ -23,6 +25,7 @@ class EmbeddingsModel:
def get_model(self):
return self.model
class ChatModel:
def __init__(self, model: BaseChatModel):
self.model = model
@ -30,13 +33,14 @@ class ChatModel:
def get_llm(self):
return self.model
class ResumeParser:
def __init__(self, chat_model: ChatModel):
self.llm = chat_model.get_llm()
self.resume_prompt = """
Проанализируй текст резюме и извлеки из него структурированные данные в JSON формате.
Верни только JSON без дополнительных комментариев.
Формат ответа:
{{
"name": "Имя кандидата",
@ -55,7 +59,7 @@ class ResumeParser:
"education": "Образование",
"summary": "Краткое резюме о кандидате"
}}
Текст резюме:
{resume_text}
"""
@ -64,16 +68,16 @@ class ResumeParser:
"""Извлекает текст из PDF файла"""
try:
with pdfplumber.open(file_path) as pdf:
text = '\n'.join([page.extract_text() or '' for page in pdf.pages])
text = "\n".join([page.extract_text() or "" for page in pdf.pages])
return text.strip()
except Exception as e:
raise Exception(f"Ошибка при чтении PDF: {str(e)}")
raise Exception(f"Ошибка при чтении PDF: {str(e)}") from e
def extract_text_from_docx(self, file_path: str) -> str:
"""Извлекает текст из DOCX файла"""
try:
print(f"[DEBUG] Extracting DOCX text from: {file_path}")
if docx2txt:
# Предпочитаем docx2txt для простого извлечения текста
print("[DEBUG] Using docx2txt")
@ -83,19 +87,21 @@ class ResumeParser:
return text.strip()
else:
print("[DEBUG] docx2txt returned empty text")
if Document:
# Используем python-docx как fallback
print("[DEBUG] Using python-docx as fallback")
doc = Document(file_path)
text = '\n'.join([paragraph.text for paragraph in doc.paragraphs])
text = "\n".join([paragraph.text for paragraph in doc.paragraphs])
print(f"[DEBUG] Extracted {len(text)} characters using python-docx")
return text.strip()
raise Exception("Библиотеки для чтения DOCX не установлены (docx2txt или python-docx)")
raise Exception(
"Библиотеки для чтения DOCX не установлены (docx2txt или python-docx)"
)
except Exception as e:
print(f"[DEBUG] DOCX extraction failed: {str(e)}")
raise Exception(f"Ошибка при чтении DOCX: {str(e)}")
raise Exception(f"Ошибка при чтении DOCX: {str(e)}") from e
def extract_text_from_doc(self, file_path: str) -> str:
"""Извлекает текст из DOC файла"""
@ -104,103 +110,114 @@ class ResumeParser:
if Document:
try:
doc = Document(file_path)
text = '\n'.join([paragraph.text for paragraph in doc.paragraphs])
text = "\n".join([paragraph.text for paragraph in doc.paragraphs])
return text.strip()
except:
except Exception:
# Если python-docx не может прочитать .doc, пытаемся использовать системные утилиты
pass
# Попытка использовать системную команду antiword (для Linux/Mac)
import subprocess
try:
result = subprocess.run(['antiword', file_path], capture_output=True, text=True)
result = subprocess.run(
["antiword", file_path], capture_output=True, text=True
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
raise Exception("Не удалось найти подходящий инструмент для чтения DOC файлов. Рекомендуется использовать DOCX формат.")
raise Exception(
"Не удалось найти подходящий инструмент для чтения DOC файлов. Рекомендуется использовать DOCX формат."
)
except Exception as e:
raise Exception(f"Ошибка при чтении DOC: {str(e)}")
raise Exception(f"Ошибка при чтении DOC: {str(e)}") from e
def extract_text_from_txt(self, file_path: str) -> str:
"""Извлекает текст из TXT файла"""
try:
# Попробуем разные кодировки
encodings = ['utf-8', 'cp1251', 'latin-1', 'cp1252']
encodings = ["utf-8", "cp1251", "latin-1", "cp1252"]
for encoding in encodings:
try:
with open(file_path, 'r', encoding=encoding) as file:
with open(file_path, encoding=encoding) as file:
text = file.read()
return text.strip()
except UnicodeDecodeError:
continue
raise Exception("Не удалось определить кодировку текстового файла")
except Exception as e:
raise Exception(f"Ошибка при чтении TXT: {str(e)}")
raise Exception(f"Ошибка при чтении TXT: {str(e)}") from e
def extract_text_from_file(self, file_path: str) -> str:
"""Универсальный метод извлечения текста из файла"""
if not os.path.exists(file_path):
raise Exception(f"Файл не найден: {file_path}")
# Определяем расширение файла
_, ext = os.path.splitext(file_path.lower())
# Добавляем отладочную информацию
print(f"[DEBUG] Parsing file: {file_path}, detected extension: {ext}")
if ext == '.pdf':
if ext == ".pdf":
return self.extract_text_from_pdf(file_path)
elif ext == '.docx':
elif ext == ".docx":
return self.extract_text_from_docx(file_path)
elif ext == '.doc':
elif ext == ".doc":
return self.extract_text_from_doc(file_path)
elif ext == '.txt':
elif ext == ".txt":
return self.extract_text_from_txt(file_path)
else:
raise Exception(f"Неподдерживаемый формат файла: {ext}. Поддерживаемые форматы: PDF, DOCX, DOC, TXT")
raise Exception(
f"Неподдерживаемый формат файла: {ext}. Поддерживаемые форматы: PDF, DOCX, DOC, TXT"
)
def parse_resume_text(self, resume_text: str) -> Dict[str, Any]:
def parse_resume_text(self, resume_text: str) -> dict[str, Any]:
"""Парсит текст резюме через LLM"""
try:
messages = [
SystemMessage(content="Ты эксперт по анализу резюме. Извлекай данные точно в указанном JSON формате."),
HumanMessage(content=self.resume_prompt.format(resume_text=resume_text))
SystemMessage(
content="Ты эксперт по анализу резюме. Извлекай данные точно в указанном JSON формате."
),
HumanMessage(
content=self.resume_prompt.format(resume_text=resume_text)
),
]
response = self.llm.invoke(messages)
# Извлекаем JSON из ответа
response_text = response.content.strip()
# Пытаемся найти JSON в ответе
if response_text.startswith('{') and response_text.endswith('}'):
if response_text.startswith("{") and response_text.endswith("}"):
return json.loads(response_text)
else:
# Ищем JSON внутри текста
start = response_text.find('{')
end = response_text.rfind('}') + 1
start = response_text.find("{")
end = response_text.rfind("}") + 1
if start != -1 and end > start:
json_str = response_text[start:end]
return json.loads(json_str)
else:
raise ValueError("JSON не найден в ответе LLM")
except json.JSONDecodeError as e:
raise Exception(f"Ошибка парсинга JSON из ответа LLM: {str(e)}")
except Exception as e:
raise Exception(f"Ошибка при обращении к LLM: {str(e)}")
def parse_resume_from_file(self, file_path: str) -> Dict[str, Any]:
except json.JSONDecodeError as e:
raise Exception(f"Ошибка парсинга JSON из ответа LLM: {str(e)}") from e
except Exception as e:
raise Exception(f"Ошибка при обращении к LLM: {str(e)}") from e
def parse_resume_from_file(self, file_path: str) -> dict[str, Any]:
"""Полный цикл парсинга резюме из файла"""
# Шаг 1: Извлекаем текст из файла (поддерживаем PDF, DOCX, DOC, TXT)
resume_text = self.extract_text_from_file(file_path)
if not resume_text:
raise Exception("Не удалось извлечь текст из файла")
# Шаг 2: Парсим через LLM
return self.parse_resume_text(resume_text)

View File

@ -1,65 +1,71 @@
import json
from datetime import datetime
from typing import List, Optional
import redis
from sqlmodel import select
from sqlalchemy.ext.asyncio import AsyncSession
from langchain.schema import HumanMessage, AIMessage
import redis
from langchain.memory import ConversationSummaryBufferMemory
from langchain.schema import AIMessage, HumanMessage
from sqlalchemy.ext.asyncio import AsyncSession
from rag.settings import settings
class ChatMemoryManager:
def __init__(self, llm, token_limit=3000):
self.redis = redis.Redis(host=settings.redis_cache_url, port=settings.redis_cache_port, db=settings.redis_cache_db)
self.redis = redis.Redis(
host=settings.redis_cache_url,
port=settings.redis_cache_port,
db=settings.redis_cache_db,
)
self.llm = llm
self.token_limit = token_limit
def _convert_to_langchain(self, messages: List[dict]):
def _convert_to_langchain(self, messages: list[dict]):
return [
AIMessage(content=msg["content"]) if msg["is_ai"]
AIMessage(content=msg["content"])
if msg["is_ai"]
else HumanMessage(content=msg["content"])
for msg in messages
]
def _annotate_messages(self, messages: List):
def _annotate_messages(self, messages: list):
# Convert to format compatible with langchain
# Assuming messages have some way to identify if they're from AI
return [
{
**msg,
"is_ai": msg.get("user_type") == "AI" or msg.get("username") == "SOMMELIER"
"is_ai": msg.get("user_type") == "AI"
or msg.get("username") == "SOMMELIER",
}
for msg in messages
]
def _serialize_messages(self, messages: List[dict]):
def _serialize_messages(self, messages: list[dict]):
return [
{**msg, "created_at": msg["created_at"].isoformat()}
for msg in messages
{**msg, "created_at": msg["created_at"].isoformat()} for msg in messages
]
def _cache_key(self, session_id: int) -> str:
return f"chat_memory:{session_id}"
async def load_chat_history(self, session_id: int, session: AsyncSession) -> List[HumanMessage | AIMessage]:
async def load_chat_history(
self, session_id: int, session: AsyncSession
) -> list[HumanMessage | AIMessage]:
cache_key = self._cache_key(session_id)
serialized = self.redis.get(cache_key)
if serialized:
cached_messages = json.loads(serialized)
if cached_messages:
last_time = datetime.fromisoformat(cached_messages[-1]["created_at"])
# last_time = datetime.fromisoformat(cached_messages[-1]["created_at"])
# TODO: Replace with actual Message model query when available
# This would need to be implemented with SQLModel/SQLAlchemy
new_messages = [] # Placeholder for actual DB query
if new_messages:
annotated_messages = self._annotate_messages(new_messages)
all_messages = cached_messages + self._serialize_messages(annotated_messages)
all_messages = cached_messages + self._serialize_messages(
annotated_messages
)
self.redis.setex(cache_key, 3600, json.dumps(all_messages))
return self._convert_to_langchain(all_messages)
@ -68,18 +74,23 @@ class ChatMemoryManager:
# TODO: Replace with actual Message model query when available
# This would need to be implemented with SQLModel/SQLAlchemy
db_messages = [] # Placeholder for actual DB query
if db_messages:
annotated_messages = self._annotate_messages(db_messages)
self.redis.setex(cache_key, 3600, json.dumps(self._serialize_messages(annotated_messages)))
self.redis.setex(
cache_key,
3600,
json.dumps(self._serialize_messages(annotated_messages)),
)
return self._convert_to_langchain(annotated_messages)
return []
async def get_session_memory(self, session_id: int, session: AsyncSession) -> ConversationSummaryBufferMemory:
async def get_session_memory(
self, session_id: int, session: AsyncSession
) -> ConversationSummaryBufferMemory:
memory = ConversationSummaryBufferMemory(
llm=self.llm,
max_token_limit=self.token_limit
llm=self.llm, max_token_limit=self.token_limit
)
messages = await self.load_chat_history(session_id, session)

View File

@ -1,23 +1,24 @@
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from rag.llm.model import ChatModel, EmbeddingsModel
from rag.database.model import VectorStoreModel
from rag.service.model import RagService
from rag.vector_store import MilvusVectorStore
from rag.settings import settings
from langchain_milvus import Milvus
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from rag.database.model import VectorStoreModel
from rag.llm.model import ChatModel, EmbeddingsModel
from rag.service.model import RagService
from rag.settings import settings
from rag.vector_store import MilvusVectorStore
class ModelRegistry:
"""Реестр для инициализации и получения моделей"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(ModelRegistry, cls).__new__(cls)
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self._chat_model = None
@ -25,57 +26,56 @@ class ModelRegistry:
self._vector_store = None
self._rag_service = None
self._initialized = True
def get_chat_model(self) -> ChatModel:
"""Получить или создать chat модель"""
if self._chat_model is None:
if settings.openai_api_key:
llm = ChatOpenAI(
api_key=settings.openai_api_key,
model="gpt-4o-mini",
temperature=0
api_key=settings.openai_api_key, model="gpt-4o-mini", temperature=0
)
self._chat_model = ChatModel(llm)
else:
raise ValueError("OpenAI API key не настроен в settings")
return self._chat_model
def get_embeddings_model(self) -> EmbeddingsModel:
"""Получить или создать embeddings модель"""
if self._embeddings_model is None:
if settings.openai_api_key:
embeddings = OpenAIEmbeddings(
api_key=settings.openai_api_key,
model=settings.openai_embeddings_model
model=settings.openai_embeddings_model,
)
self._embeddings_model = EmbeddingsModel(embeddings)
else:
raise ValueError("OpenAI API key не настроен в settings")
return self._embeddings_model
def get_vector_store(self) -> MilvusVectorStore:
"""Получить или создать vector store"""
if self._vector_store is None:
embeddings_model = self.get_embeddings_model()
self._vector_store = MilvusVectorStore(
embeddings_model.get_model(),
collection_name=settings.milvus_collection
embeddings_model.get_model(), collection_name=settings.milvus_collection
)
return self._vector_store
def get_rag_service(self) -> RagService:
"""Получить или создать RAG сервис"""
if self._rag_service is None:
# Создаем VectorStoreModel для совместимости с существующим кодом
# Парсим URI для получения host и port
uri_without_protocol = settings.milvus_uri.replace("http://", "").replace("https://", "")
uri_without_protocol = settings.milvus_uri.replace("http://", "").replace(
"https://", ""
)
if ":" in uri_without_protocol:
host, port = uri_without_protocol.split(":", 1)
port = int(port)
else:
host = uri_without_protocol
port = 19530 # Default Milvus port
try:
# Попробуем использовать URI напрямую
milvus_store = Milvus(
@ -85,7 +85,7 @@ class ModelRegistry:
},
collection_name=settings.milvus_collection,
)
except Exception as e:
except Exception:
# Если не сработало, попробуем host/port
milvus_store = Milvus(
embedding_function=self.get_embeddings_model().get_model(),
@ -95,15 +95,14 @@ class ModelRegistry:
},
collection_name=settings.milvus_collection,
)
vector_store_model = VectorStoreModel(milvus_store)
self._rag_service = RagService(
vector_store=vector_store_model,
llm=self.get_chat_model()
vector_store=vector_store_model, llm=self.get_chat_model()
)
return self._rag_service
# Singleton instance
registry = ModelRegistry()
registry = ModelRegistry()

View File

@ -1,3 +1,3 @@
from .model import RagService
__all__ = ['RagService']
__all__ = ["RagService"]

View File

@ -1,21 +1,21 @@
from rag.database.model import VectorStoreModel
from langchain_core.runnables import RunnableWithMessageHistory
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from rag.llm.model import ChatModel
from langchain.schema import HumanMessage, SystemMessage
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.schema import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableWithMessageHistory
from rag.database.model import VectorStoreModel
from rag.llm.model import ChatModel
from rag.memory import ChatMemoryManager
rag_template: str = """
rag_template: str = """
You are a beverage and alcohol expert like a sommelier, but for all kinds of alcoholic drinks, including beer, wine, spirits, cocktails, etc
Answer clearly and stay within your expertise in alcohol and related topics
Rules:
1. Speak in first person: "I recommend", "I think"
2. Be conversational and personable - like a knowledgeable friend at a bar
3. Use facts from the context for specific characteristics, but speak generally when needed
4. Do not disclose sources or metadata from contextual documents
4. Do not disclose sources or metadata from contextual documents
5. Answer questions about alcohol and related topics (food pairings, culture, serving, etc) but politely decline unrelated subjects
6. Be brief and useful - keep answers to 2-4 sentences
7. Use chat history to maintain a natural conversation flow
@ -24,22 +24,29 @@ Rules:
Context: {context}
"""
get_summary_template = """Create a concise 3-5 word title for the following conversation.
get_summary_template = """Create a concise 3-5 word title for the following conversation.
Focus on the main topic. Reply only with the title.\n\n
Chat history:\n"""
rephrase_prompt = ChatPromptTemplate.from_messages([
("system", "Given a chat history and the latest user question which might reference context in the chat history, "
"formulate a standalone question. Do NOT answer the question."),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
rephrase_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"Given a chat history and the latest user question which might reference context in the chat history, "
"formulate a standalone question. Do NOT answer the question.",
),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
qa_prompt = ChatPromptTemplate.from_messages([
("system", rag_template),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
qa_prompt = ChatPromptTemplate.from_messages(
[
("system", rag_template),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
class RagService:
@ -49,19 +56,25 @@ class RagService:
retriever = self.vector_store.as_retriever()
self.rephrase_prompt = ChatPromptTemplate.from_messages([
("system",
"Given a chat history and the latest user question which might reference context in the chat history, "
"formulate a standalone question. Do NOT answer the question."),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
self.rephrase_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"Given a chat history and the latest user question which might reference context in the chat history, "
"formulate a standalone question. Do NOT answer the question.",
),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
self.qa_prompt = ChatPromptTemplate.from_messages([
("system", rag_template),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
self.qa_prompt = ChatPromptTemplate.from_messages(
[
("system", rag_template),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
self.history_aware_retriever = create_history_aware_retriever(
self.llm, retriever, self.rephrase_prompt
@ -87,34 +100,36 @@ class RagService:
get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
output_messages_key="answer"
output_messages_key="answer",
)
for chunk in conversational_rag_chain.stream(
{"input": query},
config={"configurable": {"session_id": str(session_id)}}
{"input": query}, config={"configurable": {"session_id": str(session_id)}}
):
answer = chunk.get('answer', '')
answer = chunk.get("answer", "")
if answer:
yield answer
def generate_title_with_llm(self, chat_history: str | list[str]) -> str:
# Вариант 1: Если chat_history — строка
if isinstance(chat_history, str):
prompt = get_summary_template + chat_history
messages = [
SystemMessage(content="You are a helpful assistant that generates chat titles."),
HumanMessage(content=prompt)
SystemMessage(
content="You are a helpful assistant that generates chat titles."
),
HumanMessage(content=prompt),
]
# Вариант 2: Если chat_history — список сообщений (например, ["user: ...", "bot: ..."])
else:
prompt = get_summary_template + "\n".join(chat_history)
messages = [
SystemMessage(content="You are a helpful assistant that generates chat titles."),
HumanMessage(content=prompt)
SystemMessage(
content="You are a helpful assistant that generates chat titles."
),
HumanMessage(content=prompt),
]
response = self.llm.invoke(messages)

View File

@ -1,12 +1,10 @@
import os
from pydantic_settings import BaseSettings
from typing import Optional
class RagSettings(BaseSettings):
# Database
database_url: str = "postgresql+asyncpg://tdjx:1309@localhost:5432/hr_ai"
# Milvus Settings
milvus_uri: str = "http://5.188.159.90:19530"
milvus_collection: str = "candidate_profiles"
@ -15,31 +13,31 @@ class RagSettings(BaseSettings):
redis_cache_url: str = "localhost"
redis_cache_port: int = 6379
redis_cache_db: int = 0
# S3 Configuration
s3_endpoint_url: str
s3_access_key_id: str
s3_secret_access_key: str
s3_bucket_name: str
s3_region: str = "ru-1"
# LLM Settings
openai_api_key: Optional[str] = None
anthropic_api_key: Optional[str] = None
openai_api_key: str | None = None
anthropic_api_key: str | None = None
openai_model: str = "gpt-4o-mini"
openai_embeddings_model: str = "text-embedding-3-small"
# AI Agent Settings
deepgram_api_key: Optional[str] = None
cartesia_api_key: Optional[str] = None
elevenlabs_api_key: Optional[str] = None
resemble_api_key: Optional[str] = None
deepgram_api_key: str | None = None
cartesia_api_key: str | None = None
elevenlabs_api_key: str | None = None
resemble_api_key: str | None = None
# LiveKit Configuration
livekit_url: str = "ws://localhost:7880"
livekit_api_key: str = "devkey"
livekit_api_secret: str = "devkey_secret_32chars_minimum_length"
# App Configuration
app_env: str = "development"
debug: bool = True
@ -49,4 +47,4 @@ class RagSettings(BaseSettings):
env_file_encoding = "utf-8"
settings = RagSettings()
settings = RagSettings()

View File

@ -1,11 +1,15 @@
from typing import List, Dict, Any
from langchain_milvus import Milvus
from typing import Any
from langchain_core.embeddings import Embeddings
from langchain_milvus import Milvus
from rag.settings import settings
class MilvusVectorStore:
def __init__(self, embeddings_model: Embeddings, collection_name: str = "candidate_profiles"):
def __init__(
self, embeddings_model: Embeddings, collection_name: str = "candidate_profiles"
):
self.embeddings = embeddings_model
self.collection_name = collection_name
@ -18,18 +22,22 @@ class MilvusVectorStore:
collection_name=collection_name,
)
def add_candidate_profile(self, candidate_id: str, resume_data: Dict[str, Any]):
def add_candidate_profile(self, candidate_id: str, resume_data: dict[str, Any]):
"""Добавляет профиль кандидата в векторную базу"""
try:
# Создаем текст для векторизации из навыков и опыта
skills_text = " ".join(resume_data.get("skills", []))
experience_text = " ".join([
f"{exp.get('position', '')} {exp.get('company', '')} {exp.get('description', '')}"
for exp in resume_data.get("experience", [])
])
combined_text = f"{skills_text} {experience_text} {resume_data.get('summary', '')}"
experience_text = " ".join(
[
f"{exp.get('position', '')} {exp.get('company', '')} {exp.get('description', '')}"
for exp in resume_data.get("experience", [])
]
)
combined_text = (
f"{skills_text} {experience_text} {resume_data.get('summary', '')}"
)
# Метаданные для поиска
metadata = {
"candidate_id": candidate_id,
@ -38,60 +46,53 @@ class MilvusVectorStore:
"phone": resume_data.get("phone", ""),
"total_years": resume_data.get("total_years", 0),
"skills": resume_data.get("skills", []),
"education": resume_data.get("education", "")
"education": resume_data.get("education", ""),
}
# Добавляем в векторную базу
self.vector_store.add_texts(
texts=[combined_text],
metadatas=[metadata],
ids=[candidate_id]
texts=[combined_text], metadatas=[metadata], ids=[candidate_id]
)
return True
except Exception as e:
raise Exception(f"Ошибка при добавлении кандидата в Milvus: {str(e)}")
def search_similar_candidates(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
return True
except Exception as e:
raise Exception(f"Ошибка при добавлении кандидата в Milvus: {str(e)}") from e
def search_similar_candidates(self, query: str, k: int = 5) -> list[dict[str, Any]]:
"""Поиск похожих кандидатов по запросу"""
try:
results = self.vector_store.similarity_search_with_score(query, k=k)
candidates = []
for doc, score in results:
candidate = {
"content": doc.page_content,
"metadata": doc.metadata,
"similarity_score": score
"similarity_score": score,
}
candidates.append(candidate)
return candidates
except Exception as e:
raise Exception(f"Ошибка при поиске кандидатов в Milvus: {str(e)}")
def get_candidate_by_id(self, candidate_id: str) -> Dict[str, Any]:
return candidates
except Exception as e:
raise Exception(f"Ошибка при поиске кандидатов в Milvus: {str(e)}") from e
def get_candidate_by_id(self, candidate_id: str) -> dict[str, Any]:
"""Получает кандидата по ID"""
try:
results = self.vector_store.similarity_search(
query="",
k=1,
expr=f"candidate_id == '{candidate_id}'"
query="", k=1, expr=f"candidate_id == '{candidate_id}'"
)
if results:
doc = results[0]
return {
"content": doc.page_content,
"metadata": doc.metadata
}
return {"content": doc.page_content, "metadata": doc.metadata}
else:
return None
except Exception as e:
raise Exception(f"Ошибка при получении кандидата из Milvus: {str(e)}")
raise Exception(f"Ошибка при получении кандидата из Milvus: {str(e)}") from e
def delete_candidate(self, candidate_id: str):
"""Удаляет кандидата из векторной базы"""
@ -99,6 +100,6 @@ class MilvusVectorStore:
# В Milvus удаление по ID
self.vector_store.delete([candidate_id])
return True
except Exception as e:
raise Exception(f"Ошибка при удалении кандидата из Milvus: {str(e)}")
raise Exception(f"Ошибка при удалении кандидата из Milvus: {str(e)}") from e

View File

@ -8,23 +8,25 @@ from pathlib import Path
# Add root directory to PYTHONPATH
sys.path.append(str(Path(__file__).parent))
async def test_database():
"""Test PostgreSQL connection"""
print("Testing database connection...")
try:
from sqlalchemy import select
from app.core.database import get_session as get_db
from app.models.resume import Resume
from sqlalchemy import select
async for db in get_db():
result = await db.execute(select(Resume).limit(1))
resumes = result.scalars().all()
print("PASS - Database connection successful")
print(f"Found resumes: {len(resumes)}")
return True
except Exception as e:
print(f"FAIL - Database error: {str(e)}")
return False
@ -33,14 +35,14 @@ async def test_database():
async def test_rag():
"""Test RAG system"""
print("\nTesting RAG system...")
try:
from rag.registry import registry
from rag.llm.model import ResumeParser
from rag.registry import registry
chat_model = registry.get_chat_model()
parser = ResumeParser(chat_model)
# Test resume text
test_text = """
John Doe
@ -49,14 +51,14 @@ async def test_rag():
Skills: Python, Django, PostgreSQL
Education: Computer Science
"""
parsed_resume = parser.parse_resume_text(test_text)
print("PASS - RAG system working")
print(f"Parsed data keys: {list(parsed_resume.keys())}")
return True
except Exception as e:
print(f"FAIL - RAG error: {str(e)}")
return False
@ -65,21 +67,22 @@ async def test_rag():
def test_redis():
"""Test Redis connection"""
print("\nTesting Redis connection...")
try:
import redis
from rag.settings import settings
r = redis.Redis(
host=settings.redis_cache_url,
port=settings.redis_cache_port,
db=settings.redis_cache_db
db=settings.redis_cache_db,
)
r.ping()
print("PASS - Redis connection successful")
return True
except Exception as e:
print(f"FAIL - Redis error: {str(e)}")
print("TIP: Start Redis with: docker run -d -p 6379:6379 redis:alpine")
@ -89,24 +92,24 @@ def test_redis():
async def test_interview_service():
"""Test interview service"""
print("\nTesting interview service...")
try:
from app.services.interview_service import InterviewRoomService
from app.core.database import get_session as get_db
from app.services.interview_service import InterviewRoomService
async for db in get_db():
service = InterviewRoomService(db)
# Test token generation
token = service.generate_access_token("test_room", "test_user")
print(f"PASS - Token generated (length: {len(token)})")
# Test fallback plan
plan = service._get_fallback_interview_plan()
print(f"PASS - Interview plan structure: {list(plan.keys())}")
return True
except Exception as e:
print(f"FAIL - Interview service error: {str(e)}")
return False
@ -115,10 +118,10 @@ async def test_interview_service():
def test_ai_agent():
"""Test AI agent"""
print("\nTesting AI agent...")
try:
from ai_interviewer_agent import InterviewAgent
test_plan = {
"interview_structure": {
"duration_minutes": 15,
@ -127,24 +130,24 @@ def test_ai_agent():
{
"name": "Introduction",
"duration_minutes": 5,
"questions": ["Tell me about yourself"]
"questions": ["Tell me about yourself"],
}
]
],
},
"candidate_info": {
"name": "Test Candidate",
"skills": ["Python"],
"total_years": 2
}
"total_years": 2,
},
}
agent = InterviewAgent(test_plan)
print(f"PASS - AI Agent initialized with {len(agent.sections)} sections")
print(f"Current section: {agent.get_current_section().get('name')}")
return True
except Exception as e:
print(f"FAIL - AI Agent error: {str(e)}")
return False
@ -155,7 +158,7 @@ async def main():
print("=" * 50)
print("HR-AI SYSTEM TEST")
print("=" * 50)
tests = [
("Database", test_database),
("RAG System", test_rag),
@ -163,9 +166,9 @@ async def main():
("Interview Service", test_interview_service),
("AI Agent", lambda: test_ai_agent()),
]
results = []
for test_name, test_func in tests:
try:
if asyncio.iscoroutinefunction(test_func):
@ -176,27 +179,29 @@ async def main():
except Exception as e:
print(f"CRITICAL ERROR in {test_name}: {str(e)}")
results.append((test_name, False))
# Summary
print("\n" + "=" * 50)
print("TEST RESULTS")
print("=" * 50)
passed = 0
for test_name, result in results:
status = "PASS" if result else "FAIL"
print(f"{test_name:20} {status}")
if result:
passed += 1
total = len(results)
print(f"\nRESULT: {passed}/{total} tests passed")
if passed == total:
print("\nSYSTEM READY FOR TESTING!")
print("Next steps:")
print("1. Start FastAPI: uvicorn app.main:app --reload")
print("2. Start Celery: celery -A celery_worker.celery_app worker --loglevel=info")
print(
"2. Start Celery: celery -A celery_worker.celery_app worker --loglevel=info"
)
print("3. Upload test resume via /resume/upload")
print("4. Check interview plan generation")
else:
@ -205,4 +210,4 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

View File

@ -5,32 +5,34 @@
import asyncio
import sys
import os
import requests
from pathlib import Path
import requests
# Добавляем корневую директорию в PYTHONPATH
sys.path.append(str(Path(__file__).parent))
async def test_database_connection():
"""Тест подключения к PostgreSQL"""
print("Testing database connection...")
try:
from sqlalchemy import select
from app.core.database import get_db
from app.models.resume import Resume
from sqlalchemy import select
# Получаем async сессию
async for db in get_db():
# Пробуем выполнить простой запрос
result = await db.execute(select(Resume).limit(1))
resumes = result.scalars().all()
print("OK Database: connection successful")
print(f"Found resumes in database: {len(resumes)}")
return True
except Exception as e:
print(f"FAIL Database: connection error - {str(e)}")
return False
@ -39,20 +41,20 @@ async def test_database_connection():
async def test_rag_system():
"""Тест RAG системы (парсинг резюме)"""
print("\n🔍 Тестируем RAG систему...")
try:
from rag.registry import registry
from rag.llm.model import ResumeParser
from rag.registry import registry
# Инициализируем модели
chat_model = registry.get_chat_model()
embeddings_model = registry.get_embeddings_model()
# embeddings_model = registry.get_embeddings_model()
print("✅ RAG система: модели инициализированы")
# Тестируем парсер резюме
parser = ResumeParser(chat_model)
# Создаем тестовый текст резюме
test_resume_text = """
Иван Петров
@ -61,14 +63,14 @@ async def test_rag_system():
Навыки: Python, Django, PostgreSQL, Docker
Образование: МГУ, факультет ВМК
"""
parsed_resume = parser.parse_resume_text(test_resume_text)
print("✅ RAG система: парсинг резюме работает")
print(f"📋 Распарсенные данные: {parsed_resume}")
return True
except Exception as e:
print(f"❌ RAG система: ошибка - {str(e)}")
return False
@ -77,41 +79,44 @@ async def test_rag_system():
def test_redis_connection():
"""Тест подключения к Redis"""
print("\n🔍 Тестируем подключение к Redis...")
try:
import redis
from rag.settings import settings
r = redis.Redis(
host=settings.redis_cache_url,
port=settings.redis_cache_port,
db=settings.redis_cache_db
db=settings.redis_cache_db,
)
# Пробуем ping
r.ping()
print("✅ Redis: подключение успешно")
return True
except Exception as e:
print(f"❌ Redis: ошибка подключения - {str(e)}")
print("💡 Для запуска Redis используйте: docker run -d -p 6379:6379 redis:alpine")
print(
"💡 Для запуска Redis используйте: docker run -d -p 6379:6379 redis:alpine"
)
return False
async def test_celery_tasks():
"""Тест Celery задач"""
print("\n🔍 Тестируем Celery задачи...")
try:
from celery_worker.tasks import parse_resume_task
print("✅ Celery: задачи импортируются")
print("💡 Для полного теста запустите: celery -A celery_worker.celery_app worker --loglevel=info")
print(
"💡 Для полного теста запустите: celery -A celery_worker.celery_app worker --loglevel=info"
)
return True
except Exception as e:
print(f"❌ Celery: ошибка - {str(e)}")
return False
@ -120,14 +125,14 @@ async def test_celery_tasks():
async def test_interview_service():
"""Тест сервиса интервью (без LiveKit)"""
print("\n🔍 Тестируем сервис интервью...")
try:
from app.services.interview_service import InterviewRoomService
from app.core.database import get_db
from app.services.interview_service import InterviewRoomService
async for db in get_db():
service = InterviewRoomService(db)
# Тестируем генерацию токена (должен работать даже без LiveKit сервера)
try:
token = service.generate_access_token("test_room", "test_user")
@ -135,14 +140,14 @@ async def test_interview_service():
print(f"🎫 Тестовый токен сгенерирован (длина: {len(token)})")
except Exception as e:
print(f"⚠️ Interview Service: ошибка токена - {str(e)}")
# Тестируем fallback план интервью
fallback_plan = service._get_fallback_interview_plan()
print("✅ Interview Service: fallback план работает")
print(f"📋 Структура плана: {list(fallback_plan.keys())}")
return True
except Exception as e:
print(f"❌ Interview Service: ошибка - {str(e)}")
return False
@ -151,10 +156,10 @@ async def test_interview_service():
def test_ai_agent_import():
"""Тест импорта AI агента"""
print("\n🔍 Тестируем AI агента...")
try:
from ai_interviewer_agent import InterviewAgent
# Тестовый план интервью
test_plan = {
"interview_structure": {
@ -164,34 +169,34 @@ def test_ai_agent_import():
{
"name": "Знакомство",
"duration_minutes": 5,
"questions": ["Расскажи о себе"]
"questions": ["Расскажи о себе"],
},
{
"name": "Опыт",
"duration_minutes": 10,
"questions": ["Какой у тебя опыт?"]
}
]
"questions": ["Какой у тебя опыт?"],
},
],
},
"candidate_info": {
"name": "Тестовый кандидат",
"skills": ["Python"],
"total_years": 2
}
"total_years": 2,
},
}
agent = InterviewAgent(test_plan)
print("✅ AI Agent: импорт и инициализация работают")
print(f"📊 Секций в плане: {len(agent.sections)}")
print(f"🎯 Текущая секция: {agent.get_current_section().get('name')}")
# Тестируем извлечение системных инструкций
instructions = agent.get_system_instructions()
print(f"📝 Инструкции сгенерированы (длина: {len(instructions)})")
return True
except Exception as e:
print(f"❌ AI Agent: ошибка - {str(e)}")
return False
@ -200,10 +205,11 @@ def test_ai_agent_import():
def check_external_services():
"""Проверка внешних сервисов"""
print("\n🔍 Проверяем внешние сервисы...")
# Проверяем Milvus
try:
from rag.settings import settings
response = requests.get(f"{settings.milvus_uri}/health", timeout=5)
if response.status_code == 200:
print("✅ Milvus: сервер доступен")
@ -211,23 +217,27 @@ def check_external_services():
print("⚠️ Milvus: сервер недоступен")
except Exception:
print("❌ Milvus: сервер недоступен")
# Проверяем LiveKit (если запущен)
try:
# LiveKit health check обычно на HTTP порту
livekit_http_url = settings.livekit_url.replace("ws://", "http://").replace(":7880", ":7880")
livekit_http_url = settings.livekit_url.replace("ws://", "http://").replace(
":7880", ":7880"
)
response = requests.get(livekit_http_url, timeout=2)
print("✅ LiveKit: сервер запущен")
except Exception:
print("❌ LiveKit: сервер не запущен")
print("💡 Для запуска LiveKit используйте Docker: docker run --rm -p 7880:7880 -p 7881:7881 livekit/livekit-server --dev")
print(
"💡 Для запуска LiveKit используйте Docker: docker run --rm -p 7880:7880 -p 7881:7881 livekit/livekit-server --dev"
)
async def run_all_tests():
"""Запуск всех тестов"""
print("=== HR-AI System Testing ===")
print("=" * 50)
tests = [
("Database", test_database_connection),
("RAG System", test_rag_system),
@ -236,9 +246,9 @@ async def run_all_tests():
("Interview Service", test_interview_service),
("AI Agent", lambda: test_ai_agent_import()),
]
results = {}
for test_name, test_func in tests:
try:
if asyncio.iscoroutinefunction(test_func):
@ -249,24 +259,24 @@ async def run_all_tests():
except Exception as e:
print(f"{test_name}: критическая ошибка - {str(e)}")
results[test_name] = False
# Проверяем внешние сервисы
check_external_services()
# Итоговый отчет
print("\n" + "=" * 50)
print("📊 ИТОГОВЫЙ ОТЧЕТ")
print("=" * 50)
passed = sum(1 for r in results.values() if r)
total = len(results)
for test_name, result in results.items():
status = "✅ PASS" if result else "❌ FAIL"
print(f"{test_name:20} {status}")
print(f"\n🎯 Результат: {passed}/{total} тестов прошли успешно")
if passed == total:
print("🎉 Система готова к тестированию!")
print_next_steps()
@ -279,7 +289,9 @@ def print_next_steps():
"""Следующие шаги для полного тестирования"""
print("\n📋 СЛЕДУЮЩИЕ ШАГИ:")
print("1. Запустите FastAPI сервер: uvicorn app.main:app --reload")
print("2. Запустите Celery worker: celery -A celery_worker.celery_app worker --loglevel=info")
print(
"2. Запустите Celery worker: celery -A celery_worker.celery_app worker --loglevel=info"
)
print("3. Загрузите тестовое резюме через /resume/upload")
print("4. Проверьте генерацию плана интервью в базе данных")
print("5. Для полного теста голосовых интервью потребуются:")
@ -291,10 +303,10 @@ def print_troubleshooting():
"""Устранение неисправностей"""
print("\n🔧 УСТРАНЕНИЕ ПРОБЛЕМ:")
print("• Redis не запущен: docker run -d -p 6379:6379 redis:alpine")
print("• Milvus недоступен: проверьте настройки MILVUS_URI")
print("• Milvus недоступен: проверьте настройки MILVUS_URI")
print("• RAG ошибки: проверьте OPENAI_API_KEY")
print("• База данных: проверьте DATABASE_URL и запустите alembic upgrade head")
if __name__ == "__main__":
asyncio.run(run_all_tests())
asyncio.run(run_all_tests())

3988
uv.lock

File diff suppressed because it is too large Load Diff