upd promts; fix reports

This commit is contained in:
Даниил Ивлев 2025-09-08 14:07:09 +05:00
parent 8d449af338
commit 9128bb8881
5 changed files with 141 additions and 98 deletions

View File

@ -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

View File

@ -233,7 +233,7 @@ class InterviewRoomService:
# Если плана нет, создаем базовый план на основе имеющихся данных
fallback_plan = {
"interview_structure": {
"duration_minutes": 30,
"duration_minutes": 45,
"greeting": f"Привет, {resume.applicant_name}! Готов к интервью?",
"sections": [
{

View File

@ -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"],

View File

@ -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", "")
)
# Агрегированные поля

View File

@ -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": "Кандидат имеет хороший технический опыт"
}}
"""