fixes
This commit is contained in:
parent
8fa727333a
commit
6e1c631d70
@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Принудительно устанавливаем UTF-8 для Windows
|
# Принудительно устанавливаем UTF-8 для Windows
|
||||||
@ -23,6 +24,7 @@ from app.core.database import get_session
|
|||||||
from app.repositories.interview_repository import InterviewRepository
|
from app.repositories.interview_repository import InterviewRepository
|
||||||
from app.repositories.resume_repository import ResumeRepository
|
from app.repositories.resume_repository import ResumeRepository
|
||||||
from app.services.interview_finalization_service import InterviewFinalizationService
|
from app.services.interview_finalization_service import InterviewFinalizationService
|
||||||
|
from app.services.interview_service import InterviewRoomService
|
||||||
from rag.settings import settings
|
from rag.settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger("ai-interviewer")
|
logger = logging.getLogger("ai-interviewer")
|
||||||
@ -61,7 +63,7 @@ class InterviewAgent:
|
|||||||
self.last_user_response = None
|
self.last_user_response = None
|
||||||
self.intro_done = False # Новый флаг — произнесено ли приветствие
|
self.intro_done = False # Новый флаг — произнесено ли приветствие
|
||||||
self.interview_finalized = False # Флаг завершения интервью
|
self.interview_finalized = False # Флаг завершения интервью
|
||||||
|
|
||||||
# Трекинг времени интервью
|
# Трекинг времени интервью
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@ -160,9 +162,35 @@ class InterviewAgent:
|
|||||||
- Опыт работы: {candidate_years} лет
|
- Опыт работы: {candidate_years} лет
|
||||||
- Ключевые навыки: {candidate_skills}
|
- Ключевые навыки: {candidate_skills}
|
||||||
|
|
||||||
ПЛАН ИНТЕРВЬЮ (используй как руководство, но адаптируйся):
|
ЦЕЛЬ ИНТЕРВЬЮ:
|
||||||
|
|
||||||
|
Найти кандидата, который не только подходит по техническим навыкам, но и силён по мягким навыкам, культуре и потенциалу.
|
||||||
|
Задачи интервью:
|
||||||
|
- Выявить сильные и слабые стороны кандидата.
|
||||||
|
- Понять, насколько он подходит к вакансии и соответствует интервью.
|
||||||
|
- Проверить мышление, мотивацию и способность адаптироваться.
|
||||||
|
|
||||||
|
ПОКАЗАТЕЛИ "ДОСТОЙНОГО КАНДИДАТА":
|
||||||
|
- Глубокое понимание ключевых технологий ({candidate_skills}).
|
||||||
|
- Умение решать проблемы, а не просто отвечать на вопросы.
|
||||||
|
- Чёткая и логичная коммуникация.
|
||||||
|
- Способность учиться и адаптироваться.
|
||||||
|
- Совпадение ценностей и принципов с командой и компанией.
|
||||||
|
|
||||||
|
ПЛАН ИНТЕРВЬЮ (как руководство, адаптируйся по ситуации)
|
||||||
|
|
||||||
{sections_info}
|
{sections_info}
|
||||||
|
|
||||||
|
ТИПЫ ВОПРОСОВ:
|
||||||
|
Поведенческие (30%) — выяснить, как кандидат действовал в реальных ситуациях.
|
||||||
|
Пример: "Расскажи про ситуацию, когда ты столкнулся с трудной задачей на проекте. Что ты сделал?"
|
||||||
|
|
||||||
|
Технические (50%) — проверить глубину знаний и практические навыки.
|
||||||
|
Пример: "Как бы ты реализовал X?" или "Объясни разницу между A и B."
|
||||||
|
|
||||||
|
Проблемные / кейсы (20%) — проверить мышление и подход к решению.
|
||||||
|
Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?"
|
||||||
|
|
||||||
ВРЕМЯ ИНТЕРВЬЮ:
|
ВРЕМЯ ИНТЕРВЬЮ:
|
||||||
- Запланированная длительность: {self.duration_minutes} минут
|
- Запланированная длительность: {self.duration_minutes} минут
|
||||||
- Прошло времени: {elapsed_minutes:.1f} минут ({time_percentage:.0f}%)
|
- Прошло времени: {elapsed_minutes:.1f} минут ({time_percentage:.0f}%)
|
||||||
@ -172,26 +200,36 @@ class InterviewAgent:
|
|||||||
ФОКУС-ОБЛАСТИ: {focus_areas_str}
|
ФОКУС-ОБЛАСТИ: {focus_areas_str}
|
||||||
КЛЮЧЕВЫЕ ОЦЕНОЧНЫЕ ТОЧКИ: {evaluation_points_str}
|
КЛЮЧЕВЫЕ ОЦЕНОЧНЫЕ ТОЧКИ: {evaluation_points_str}
|
||||||
|
|
||||||
|
КРАСНЫЕ ФЛАГИ:
|
||||||
|
Во время интервью отмечай следующие негативные сигналы:
|
||||||
|
- Не может объяснить собственные решения.
|
||||||
|
- Противоречит сам себе или врёт.
|
||||||
|
- Агрессивная, пассивная или неуважительная коммуникация.
|
||||||
|
- Нет желания учиться или интереса к проекту.
|
||||||
|
- Перекладывает ответственность на других, не признаёт ошибок.
|
||||||
|
|
||||||
ИНСТРУКЦИИ:
|
ИНСТРУКЦИИ:
|
||||||
1. Начни с приветствия: {greeting}
|
1. Начни с приветствия: {greeting}
|
||||||
2. Адаптируй вопросы под ответы кандидата
|
2. Адаптируй вопросы под ответы кандидата
|
||||||
|
3. Не повторяй то, что клиент тебе сказал, лучше показывай, что понял, услышал и иди дальше. Лишний раз его не хвали
|
||||||
3. Следи за временем - при превышении 80% времени начинай завершать интервью
|
3. Следи за временем - при превышении 80% времени начинай завершать интервью
|
||||||
4. Оценивай качество и глубину ответов кандидата
|
4. Оценивай качество и глубину ответов кандидата
|
||||||
5. Завершай интервью если:
|
5. Если получаешь сообщение "[СИСТЕМА] Клиент молчит..." - это означает проблемы со связью или кандидат растерялся. Скажи что-то вроде "Приём! Ты меня слышишь?" или "Всё в порядке? Связь не пропала?"
|
||||||
|
6. Завершай интервью если:
|
||||||
- Получил достаточно информации для оценки
|
- Получил достаточно информации для оценки
|
||||||
- Время почти истекло (>90% от запланированного)
|
- Время почти истекло (>90% от запланированного)
|
||||||
- Кандидат дал исчерпывающие ответы
|
- Кандидат дал исчерпывающие ответы
|
||||||
6. При завершении спроси о вопросах кандидата и поблагодари
|
- Получаешь сообщение "[СИСТЕМА] Похоже клиент отключился"
|
||||||
|
7. При завершении спроси о вопросах кандидата и поблагодари
|
||||||
|
|
||||||
ВАЖНО: Отвечай естественно и разговорно, как живой интервьюер!
|
ВАЖНО: Отвечай естественно и разговорно, как живой интервьюер!
|
||||||
|
|
||||||
ЗАВЕРШЕНИЕ ИНТЕРВЬЮ:
|
ЗАВЕРШЕНИЕ ИНТЕРВЬЮ:
|
||||||
Когда нужно завершить интервью (время истекло, получена достаточная информация),
|
Когда нужно завершить интервью (время истекло, получена достаточная информация),
|
||||||
используй одну из этих ключевых фраз:
|
используй фразу типа:
|
||||||
- "Спасибо за интересную беседу! У тебя есть вопросы ко мне?"
|
- "Спасибо за интересную беседу! Интервью подходит к концу. У тебя есть вопросы ко мне?"
|
||||||
- "Это всё, что я хотел узнать. Есть ли у вас вопросы?"
|
|
||||||
- "Интервью подходит к концу. У тебя есть вопросы ко мне?"
|
ФИНАЛЬНАЯ ФРАЗА после которой конец интервью:
|
||||||
ФИНАЛЬНАЯ ФРАЗА после которой ничего не будет:
|
|
||||||
- До скорой встречи!
|
- До скорой встречи!
|
||||||
|
|
||||||
ЗАВЕРШАЙ ИНТЕРВЬЮ, если:
|
ЗАВЕРШАЙ ИНТЕРВЬЮ, если:
|
||||||
@ -288,8 +326,8 @@ async def entrypoint(ctx: JobContext):
|
|||||||
logger.info("[INIT] Using default interview plan")
|
logger.info("[INIT] Using default interview plan")
|
||||||
interview_plan = {
|
interview_plan = {
|
||||||
"interview_structure": {
|
"interview_structure": {
|
||||||
"duration_minutes": 2, # ТЕСТОВЫЙ РЕЖИМ - 2 минуты
|
"duration_minutes": 5, # ТЕСТОВЫЙ РЕЖИМ - 5 минут
|
||||||
"greeting": "Привет! Это быстрое тестовое интервью на 2 минуты. Готов?",
|
"greeting": "Привет! Это быстрое тестовое интервью на 5 минут. Готов?",
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"name": "Знакомство",
|
"name": "Знакомство",
|
||||||
@ -348,8 +386,14 @@ async def entrypoint(ctx: JobContext):
|
|||||||
# Создаем обычный Agent и Session
|
# Создаем обычный Agent и Session
|
||||||
agent = Agent(instructions=interviewer.get_system_instructions())
|
agent = Agent(instructions=interviewer.get_system_instructions())
|
||||||
|
|
||||||
# Создаем AgentSession с обычным TTS
|
# Создаем AgentSession с обычным TTS и детекцией неактивности пользователя
|
||||||
session = AgentSession(vad=silero.VAD.load(), stt=stt, llm=llm, tts=tts)
|
session = AgentSession(
|
||||||
|
vad=silero.VAD.load(),
|
||||||
|
stt=stt,
|
||||||
|
llm=llm,
|
||||||
|
tts=tts,
|
||||||
|
user_away_timeout=7.0 # 7 секунд неактивности для срабатывания away
|
||||||
|
)
|
||||||
|
|
||||||
# --- Сохранение диалога в БД ---
|
# --- Сохранение диалога в БД ---
|
||||||
async def save_dialogue_to_db(room_name: str, dialogue_history: list):
|
async def save_dialogue_to_db(room_name: str, dialogue_history: list):
|
||||||
@ -446,7 +490,20 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
if not interviewer.interview_finalized:
|
if not interviewer.interview_finalized:
|
||||||
# Запускаем полную цепочку завершения интервью
|
# Запускаем полную цепочку завершения интервью
|
||||||
await complete_interview_sequence(ctx.room.name, interviewer)
|
try:
|
||||||
|
session_generator = get_session()
|
||||||
|
db = await anext(session_generator)
|
||||||
|
try:
|
||||||
|
interview_repo = InterviewRepository(db)
|
||||||
|
resume_repo = ResumeRepository(db)
|
||||||
|
interview_service = InterviewRoomService(
|
||||||
|
interview_repo, resume_repo
|
||||||
|
)
|
||||||
|
await interview_service.end_interview_session(session_id)
|
||||||
|
finally:
|
||||||
|
await session_generator.aclose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FINALIZE] Error finalizing interview: {str(e)}")
|
||||||
return True
|
return True
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -477,7 +534,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(2) # Проверяем каждые 2 секунды
|
await asyncio.sleep(1) # Проверяем каждые 1 секунды
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[COMMAND] Error monitoring commands: {str(e)}")
|
logger.error(f"[COMMAND] Error monitoring commands: {str(e)}")
|
||||||
@ -485,6 +542,22 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
# Запускаем мониторинг команд в фоне
|
# Запускаем мониторинг команд в фоне
|
||||||
asyncio.create_task(monitor_end_commands())
|
asyncio.create_task(monitor_end_commands())
|
||||||
|
|
||||||
|
# --- Обработчик состояния пользователя (замена мониторинга тишины) ---
|
||||||
|
@session.on("user_state_changed")
|
||||||
|
def on_user_state_changed(event):
|
||||||
|
"""Обработчик изменения состояния пользователя (активен/неактивен)"""
|
||||||
|
async def on_change():
|
||||||
|
logger.info(f"[USER_STATE] User state changed to: {event.new_state}")
|
||||||
|
|
||||||
|
if event.new_state == "away" and interviewer.intro_done:
|
||||||
|
logger.info("[USER_STATE] User went away, generating response...")
|
||||||
|
|
||||||
|
# Генерируем ответ через LLM с инструкциями
|
||||||
|
await session.generate_reply(
|
||||||
|
instructions="Клиент молчит уже больше 10 секунд. Проверь связь фразой вроде 'Приём! Ты меня слышишь?' или 'Связь не пропала?'"
|
||||||
|
)
|
||||||
|
asyncio.create_task(on_change())
|
||||||
|
|
||||||
# --- Полная цепочка завершения интервью ---
|
# --- Полная цепочка завершения интервью ---
|
||||||
async def complete_interview_sequence(room_name: str, interviewer_instance):
|
async def complete_interview_sequence(room_name: str, interviewer_instance):
|
||||||
@ -494,47 +567,31 @@ async def entrypoint(ctx: JobContext):
|
|||||||
2. Закрытие комнаты LiveKit
|
2. Закрытие комнаты LiveKit
|
||||||
3. Завершение процесса агента
|
3. Завершение процесса агента
|
||||||
"""
|
"""
|
||||||
|
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:
|
try:
|
||||||
logger.info("[SEQUENCE] Starting interview completion sequence")
|
await close_room(room_name)
|
||||||
|
logger.info(f"[SEQUENCE] Step 2: Room {room_name} closed successfully")
|
||||||
# Шаг 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:
|
except Exception as e:
|
||||||
logger.error(f"[SEQUENCE] Error in interview completion sequence: {str(e)}")
|
logger.error(f"[SEQUENCE] Step 2: Failed to close room: {str(e)}")
|
||||||
# Fallback: принудительно завершаем процесс даже при ошибках
|
logger.info(
|
||||||
logger.info("[SEQUENCE] Fallback: Force terminating process")
|
"[SEQUENCE] Step 2: Room closure failed, but continuing sequence"
|
||||||
await asyncio.sleep(1)
|
)
|
||||||
import os
|
|
||||||
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Упрощенная логика обработки пользовательского ответа ---
|
# --- Упрощенная логика обработки пользовательского ответа ---
|
||||||
async def handle_user_input(user_response: str):
|
async def handle_user_input(user_response: str):
|
||||||
|
|
||||||
current_section = interviewer.get_current_section()
|
current_section = interviewer.get_current_section()
|
||||||
|
|
||||||
# Сохраняем ответ пользователя
|
# Сохраняем ответ пользователя
|
||||||
@ -575,6 +632,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
if role == "user":
|
if role == "user":
|
||||||
asyncio.create_task(handle_user_input(text))
|
asyncio.create_task(handle_user_input(text))
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
|
|
||||||
# Сохраняем ответ агента в историю диалога
|
# Сохраняем ответ агента в историю диалога
|
||||||
current_section = interviewer.get_current_section()
|
current_section = interviewer.get_current_section()
|
||||||
interviewer.conversation_history.append(
|
interviewer.conversation_history.append(
|
||||||
|
@ -1,300 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from livekit import rtc
|
|
||||||
|
|
||||||
from rag.settings import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AIInterviewerService:
|
|
||||||
"""Сервис AI интервьюера, который подключается к LiveKit комнате как участник"""
|
|
||||||
|
|
||||||
def __init__(self, interview_session_id: int, resume_data: dict):
|
|
||||||
self.interview_session_id = interview_session_id
|
|
||||||
self.resume_data = resume_data
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Публикация аудио трека
|
|
||||||
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"})
|
|
||||||
|
|
||||||
async def on_track_subscribed(
|
|
||||||
self,
|
|
||||||
track: rtc.Track,
|
|
||||||
publication: rtc.RemoteTrackPublication,
|
|
||||||
participant: rtc.RemoteParticipant,
|
|
||||||
):
|
|
||||||
"""Обработка получения аудио трека от пользователя"""
|
|
||||||
if track.kind == rtc.TrackKind.KIND_AUDIO:
|
|
||||||
logger.info("Subscribed to user audio track")
|
|
||||||
# Настройка обработки аудио для 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:
|
|
||||||
message = json.loads(data.decode())
|
|
||||||
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):
|
|
||||||
"""Обработка сообщений от фронтенда"""
|
|
||||||
msg_type = message.get("type")
|
|
||||||
|
|
||||||
if msg_type == "start_interview":
|
|
||||||
await self.start_interview()
|
|
||||||
elif msg_type == "end_interview":
|
|
||||||
await self.end_interview()
|
|
||||||
elif msg_type == "user_finished_speaking":
|
|
||||||
# Пользователь закончил говорить, можем обрабатывать его ответ
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def process_user_audio(self, audio_stream: rtc.AudioStream):
|
|
||||||
"""Обработка аудио от пользователя через STT"""
|
|
||||||
try:
|
|
||||||
# Здесь будет интеграция с STT сервисом
|
|
||||||
# Пока заглушка
|
|
||||||
async for audio_frame in audio_stream:
|
|
||||||
# TODO: Отправить аудио в STT (Whisper API)
|
|
||||||
# user_text = await self.speech_to_text(audio_frame)
|
|
||||||
# if user_text:
|
|
||||||
# await self.process_user_response(user_text)
|
|
||||||
pass
|
|
||||||
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", "Не указано")}
|
|
||||||
|
|
||||||
ВАЖНО:
|
|
||||||
1. Вопросы должны быть короткими и ясными для голосового формата
|
|
||||||
2. Начни с простого приветствия и представления
|
|
||||||
3. Каждый вопрос должен занимать не более 2-3 предложений
|
|
||||||
4. Используй естественную разговорную речь
|
|
||||||
|
|
||||||
Верни только JSON массив строк с вопросами:
|
|
||||||
["Привет! Расскажи немного о себе", "Какой у тебя опыт в...", ...]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from langchain.schema import HumanMessage, SystemMessage
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
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("]"):
|
|
||||||
self.interview_questions = json.loads(response_text)
|
|
||||||
else:
|
|
||||||
# Fallback вопросы
|
|
||||||
self.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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Конвертируем в речь и воспроизводим
|
|
||||||
# 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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Можем добавить анализ ответа через LLM
|
|
||||||
# И решить - задать уточняющий вопрос или перейти к следующему
|
|
||||||
|
|
||||||
# Пока просто переходим к следующему вопросу
|
|
||||||
await asyncio.sleep(1) # Небольшая пауза
|
|
||||||
await self.ask_next_question()
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сохраняем транскрипт в базу данных
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
):
|
|
||||||
"""Запуск AI интервьюера для сессии"""
|
|
||||||
try:
|
|
||||||
# Создаем токен для AI агента
|
|
||||||
# Нужно создать специальный токен для 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:
|
|
||||||
ai_interviewer = self.active_sessions[interview_session_id]
|
|
||||||
await ai_interviewer.end_interview()
|
|
||||||
del self.active_sessions[interview_session_id]
|
|
||||||
logger.info(f"Stopped AI interviewer for session: {interview_session_id}")
|
|
||||||
|
|
||||||
|
|
||||||
# Глобальный менеджер
|
|
||||||
ai_interviewer_manager = AIInterviewerManager()
|
|
@ -6,7 +6,7 @@ celery_app = Celery(
|
|||||||
"hr_ai_backend",
|
"hr_ai_backend",
|
||||||
broker=f"redis://{settings.redis_cache_url}:{settings.redis_cache_port}/{settings.redis_cache_db}",
|
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}",
|
backend=f"redis://{settings.redis_cache_url}:{settings.redis_cache_port}/{settings.redis_cache_db}",
|
||||||
include=["celery_worker.tasks"],
|
include=["celery_worker.tasks", "celery_worker.interview_analysis_task"],
|
||||||
)
|
)
|
||||||
|
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
|
@ -51,15 +51,30 @@ def generate_interview_report(resume_id: int):
|
|||||||
|
|
||||||
# Получаем историю интервью
|
# Получаем историю интервью
|
||||||
interview_session = _get_interview_session(db, resume_id)
|
interview_session = _get_interview_session(db, resume_id)
|
||||||
|
logger.info(f"[INTERVIEW_ANALYSIS] Found interview_session: {interview_session is not None}")
|
||||||
|
|
||||||
|
if interview_session:
|
||||||
|
logger.info(f"[INTERVIEW_ANALYSIS] Session ID: {interview_session.id}, dialogue_history length: {len(interview_session.dialogue_history) if interview_session.dialogue_history else 0}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[INTERVIEW_ANALYSIS] No interview session found for resume_id: {resume_id}")
|
||||||
|
|
||||||
# Парсим JSON данные
|
# Парсим JSON данные
|
||||||
parsed_resume = _parse_json_field(resume.parsed_data)
|
parsed_resume = _parse_json_field(resume.parsed_data)
|
||||||
interview_plan = _parse_json_field(resume.interview_plan)
|
interview_plan = _parse_json_field(resume.interview_plan)
|
||||||
dialogue_history = (
|
# Парсим dialogue_history отдельно (это список, а не словарь)
|
||||||
_parse_json_field(interview_session.dialogue_history)
|
dialogue_history = []
|
||||||
if interview_session
|
if interview_session and interview_session.dialogue_history:
|
||||||
else []
|
if isinstance(interview_session.dialogue_history, list):
|
||||||
)
|
dialogue_history = interview_session.dialogue_history
|
||||||
|
elif isinstance(interview_session.dialogue_history, str):
|
||||||
|
try:
|
||||||
|
dialogue_history = json.loads(interview_session.dialogue_history)
|
||||||
|
if not isinstance(dialogue_history, list):
|
||||||
|
dialogue_history = []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
dialogue_history = []
|
||||||
|
|
||||||
|
logger.info(f"[INTERVIEW_ANALYSIS] Parsed dialogue_history length: {len(dialogue_history)}")
|
||||||
|
|
||||||
# Генерируем отчет
|
# Генерируем отчет
|
||||||
report = _generate_comprehensive_report(
|
report = _generate_comprehensive_report(
|
||||||
@ -140,11 +155,25 @@ def _get_interview_session(db, resume_id: int):
|
|||||||
try:
|
try:
|
||||||
from app.models.interview import InterviewSession
|
from app.models.interview import InterviewSession
|
||||||
|
|
||||||
return (
|
logger.info(f"[GET_SESSION] Looking for interview session with resume_id: {resume_id}")
|
||||||
|
|
||||||
|
session = (
|
||||||
db.query(InterviewSession)
|
db.query(InterviewSession)
|
||||||
.filter(InterviewSession.resume_id == resume_id)
|
.filter(InterviewSession.resume_id == resume_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if session:
|
||||||
|
logger.info(f"[GET_SESSION] Found session {session.id} for resume {resume_id}")
|
||||||
|
logger.info(f"[GET_SESSION] Session status: {session.status}")
|
||||||
|
logger.info(f"[GET_SESSION] Dialogue history type: {type(session.dialogue_history)}")
|
||||||
|
if session.dialogue_history:
|
||||||
|
logger.info(f"[GET_SESSION] Raw dialogue_history preview: {str(session.dialogue_history)[:200]}...")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[GET_SESSION] No session found for resume_id: {resume_id}")
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting interview session: {e}")
|
logger.error(f"Error getting interview session: {e}")
|
||||||
return None
|
return None
|
||||||
|
@ -1,202 +0,0 @@
|
|||||||
import psutil
|
|
||||||
|
|
||||||
from celery_worker.celery_app import celery_app
|
|
||||||
from celery_worker.database import get_sync_session
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True)
|
|
||||||
def cleanup_interview_processes_task(self):
|
|
||||||
"""
|
|
||||||
Периодическая задача очистки мертвых AI процессов
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.update_state(
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
cleaned_count = 0
|
|
||||||
total_sessions = len(active_sessions)
|
|
||||||
|
|
||||||
self.update_state(
|
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем прогресс
|
|
||||||
progress = 30 + (i + 1) / total_sessions * 60
|
|
||||||
self.update_state(
|
|
||||||
state="PROGRESS",
|
|
||||||
meta={
|
|
||||||
"status": f"Processed {i + 1}/{total_sessions} sessions...",
|
|
||||||
"progress": progress,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сохраняем изменения
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
self.update_state(
|
|
||||||
state="SUCCESS",
|
|
||||||
meta={
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.update_state(
|
|
||||||
state="FAILURE",
|
|
||||||
meta={
|
|
||||||
"status": f"Error during cleanup: {str(e)}",
|
|
||||||
"progress": 0,
|
|
||||||
"error": str(e),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True)
|
|
||||||
def force_kill_interview_process_task(self, session_id: int):
|
|
||||||
"""
|
|
||||||
Принудительное завершение AI процесса для сессии
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.update_state(
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not interview_session:
|
|
||||||
return {
|
|
||||||
"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}",
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update_state(
|
|
||||||
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},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"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": "Process was already dead, cleaned up database",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.update_state(
|
|
||||||
state="FAILURE",
|
|
||||||
meta={
|
|
||||||
"status": f"Error terminating process: {str(e)}",
|
|
||||||
"progress": 0,
|
|
||||||
"error": str(e),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
raise
|
|
Loading…
Reference in New Issue
Block a user