406 lines
17 KiB
Python
406 lines
17 KiB
Python
import time
|
||
import uuid
|
||
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.repositories.vacancy_repository import VacancyRepository
|
||
from app.services.agent_manager import agent_manager
|
||
from rag.settings import settings
|
||
|
||
|
||
class InterviewRoomService:
|
||
def __init__(
|
||
self,
|
||
interview_repo: Annotated[InterviewRepository, Depends(InterviewRepository)],
|
||
resume_repo: Annotated[ResumeRepository, Depends(ResumeRepository)],
|
||
vacancy_repo: Annotated[VacancyRepository, Depends(VacancyRepository)],
|
||
):
|
||
self.interview_repo = interview_repo
|
||
self.resume_repo = resume_repo
|
||
self.vacancy_repo = vacancy_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:
|
||
"""Проверяет, можно ли проводить собеседование для данного резюме"""
|
||
try:
|
||
# Получаем резюме
|
||
resume = await self.resume_repo.get(resume_id)
|
||
|
||
if not resume:
|
||
return InterviewValidationResponse(
|
||
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}",
|
||
)
|
||
|
||
# Проверяем активную сессию только для информации (не блокируем)
|
||
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)
|
||
|
||
except Exception as e:
|
||
return InterviewValidationResponse(
|
||
can_interview=False, message=f"Error validating resume: {str(e)}"
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
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
|
||
)
|
||
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) -> LiveKitTokenResponse | None:
|
||
"""Создает сессию собеседования и возвращает токен для LiveKit"""
|
||
try:
|
||
# Валидируем резюме
|
||
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)
|
||
)
|
||
if existing_session:
|
||
# Используем существующую сессию
|
||
interview_session = existing_session
|
||
print(
|
||
f"[DEBUG] Using existing interview session: {interview_session.id}"
|
||
)
|
||
else:
|
||
# Проверяем доступность агента
|
||
if not agent_manager.is_available():
|
||
print("[ERROR] AI Agent is not available for new interview")
|
||
return None
|
||
|
||
# Создаем новую сессию собеседования
|
||
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}")
|
||
|
||
# Получаем готовый план интервью для AI агента
|
||
interview_plan = await self.get_resume_data_for_interview(resume_id)
|
||
|
||
# Получаем данные вакансии
|
||
resume = await self.resume_repo.get(resume_id)
|
||
vacancy_data = None
|
||
if resume and resume.vacancy_id:
|
||
vacancy = await self.vacancy_repo.get_by_id(resume.vacancy_id)
|
||
if vacancy:
|
||
# Конвертируем объект вакансии в словарь для JSON сериализации
|
||
vacancy_data = {
|
||
"title": vacancy.title,
|
||
"description": vacancy.description,
|
||
"key_skills": vacancy.key_skills,
|
||
"employment_type": vacancy.employment_type,
|
||
"experience": vacancy.experience,
|
||
"schedule": vacancy.schedule,
|
||
"area_name": vacancy.area_name,
|
||
"professional_roles": vacancy.professional_roles,
|
||
"contacts_name": vacancy.contacts_name,
|
||
}
|
||
|
||
# Обновляем статус сессии на ACTIVE
|
||
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,
|
||
vacancy_data,
|
||
)
|
||
|
||
if not success:
|
||
print("[ERROR] Failed to assign session to AI agent")
|
||
return None
|
||
|
||
# Генерируем токен
|
||
participant_name = f"user_{resume_id}"
|
||
token = self.generate_access_token(
|
||
interview_session.room_name, participant_name
|
||
)
|
||
|
||
return LiveKitTokenResponse(
|
||
token=token,
|
||
room_name=interview_session.room_name,
|
||
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) -> InterviewSession | None:
|
||
"""Получает активную сессию собеседования для резюме"""
|
||
return await self.interview_repo.get_active_session_by_resume_id(resume_id)
|
||
|
||
async def end_interview_session(self, session_id: int) -> bool:
|
||
"""Завершает интервью-сессию и освобождает агента"""
|
||
try:
|
||
# Освобождаем агента от текущей сессии
|
||
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 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": {
|
||
"duration_minutes": 30,
|
||
"greeting": f"Привет, {resume.applicant_name}! Готов к интервью?",
|
||
"sections": [
|
||
{
|
||
"name": "Знакомство",
|
||
"duration_minutes": 5,
|
||
"questions": [
|
||
"Расскажи немного о себе",
|
||
"Что тебя привлекло в этой позиции?",
|
||
],
|
||
},
|
||
{
|
||
"name": "Опыт работы",
|
||
"duration_minutes": 15,
|
||
"questions": [
|
||
"Расскажи о своем опыте",
|
||
"Какие технологии используешь?",
|
||
],
|
||
},
|
||
{
|
||
"name": "Вопросы кандидата",
|
||
"duration_minutes": 10,
|
||
"questions": ["Есть ли у тебя вопросы ко мне?"],
|
||
},
|
||
],
|
||
},
|
||
"focus_areas": ["experience", "technical_skills"],
|
||
"candidate_info": {
|
||
"name": resume.applicant_name,
|
||
"email": resume.applicant_email,
|
||
"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", ""),
|
||
}
|
||
)
|
||
|
||
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 {
|
||
"interview_structure": {
|
||
"duration_minutes": 30,
|
||
"greeting": "Привет! Готов к интервью?",
|
||
"sections": [
|
||
{
|
||
"name": "Знакомство",
|
||
"duration_minutes": 10,
|
||
"questions": [
|
||
"Расскажи о себе",
|
||
"Что тебя привлекло в этой позиции?",
|
||
],
|
||
},
|
||
{
|
||
"name": "Опыт работы",
|
||
"duration_minutes": 15,
|
||
"questions": [
|
||
"Расскажи о своем опыте",
|
||
"Какие технологии используешь?",
|
||
],
|
||
},
|
||
{
|
||
"name": "Вопросы кандидата",
|
||
"duration_minutes": 5,
|
||
"questions": ["Есть ли у тебя вопросы?"],
|
||
},
|
||
],
|
||
},
|
||
"focus_areas": ["experience", "technical_skills"],
|
||
"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:
|
||
"""Обновляет информацию о процессе 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}"
|
||
)
|
||
return True
|
||
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
# Процесс уже не существует
|
||
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"
|
||
)
|
||
cleaned_count += 1
|
||
except psutil.NoSuchProcess:
|
||
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
|