480 lines
23 KiB
Python
480 lines
23 KiB
Python
# -*- 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
|
||
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
|
||
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
|
||
|
||
logger = logging.getLogger("ai-interviewer")
|
||
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)
|
||
# Создаем 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
|
||
|
||
|
||
class InterviewAgent:
|
||
"""AI Agent для проведения собеседований с управлением диалогом"""
|
||
|
||
def __init__(self, interview_plan: Dict):
|
||
self.interview_plan = interview_plan
|
||
self.conversation_history = []
|
||
|
||
# Состояние диалога
|
||
self.current_section = 0
|
||
self.current_question_in_section = 0
|
||
self.questions_asked_total = 0
|
||
self.waiting_for_response = False
|
||
self.last_question = None
|
||
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:
|
||
"""Получить текущую секцию интервью"""
|
||
if self.current_section < len(self.sections):
|
||
return self.sections[self.current_section]
|
||
return {}
|
||
|
||
def get_next_question(self) -> str:
|
||
"""Получить следующий вопрос"""
|
||
section = self.get_current_section()
|
||
questions = section.get('questions', [])
|
||
if self.current_question_in_section < len(questions):
|
||
return questions[self.current_question_in_section]
|
||
return None
|
||
|
||
def move_to_next_question(self):
|
||
"""Переход к следующему вопросу"""
|
||
self.current_question_in_section += 1
|
||
self.questions_asked_total += 1
|
||
|
||
section = self.get_current_section()
|
||
if self.current_question_in_section >= len(section.get('questions', [])):
|
||
self.move_to_next_section()
|
||
|
||
def move_to_next_section(self):
|
||
"""Переход к следующей секции"""
|
||
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')}")
|
||
|
||
def is_interview_complete(self) -> bool:
|
||
"""Интервью завершается только по решению LLM через ключевые фразы"""
|
||
return False # LLM теперь решает через ключевые фразы
|
||
|
||
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', [])
|
||
|
||
# Вычисляем текущее время интервью
|
||
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
|
||
])
|
||
|
||
# Безопасно формируем строки для избежания конфликтов с кавычками
|
||
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 = 'СРОЧНО ЗАВЕРШАТЬ'
|
||
elif time_percentage > 75:
|
||
time_status = 'ВРЕМЯ ЗАКАНЧИВАЕТСЯ'
|
||
else:
|
||
time_status = 'НОРМАЛЬНО'
|
||
|
||
return f"""Ты опытный HR-интервьюер, который проводит адаптивное голосовое собеседование.
|
||
|
||
ИНФОРМАЦИЯ О КАНДИДАТЕ:
|
||
- Имя: {candidate_name}
|
||
- Опыт работы: {candidate_years} лет
|
||
- Ключевые навыки: {candidate_skills}
|
||
|
||
ПЛАН ИНТЕРВЬЮ (используй как руководство, но адаптируйся):
|
||
{sections_info}
|
||
|
||
ВРЕМЯ ИНТЕРВЬЮ:
|
||
- Запланированная длительность: {self.duration_minutes} минут
|
||
- Прошло времени: {elapsed_minutes:.1f} минут ({time_percentage:.0f}%)
|
||
- Осталось времени: {remaining_minutes:.1f} минут
|
||
- Статус времени: {time_status}
|
||
|
||
ФОКУС-ОБЛАСТИ: {focus_areas_str}
|
||
КЛЮЧЕВЫЕ ОЦЕНОЧНЫЕ ТОЧКИ: {evaluation_points_str}
|
||
|
||
ИНСТРУКЦИИ:
|
||
1. Начни с приветствия: {greeting}
|
||
2. Адаптируй вопросы под ответы кандидата
|
||
3. Следи за временем - при превышении 80% времени начинай завершать интервью
|
||
4. Оценивай качество и глубину ответов кандидата
|
||
5. Завершай интервью если:
|
||
- Получил достаточно информации для оценки
|
||
- Время почти истекло (>90% от запланированного)
|
||
- Кандидат дал исчерпывающие ответы
|
||
6. При завершении спроси о вопросах кандидата и поблагодари
|
||
|
||
ВАЖНО: Отвечай естественно и разговорно, как живой интервьюер!
|
||
|
||
ЗАВЕРШЕНИЕ ИНТЕРВЬЮ:
|
||
Когда нужно завершить интервью (время истекло, получена достаточная информация),
|
||
используй одну из этих ключевых фраз:
|
||
- "Спасибо за интересную беседу! У тебя есть вопросы ко мне?"
|
||
- "Это всё, что я хотел узнать. Есть ли у вас вопросы?"
|
||
- "Интервью подходит к концу. У тебя есть вопросы ко мне?"
|
||
ФИНАЛЬНАЯ ФРАЗА после которой ничего не будет:
|
||
- До скорой встречи!
|
||
|
||
ЗАВЕРШАЙ ИНТЕРВЬЮ, если:
|
||
- Прошло >80% времени И получил основную информацию
|
||
- Кандидат дал полные ответы по всем ключевым областям
|
||
- Возникли технические проблемы или кандидат просит завершить
|
||
|
||
СТИЛЬ: Дружелюбный, профессиональный, заинтересованный в кандидате.
|
||
"""
|
||
|
||
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,
|
||
"time_percentage": time_percentage,
|
||
"duration_minutes": self.duration_minutes
|
||
}
|
||
|
||
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'),
|
||
"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}%"
|
||
}
|
||
|
||
|
||
async def entrypoint(ctx: JobContext):
|
||
"""Точка входа для AI агента"""
|
||
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 = {}
|
||
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")
|
||
interview_plan = {
|
||
"interview_structure": {
|
||
"duration_minutes": 2, # ТЕСТОВЫЙ РЕЖИМ - 2 минуты
|
||
"greeting": "Привет! Это быстрое тестовое интервью на 2 минуты. Готов?",
|
||
"sections": [
|
||
{"name": "Знакомство", "duration_minutes": 1, "questions": ["Расскажи кратко о себе одним предложением"]},
|
||
{"name": "Завершение", "duration_minutes": 1, "questions": ["Спасибо! Есть вопросы ко мне?"]}
|
||
]
|
||
},
|
||
"candidate_info": {"name": "Тестовый кандидат", "skills": ["Python", "React"], "total_years": 3},
|
||
"focus_areas": ["quick_test"],
|
||
"key_evaluation_points": ["Коммуникация"]
|
||
}
|
||
|
||
interviewer = InterviewAgent(interview_plan)
|
||
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)
|
||
|
||
# LLM
|
||
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")
|
||
|
||
# Создаем обычный Agent и Session
|
||
agent = Agent(instructions=interviewer.get_system_instructions())
|
||
|
||
# Создаем AgentSession с обычным TTS
|
||
session = AgentSession(vad=silero.VAD.load(), stt=stt, llm=llm, tts=tts)
|
||
|
||
# --- Сохранение диалога в БД ---
|
||
async def save_dialogue_to_db(room_name: str, dialogue_history: list):
|
||
try:
|
||
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)
|
||
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}")
|
||
finally:
|
||
await session_generator.aclose()
|
||
except Exception as e:
|
||
logger.error(f"[DB] Error saving dialogue: {str(e)}")
|
||
|
||
# --- Логика завершения интервью ---
|
||
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}")
|
||
|
||
# Собираем метрики интервью
|
||
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']
|
||
}
|
||
|
||
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)
|
||
|
||
# Используем сервис для завершения интервью
|
||
result = await finalization_service.finalize_interview(
|
||
room_name=room_name,
|
||
dialogue_history=interviewer_instance.conversation_history,
|
||
interview_metrics=interview_metrics
|
||
)
|
||
|
||
if result:
|
||
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}")
|
||
finally:
|
||
await session_generator.aclose()
|
||
except Exception as e:
|
||
logger.error(f"[FINALIZE] Error finalizing interview: {str(e)}")
|
||
|
||
# --- Проверка завершения интервью по ключевым фразам ---
|
||
async def check_interview_completion_by_keywords(agent_text: str):
|
||
"""Проверяет завершение интервью по ключевым фразам"""
|
||
# Ключевые фразы для завершения интервью
|
||
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")
|
||
|
||
if not interviewer.interview_finalized:
|
||
# Запускаем полную цепочку завершения интервью
|
||
await complete_interview_sequence(ctx.room.name, interviewer)
|
||
return True
|
||
break
|
||
|
||
return False
|
||
|
||
# --- Полная цепочка завершения интервью ---
|
||
async def complete_interview_sequence(room_name: str, interviewer_instance):
|
||
"""
|
||
Полная цепочка завершения интервью:
|
||
1. Финализация диалога в БД
|
||
2. Закрытие комнаты LiveKit
|
||
3. Завершение процесса агента
|
||
"""
|
||
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:
|
||
await close_room(room_name)
|
||
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")
|
||
|
||
# Шаг 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)
|
||
|
||
|
||
# --- Упрощенная логика обработки пользовательского ответа ---
|
||
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
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
"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']}")
|
||
|
||
|
||
# Обновляем инструкции агента с текущим прогрессом
|
||
try:
|
||
updated_instructions = interviewer.get_system_instructions()
|
||
await agent.update_instructions(updated_instructions)
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Failed to update instructions: {str(e)}")
|
||
|
||
@session.on("conversation_item_added")
|
||
def on_conversation_item(event):
|
||
role = event.item.role
|
||
text = event.item.text_content
|
||
|
||
if role == "user":
|
||
asyncio.create_task(handle_user_input(text))
|
||
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')
|
||
})
|
||
|
||
# Сохраняем диалог в БД
|
||
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
|
||
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|