diff --git a/ai_interviewer_agent.py b/ai_interviewer_agent.py index a571263..dce6495 100644 --- a/ai_interviewer_agent.py +++ b/ai_interviewer_agent.py @@ -65,9 +65,8 @@ class InterviewAgent: self.interview_finalized = False # Флаг завершения интервью # Трекинг времени интервью - import time - - self.interview_start_time = time.time() + self.interview_start_time = None # Устанавливается при фактическом старте + self.interview_end_time = None # Устанавливается при завершении self.duration_minutes = interview_plan.get("interview_structure", {}).get( "duration_minutes", 10 ) @@ -77,10 +76,6 @@ class InterviewAgent: ) 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): @@ -126,11 +121,10 @@ class InterviewAgent: 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) + time_info = self.get_time_info() + elapsed_minutes = time_info["elapsed_minutes"] + remaining_minutes = time_info["remaining_minutes"] + time_percentage = time_info["time_percentage"] # Формируем план интервью для агента sections_info = "\n".join( @@ -193,7 +187,7 @@ class InterviewAgent: - Контактное лицо: {self.vacancy_data.get('contacts_name') or 'Не указано'}""" return f""" -Ты опытный HR-интервьюер, который проводит адаптивное голосовое собеседование. Представься контактным именем из вакансии (если оно есть) +Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься контактным именем из вакансии (если оно есть) ИНФОРМАЦИЯ О ВАКАНСИИ: @@ -233,6 +227,10 @@ class InterviewAgent: Проблемные / кейсы (20%) — проверить мышление и подход к решению. Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?" +Задавай вопросы кратко и понятно. Не вываливай кучу информации на человека. +Не перечисляй человеку все пункты и вопросы из секции. Предлагай один общий вопрос или задавай уточняющие по по очереди. +Ты должна спрашивать вопросы максимум в 3 предложения + ВРЕМЯ ИНТЕРВЬЮ: - Запланированная длительность: {self.duration_minutes} минут - Прошло времени: {elapsed_minutes:.1f} минут ({time_percentage:.0f}%) @@ -286,9 +284,17 @@ class InterviewAgent: """Получает информацию о времени интервью""" 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) + if self.interview_start_time is None: + # Интервью еще не началось + elapsed_minutes = 0.0 + remaining_minutes = float(self.duration_minutes) + time_percentage = 0.0 + else: + # Интервью идет + current_time = self.interview_end_time or time.time() + elapsed_minutes = (current_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, @@ -421,8 +427,9 @@ async def entrypoint(ctx: JobContext): # TTS tts = ( openai.TTS( - model="gpt-4o-mini-tts", + model="tts-1-hd", api_key=settings.openai_api_key, + voice='coral' ) if settings.openai_api_key else silero.TTS(language="ru", model="v4_ru") @@ -473,6 +480,18 @@ async def entrypoint(ctx: JobContext): return interviewer_instance.interview_finalized = True + + # Устанавливаем время завершения интервью + import time + interviewer_instance.interview_end_time = time.time() + + if interviewer_instance.interview_start_time: + total_minutes = (interviewer_instance.interview_end_time - interviewer_instance.interview_start_time) / 60 + logger.info( + f"[TIME] Interview ended at {time.strftime('%H:%M:%S')}, total duration: {total_minutes:.1f} min" + ) + else: + logger.info(f"[TIME] Interview ended at {time.strftime('%H:%M:%S')} (no start time recorded)") try: logger.info( @@ -543,11 +562,13 @@ async def entrypoint(ctx: JobContext): # --- Мониторинг команд завершения --- async def monitor_end_commands(): - """Мониторит команды завершения сессии""" + """Мониторит команды завершения сессии и лимит времени""" command_file = "agent_commands.json" + TIME_LIMIT_MINUTES = 60 # Жесткий лимит времени интервью 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) @@ -566,71 +587,50 @@ async def entrypoint(ctx: JobContext): ) break - await asyncio.sleep(1) # Проверяем каждые 1 секунды + # Проверяем превышение лимита времени + if interviewer.interview_start_time is not None: + time_info = interviewer.get_time_info() + if time_info["elapsed_minutes"] >= TIME_LIMIT_MINUTES: + logger.warning( + f"[TIME_LIMIT] Interview exceeded {TIME_LIMIT_MINUTES} minutes " + f"({time_info['elapsed_minutes']:.1f} min), forcing completion" + ) + + if not interviewer.interview_finalized: + await complete_interview_sequence( + ctx.room.name, interviewer + ) + break + + await asyncio.sleep(2) # Проверяем каждые 5 секунд except Exception as e: logger.error(f"[COMMAND] Error monitoring commands: {str(e)}") - await asyncio.sleep(5) + await asyncio.sleep(2) # Запускаем мониторинг команд в фоне asyncio.create_task(monitor_end_commands()) - # --- Обработчик состояния пользователя (замена мониторинга тишины) --- - disconnect_timer: asyncio.Task | None = None - @session.on("user_state_changed") def on_user_state_changed(event): """Обработчик изменения состояния пользователя (активен/неактивен)""" async def on_change(): - nonlocal disconnect_timer logger.info(f"[USER_STATE] User state changed to: {event.new_state}") - # === Пользователь начал говорить === - if event.new_state == "speaking": - # Если есть таймер на 30 секунд — отменяем его - if disconnect_timer is not None: - logger.info("[USER_STATE] Cancelling disconnect timer due to speaking") - disconnect_timer.cancel() - disconnect_timer = None - # === Пользователь молчит более 10 секунд (state == away) === - elif event.new_state == "away" and interviewer.intro_done: + if event.new_state == "away" and interviewer.intro_done: logger.info("[USER_STATE] User away detected, sending check-in message...") - # 1) Первое сообщение — проверка связи - handle = await session.generate_reply( + # сообщение — проверка связи + await session.generate_reply( instructions=( "Клиент молчит уже больше 10 секунд. " "Проверь связь фразой вроде 'Приём! Ты меня слышишь?' " "или 'Связь не пропала?'" ) ) - await handle # ждем завершения первой реплики - - # 2) Таймер на 30 секунд - async def disconnect_timeout(): - try: - await asyncio.sleep(30) - logger.info("[DISCONNECT_TIMER] 30 seconds passed, sending disconnect message") - - # Второе сообщение — считаем, что клиент отключился - await session.generate_reply( - instructions="Похоже клиент отключился" - ) - - logger.info("[DISCONNECT_TIMER] Disconnect message sent successfully") - except asyncio.CancelledError: - logger.info("[DISCONNECT_TIMER] Timer cancelled before completion") - except Exception as e: - logger.error(f"[DISCONNECT_TIMER] Error in disconnect timeout: {e}") - - # 3) Если уже есть активный таймер — отменяем его перед запуском нового - if disconnect_timer is not None: - disconnect_timer.cancel() - - disconnect_timer = asyncio.create_task(disconnect_timeout()) asyncio.create_task(on_change()) @@ -705,6 +705,12 @@ async def entrypoint(ctx: JobContext): # Обновляем прогресс интервью if not interviewer.intro_done: interviewer.intro_done = True + # Устанавливаем время начала интервью при первом сообщении + import time + interviewer.interview_start_time = time.time() + logger.info( + f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {interviewer.duration_minutes} min" + ) # Обновляем счетчик сообщений и треким время interviewer.questions_asked_total += 1 diff --git a/app/services/interview_service.py b/app/services/interview_service.py index 3038a17..ecdd1cd 100644 --- a/app/services/interview_service.py +++ b/app/services/interview_service.py @@ -233,7 +233,7 @@ class InterviewRoomService: # Если плана нет, создаем базовый план на основе имеющихся данных fallback_plan = { "interview_structure": { - "duration_minutes": 30, + "duration_minutes": 45, "greeting": f"Привет, {resume.applicant_name}! Готов к интервью?", "sections": [ { diff --git a/app/services/pdf_report_service.py b/app/services/pdf_report_service.py index 83edae3..18bfc07 100644 --- a/app/services/pdf_report_service.py +++ b/app/services/pdf_report_service.py @@ -28,6 +28,21 @@ class PDFReportService: self.styles = getSampleStyleSheet() self._setup_custom_styles() + def _format_list_field(self, field_value) -> str: + """Форматирует поле со списком для отображения в PDF""" + if not field_value: + return "—" + + if isinstance(field_value, list): + # Если это список, объединяем элементы + return "\n• ".join([""] + field_value) + elif isinstance(field_value, str): + # Если это строка, возвращаем как есть + return field_value + else: + # Для других типов конвертируем в строку + return str(field_value) + def _register_fonts(self): """Регистрация шрифтов для поддержки кириллицы""" try: @@ -212,31 +227,31 @@ class PDFReportService: Paragraph("Технические навыки", table_text_style), Paragraph(f"{report.technical_skills_score}/100", table_text_style), Paragraph(report.technical_skills_justification or "—", table_text_style), - Paragraph(report.technical_skills_concerns or "—", table_text_style), + Paragraph(self._format_list_field(report.technical_skills_concerns), table_text_style), ], [ Paragraph("Релевантность опыта", table_text_style), Paragraph(f"{report.experience_relevance_score}/100", table_text_style), Paragraph(report.experience_relevance_justification or "—", table_text_style), - Paragraph(report.experience_relevance_concerns or "—", table_text_style), + Paragraph(self._format_list_field(report.experience_relevance_concerns), table_text_style), ], [ Paragraph("Коммуникация", table_text_style), Paragraph(f"{report.communication_score}/100", table_text_style), Paragraph(report.communication_justification or "—", table_text_style), - Paragraph(report.communication_concerns or "—", table_text_style), + Paragraph(self._format_list_field(report.communication_concerns), table_text_style), ], [ Paragraph("Решение задач", table_text_style), Paragraph(f"{report.problem_solving_score}/100", table_text_style), Paragraph(report.problem_solving_justification or "—", table_text_style), - Paragraph(report.problem_solving_concerns or "—", table_text_style), + Paragraph(self._format_list_field(report.problem_solving_concerns), table_text_style), ], [ Paragraph("Культурное соответствие", table_text_style), Paragraph(f"{report.cultural_fit_score}/100", table_text_style), Paragraph(report.cultural_fit_justification or "—", table_text_style), - Paragraph(report.cultural_fit_concerns or "—", table_text_style), + Paragraph(self._format_list_field(report.cultural_fit_concerns), table_text_style), ], ] @@ -343,15 +358,15 @@ class PDFReportService: ) for weakness in report.weaknesses: story.append(Paragraph(f"• {weakness}", self.styles["CustomBodyText"])) - story.append(Spacer(1, 10)) + story.append(Spacer(1, 15)) # Красные флаги if report.red_flags: - story.append(Paragraph("Важные риски:", self.styles["SubHeader"])) + story.append(Paragraph("Красные флаги:", self.styles["SectionHeader"])) for red_flag in report.red_flags: story.append( Paragraph( - f"⚠ {red_flag}", + f"{red_flag}", ParagraphStyle( name="RedFlag", parent=self.styles["CustomBodyText"], diff --git a/celery_worker/interview_analysis_task.py b/celery_worker/interview_analysis_task.py index 6a4064f..19f1e75 100644 --- a/celery_worker/interview_analysis_task.py +++ b/celery_worker/interview_analysis_task.py @@ -385,19 +385,21 @@ def _call_openai_for_evaluation(context: str) -> dict | None: И топ 3 сильные/слабые стороны. +И red_flags (если есть): расхождение в стаже и опыте резюме и собеседования, шаблонные ответы, уклонение от вопросов + ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с обязательными полями: - scores: объект с 5 критериями, каждый содержит score, justification, concerns - overall_score: число от 0 до 100 (среднее арифметическое всех scores) - recommendation: одно из 4 значений выше - strengths: массив из 3 сильных сторон - weaknesses: массив из 3 слабых сторон +- red_flags: массив из красных флагов (если есть) """ response = openai.chat.completions.create( - model="gpt-4o-mini", + model="gpt-5-mini", messages=[{"role": "user", "content": evaluation_prompt}], response_format={"type": "json_object"}, - temperature=0.3, ) evaluation = json.loads(response.choices[0].message.content) @@ -617,6 +619,20 @@ async def _generate_and_upload_pdf_report( logger.error(f"[PDF_GENERATION] Error generating PDF report: {str(e)}") +def _format_concerns_field(concerns_data) -> str: + """Форматирует поле concerns для сохранения как строку""" + if not concerns_data: + return "" + + if isinstance(concerns_data, list): + # Если это массив, объединяем элементы через запятую с переносом строки + return "; ".join(concerns_data) + elif isinstance(concerns_data, str): + return concerns_data + else: + return str(concerns_data) + + def _create_report_from_dict( interview_session_id: int, report: dict ) -> "InterviewReport": @@ -633,8 +649,8 @@ def _create_report_from_dict( technical_skills_justification=scores.get("technical_skills", {}).get( "justification", "" ), - technical_skills_concerns=scores.get("technical_skills", {}).get( - "concerns", "" + technical_skills_concerns=_format_concerns_field( + scores.get("technical_skills", {}).get("concerns", "") ), experience_relevance_score=scores.get("experience_relevance", {}).get( "score", 0 @@ -642,24 +658,30 @@ def _create_report_from_dict( experience_relevance_justification=scores.get("experience_relevance", {}).get( "justification", "" ), - experience_relevance_concerns=scores.get("experience_relevance", {}).get( - "concerns", "" + experience_relevance_concerns=_format_concerns_field( + 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", ""), + communication_concerns=_format_concerns_field( + 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", ""), + problem_solving_concerns=_format_concerns_field( + 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", ""), + cultural_fit_concerns=_format_concerns_field( + scores.get("cultural_fit", {}).get("concerns", "") + ), # Агрегированные поля overall_score=report.get("overall_score", 0), recommendation=RecommendationType(report.get("recommendation", "reject")), @@ -693,8 +715,8 @@ def _update_report_from_dict(existing_report, report: dict): existing_report.technical_skills_justification = scores["technical_skills"].get( "justification", "" ) - existing_report.technical_skills_concerns = scores["technical_skills"].get( - "concerns", "" + existing_report.technical_skills_concerns = _format_concerns_field( + scores["technical_skills"].get("concerns", "") ) if "experience_relevance" in scores: @@ -704,17 +726,17 @@ def _update_report_from_dict(existing_report, report: dict): existing_report.experience_relevance_justification = scores[ "experience_relevance" ].get("justification", "") - existing_report.experience_relevance_concerns = scores[ - "experience_relevance" - ].get("concerns", "") + existing_report.experience_relevance_concerns = _format_concerns_field( + 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", "" + existing_report.communication_concerns = _format_concerns_field( + scores["communication"].get("concerns", "") ) if "problem_solving" in scores: @@ -724,8 +746,8 @@ def _update_report_from_dict(existing_report, report: dict): existing_report.problem_solving_justification = scores["problem_solving"].get( "justification", "" ) - existing_report.problem_solving_concerns = scores["problem_solving"].get( - "concerns", "" + existing_report.problem_solving_concerns = _format_concerns_field( + scores["problem_solving"].get("concerns", "") ) if "cultural_fit" in scores: @@ -733,8 +755,8 @@ def _update_report_from_dict(existing_report, report: dict): existing_report.cultural_fit_justification = scores["cultural_fit"].get( "justification", "" ) - existing_report.cultural_fit_concerns = scores["cultural_fit"].get( - "concerns", "" + existing_report.cultural_fit_concerns = _format_concerns_field( + scores["cultural_fit"].get("concerns", "") ) # Агрегированные поля diff --git a/celery_worker/tasks.py b/celery_worker/tasks.py index eec173f..53b4b80 100644 --- a/celery_worker/tasks.py +++ b/celery_worker/tasks.py @@ -62,15 +62,15 @@ def generate_interview_plan( compatibility_prompt = f""" Проанализируй соответствие кандидата вакансии и определи, стоит ли проводить интервью. - КЛЮЧЕВОЙ И ЕДИНСТВЕННЫй КРИТЕРИЙ ОТКЛОНЕНИЯ: - 1. Профессиональная область кандидата: Полное несоответствие сферы деятельности вакансии (иначе 100 за критерий) - ДОПУСТИМЫЕ КРИТЕРИИ: - 2. Остальные показатели кандидата хотя бы примерно соответствуют вакансии: скиллы кандидата похожи или смежны вакансионным, опыт не сильно отдален - от указанного - 3. Учитывай опыт с аналогичными, похожими, смежными технологиями - 4. Когда смотришь на вакансию и кандидата не учитывай строгие слова, такие как "Требования", "Ключевые" и тп. Это лишь маркеры, + КЛЮЧЕВЫЕ КРИТЕРИИ ОТКЛОНЕНИЯ: + 1. Несоответствие профессиональной сферы — опыт и навыки кандидата не относятся к области деятельности, связанной с вакансией. + 2. Несоответствие уровня и фокуса позиции — текущая или предыдущая должность кандидата существенно отличается по направлению или уровню ответственности от требований вакансии. Допускаются смежные переходы (например, переход из fullstack в frontend или переход кандидата уровня senior на позицию middle/junior). + КЛЮЧЕВЫЕ КРИТЕРИИ ДОПУСКА: + 3. Остальные показатели кандидата примерно соответствуют вакансии: скиллы кандидата похожи или смежны вакансионным, опыт попадает в указанных промежуток + 4. Учитывай опыт с аналогичными, похожими, смежными технологиями + 5. Когда смотришь на вакансию и кандидата не учитывай строгие слова, такие как "Требования", "Ключевые" и тп. Это лишь маркеры, но не оценочные указатели - 5. Если есть спорные вопросы соответствия, лучше допустить к собеседованию и уточнить их там + 6. Если есть спорные вопросы соответствия, лучше допустить к собеседованию и уточнить их там КАНДИДАТ: - Имя: {combined_data.get("name", "Не указано")} @@ -134,7 +134,7 @@ def generate_interview_plan( # Если кандидат подходит - генерируем план интервью plan_prompt = f""" - Создай детальный план интервью для кандидата на основе его резюме и требований вакансии. + Создай детальный план интервью для кандидата на основе его резюме и требований вакансии на 45 МИНУТ. РЕЗЮМЕ КАНДИДАТА: - Имя: {combined_data.get("name", "Не указано")} @@ -182,7 +182,7 @@ def generate_interview_plan( "Опыт командной работы", "Мотивация к изучению нового" ], - "red_flags_to_check": [], + "red_flags_to_check": [Шаблонные ответы, уклонения от вопросов, расхождение в стаже], "personalization_notes": "Кандидат имеет хороший технический опыт" }} """