ai-hackaton-backend/ai_interviewer_agent.py
2025-09-03 14:36:27 +05:00

416 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
import asyncio
import json
import logging
import os
from typing import Dict, List
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')
from livekit.agents import (
Agent,
AgentSession,
JobContext,
WorkerOptions,
cli,
)
from livekit.plugins import openai, deepgram, cartesia, silero, resemble
from rag.settings import settings
logger = logging.getLogger("ai-interviewer")
logger.setLevel(logging.INFO)
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.sections = self.interview_plan.get('interview_structure', {}).get('sections', [])
self.total_sections = len(self.sections)
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):
"""Переход к следующему вопросу"""
section = self.get_current_section()
questions = section.get('questions', [])
self.current_question_in_section += 1
self.questions_asked_total += 1
# Если вопросы в секции закончились, переходим к следующей
if self.current_question_in_section >= len(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:
"""Проверяет, завершено ли интервью"""
return self.current_section >= len(self.sections)
async def analyze_user_response(self, response: str, chat_model) -> Dict[str, str]:
"""Анализирует ответ пользователя и решает следующий шаг"""
try:
from rag.registry import registry
analysis_prompt = f"""
Проанализируй ответ кандидата на интервью и определи следующий шаг.
КОНТЕКСТ:
- Текущая секция: {self.get_current_section().get('name', 'Unknown')}
- Заданный вопрос: {self.last_question}
- Ответ кандидата: {response}
Оцени ответ и определи действие:
1. "continue" - ответ полный, переходим к следующему вопросу
2. "clarify" - нужно уточнить или углубить ответ
3. "redirect" - нужно перенаправить на тему
Ответь в JSON формате:
{{
"action": "continue|clarify|redirect",
"reason": "Объяснение решения",
"follow_up_question": "Уточняющий вопрос если action=clarify или redirect"
}}
"""
from langchain.schema import HumanMessage, SystemMessage
messages = [
SystemMessage(content="Ты эксперт-аналитик интервью. Анализируй ответы объективно."),
HumanMessage(content=analysis_prompt)
]
response_analysis = chat_model.chat(messages)
response_text = response_analysis.content.strip()
# Парсим JSON ответ
if response_text.startswith('{') and response_text.endswith('}'):
return json.loads(response_text)
else:
# Fallback
return {
"action": "continue",
"reason": "Не удалось проанализировать ответ",
"follow_up_question": ""
}
except Exception as e:
logger.error(f"Ошибка анализа ответа: {str(e)}")
return {
"action": "continue",
"reason": "Ошибка анализа",
"follow_up_question": ""
}
def _extract_questions_from_plan(self) -> List[str]:
"""Извлечение вопросов из готового плана интервью"""
questions = []
try:
# Начинаем с приветствия из плана
greeting = self.interview_plan.get('interview_structure', {}).get('greeting', 'Привет! Готов к интервью?')
questions.append(greeting)
# Извлекаем вопросы из секций
sections = self.interview_plan.get('interview_structure', {}).get('sections', [])
for section in sections:
section_questions = section.get('questions', [])
questions.extend(section_questions)
return questions
except Exception as e:
logger.error(f"Ошибка извлечения вопросов из плана: {str(e)}")
# Fallback вопросы
return [
"Привет! Расскажи немного о себе",
"Какой у тебя опыт работы?",
"Что тебя привлекает в этой позиции?",
"Есть ли у тебя вопросы ко мне?"
]
def get_system_instructions(self) -> str:
"""Системные инструкции для AI агента"""
candidate_info = self.interview_plan.get('candidate_info', {})
interview_structure = self.interview_plan.get('interview_structure', {})
focus_areas = self.interview_plan.get('focus_areas', [])
greeting = interview_structure.get('greeting', 'Привет! Готов к интервью?')
current_section = self.get_current_section()
current_section_name = current_section.get('name', 'Неизвестно')
progress = f"{self.current_section + 1}/{len(self.sections)}"
return f"""Ты опытный HR-интервьюер, который проводит структурированное голосовое собеседование.
ИНФОРМАЦИЯ О КАНДИДАТЕ:
- Имя: {candidate_info.get('name', 'Кандидат')}
- Опыт работы: {candidate_info.get('total_years', 0)} лет
- Ключевые навыки: {', '.join(candidate_info.get('skills', []))}
ТЕКУЩЕЕ СОСТОЯНИЕ ИНТЕРВЬЮ:
- Прогресс: {progress} секций
- Текущая секция: {current_section_name}
- Вопросов задано: {self.questions_asked_total}
ПЛАН ИНТЕРВЬЮ:
{json.dumps(interview_structure.get('sections', []), ensure_ascii=False, indent=2)}
ТВОЯ ЗАДАЧА:
1. Веди живое интерактивное интервью
2. Анализируй каждый ответ кандидата
3. Принимай решения:
- Если ответ полный и достаточный → переходи к следующему вопросу
- Если ответ поверхностный → задавай уточняющие вопросы
- Если кандидат ушел от темы → мягко возвращай к вопросу
4. Поддерживай естественный диалог
ПРАВИЛА ВЕДЕНИЯ ДИАЛОГА:
✅ Говори только на русском языке
✅ Задавай один вопрос за раз и жди ответа
✅ Анализируй качество и полноту каждого ответа
✅ Адаптируй следующие вопросы под полученные ответы
✅ Показывай искреннюю заинтересованность
✅ Если ответ неполный - углубляйся: "Расскажи подробнее...", "А как именно ты..."
✅ При переходе между секциями делай плавные переходы
✅ Завершай интервью благодарностью и следующими шагами
ПРИМЕРЫ РЕАКЦИЙ НА ОТВЕТЫ:
- Короткий ответ: "Интересно! А можешь рассказать конкретный пример?"
- Хороший ответ: "Отлично! Давай перейдем к следующему вопросу..."
- Уход от темы: "Понимаю, но давай вернемся к..."
НАЧНИ С ПРИВЕТСТВИЯ: {greeting}
"""
async def entrypoint(ctx: JobContext):
"""Точка входа для AI агента"""
logger.info("Starting AI Interviewer Agent")
# Получаем данные о резюме из метаданных комнаты
room_metadata = ctx.room.metadata if ctx.room.metadata else "{}"
try:
metadata = json.loads(room_metadata)
interview_plan = metadata.get("interview_plan", {})
if not hasattr(interview_plan, 'interview_structure'):
raise ValueError
except:
# Fallback план для тестирования
interview_plan = {
"interview_structure": {
"duration_minutes": 30,
"greeting": "Привет! Готов к тестовому интервью?",
"sections": [
{
"name": "Знакомство",
"duration_minutes": 10,
"questions": ["Расскажи о себе", "Что тебя привлекло в этой позиции?"]
},
{
"name": "Технические навыки",
"duration_minutes": 15,
"questions": ["Расскажи о своем опыте с Python", "Какие проекты разрабатывал?"]
},
{
"name": "Вопросы кандидата",
"duration_minutes": 5,
"questions": ["Есть ли у тебя вопросы ко мне?"]
}
]
},
"focus_areas": ["technical_skills", "experience"],
"candidate_info": {
"name": "Тестовый кандидат",
"skills": ["Python", "React", "PostgreSQL"],
"total_years": 3,
"education": "Высшее техническое"
}
}
logger.info(f"Interview plan: {interview_plan}")
# Создаем интервьюера с планом
interviewer = InterviewAgent(interview_plan)
# Настройка STT (Speech-to-Text)
if hasattr(settings, 'deepgram_api_key') and settings.deepgram_api_key:
stt = deepgram.STT(
model="nova-2-general",
language="ru", # Русский язык
api_key=settings.deepgram_api_key
)
else:
# Fallback на OpenAI Whisper
stt = 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 (Text-to-Speech)
if hasattr(settings, 'resemble_api_key') and settings.resemble_api_key:
tts = resemble.TTS(
voice_uuid="55592656",
api_key=settings.resemble_api_key
)
else:
# Fallback на локальный TTS
tts = silero.TTS(
language="ru",
model="v4_ru"
)
# Создание агента с системными инструкциями
agent = Agent(
instructions=interviewer.get_system_instructions()
)
# Создание сессии агента
session = AgentSession(
vad=silero.VAD.load(), # Voice Activity Detection
stt=stt,
llm=llm,
tts=tts,
)
# Добавляем обработчики событий с управлением диалогом
@session.on("user_speech_committed")
def on_user_speech(msg):
"""Синхронный callback. Внутри создаётся async-задача."""
async def handler():
user_response = msg.content
logger.info(f"User said: {user_response}")
# Сохраняем историю
interviewer.conversation_history.append({
"role": "user",
"content": user_response,
"timestamp": datetime.utcnow().isoformat(),
"section": interviewer.get_current_section().get('name', 'Unknown')
})
interviewer.last_user_response = user_response
interviewer.waiting_for_response = False
try:
# Анализ ответа
analysis = await interviewer.analyze_user_response(user_response, llm)
action = analysis.get("action", "continue")
logger.info(f"Response analysis: {action} - {analysis.get('reason', 'No reason')}")
if action == "continue":
interviewer.move_to_next_question()
if not interviewer.is_interview_complete():
next_question = interviewer.get_next_question()
if next_question:
await session.say(next_question)
interviewer.last_question = next_question
interviewer.waiting_for_response = True
else:
await session.say(
"Спасибо за интервью! Это все вопросы, которые я хотел задать. "
"В ближайшее время мы свяжемся с тобой по результатам."
)
elif action in ["clarify", "redirect"]:
follow_up = analysis.get("follow_up_question", "Можешь рассказать подробнее?")
await session.say(follow_up)
interviewer.waiting_for_response = True
except Exception as e:
logger.error(f"Ошибка обработки ответа пользователя: {str(e)}")
interviewer.move_to_next_question()
# запускаем асинхронный обработчик
asyncio.create_task(handler())
@session.on("agent_speech_committed")
def on_agent_speech(msg):
"""Обработка речи агента"""
agent_response = msg.content
logger.info(f"Agent said: {agent_response}")
# Сохраняем в историю
interviewer.conversation_history.append({
"role": "assistant",
"content": agent_response,
"timestamp": datetime.utcnow().isoformat(),
"section": interviewer.get_current_section().get('name', 'Unknown')
})
# Если это вопрос, обновляем состояние
if "?" in agent_response:
interviewer.last_question = agent_response
interviewer.waiting_for_response = True
# Запускаем сессию агента
await session.start(agent=agent, room=ctx.room)
# Приветственное сообщение
# В новой версии приветствие будет автоматически отправлено из системных инструкций
logger.info("AI Interviewer started successfully")
def main():
"""Запуск агента"""
logging.basicConfig(level=logging.INFO)
# Настройки воркера
worker_options = WorkerOptions(
entrypoint_fnc=entrypoint,
)
# Запуск через CLI
cli.run_app(worker_options)
if __name__ == "__main__":
main()